mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 12:25:44 -06:00
Compare commits
20 Commits
@papra/app
...
@papra/doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b | ||
|
|
afdcc1c5ba | ||
|
|
92daaa35bb | ||
|
|
e4295e14ab | ||
|
|
ae37d1db36 | ||
|
|
a7464f8b89 | ||
|
|
2dd9ca9835 | ||
|
|
54cc14052c | ||
|
|
f930e46dde | ||
|
|
df75e5accb | ||
|
|
f66a9f5d1b | ||
|
|
c5b337f3bb | ||
|
|
bb1ba3e15e | ||
|
|
ce839c4127 | ||
|
|
8aabd28168 | ||
|
|
1a7a14b3ed | ||
|
|
17cebde051 |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'papra-hq/papra'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -22,4 +22,10 @@ export default antfu({
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
files: ['src/locales/*.dictionary.ts'],
|
||||
rules: {
|
||||
// Sometimes for formatting amounts of dollar, we need "${{value}}" as value is interpolated later, it's not a template string here
|
||||
'no-template-curly-in-string': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,8 +65,19 @@
|
||||
</script>
|
||||
|
||||
<style>.sr-only {position: absolute;width: 1px;height: 1px;padding: 0;margin: -1px;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border-width: 0;}</style>
|
||||
|
||||
<!-- Prevent flash of wrong theme on load -->
|
||||
<script>
|
||||
(function () {
|
||||
const stored = localStorage?.getItem('papra_color_mode') ?? 'dark';
|
||||
|
||||
if (stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-background text-foreground">
|
||||
<h1 class="sr-only">Papra - Document archiving and sharing platform</h1>
|
||||
<p class="sr-only">Papra, the document archiving and sharing platform.</p>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.81.2",
|
||||
"@tanstack/solid-query": "^5.90.3",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
@@ -44,11 +44,10 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-js-lite": "^4.1.5",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.7.1",
|
||||
@@ -60,16 +59,15 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.2.19",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"unocss": "66.5.3",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-solid": "^2.11.9",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
@@ -28,17 +28,15 @@ render(
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<Suspense>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
@@ -60,9 +58,9 @@ render(
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
document.getElementById('root')!,
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
||||
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
||||
'layout.upgrade-cta.button': 'Auf Plus upgraden',
|
||||
'layout.upgrade-cta.button': 'Jetzt upgraden',
|
||||
|
||||
'layout.theme.light': 'Heller Modus',
|
||||
'layout.theme.dark': 'Dunkler Modus',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Auf Plus upgraden',
|
||||
'subscriptions.upgrade-dialog.title': 'Diese Organisation upgraden',
|
||||
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
|
||||
'subscriptions.upgrade-dialog.per-month': '/Monat',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} jährlich abgerechnet',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
|
||||
|
||||
'subscriptions.plan.free.name': 'Kostenloser Plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
|
||||
'subscriptions.features.members': 'Organisationsmitglieder',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Community-Support',
|
||||
'subscriptions.features.support-email': 'E-Mail-Support',
|
||||
'subscriptions.features.support-priority': 'Prioritäts-Support',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Monatlich',
|
||||
'subscriptions.billing-interval.annual': 'Jährlich',
|
||||
|
||||
@@ -549,7 +549,7 @@ export const translations = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Need more space?',
|
||||
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
|
||||
'layout.upgrade-cta.button': 'Upgrade to Plus',
|
||||
'layout.upgrade-cta.button': 'Upgrade now',
|
||||
|
||||
'layout.theme.light': 'Light mode',
|
||||
'layout.theme.dark': 'Dark mode',
|
||||
@@ -625,17 +625,19 @@ export const translations = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Need help?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contact support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade to Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade this organization',
|
||||
'subscriptions.upgrade-dialog.description': 'Unlock powerful features for your organization',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contact us',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'if you need custom enterprise plans.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Current Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommended',
|
||||
'subscriptions.upgrade-dialog.per-month': '/month',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} billed annually',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
|
||||
|
||||
'subscriptions.plan.free.name': 'Free plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Document storage size',
|
||||
'subscriptions.features.members': 'Organization Members',
|
||||
@@ -647,6 +649,7 @@ export const translations = {
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Community support',
|
||||
'subscriptions.features.support-email': 'Email support',
|
||||
'subscriptions.features.support-priority': 'Priority support',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Monthly',
|
||||
'subscriptions.billing-interval.annual': 'Annual',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': '¿Necesitas más espacio?',
|
||||
'layout.upgrade-cta.description': 'Obtén 10x más almacenamiento + colaboración en equipo',
|
||||
'layout.upgrade-cta.button': 'Actualizar a Plus',
|
||||
'layout.upgrade-cta.button': 'Actualizar ahora',
|
||||
|
||||
'layout.theme.light': 'Modo claro',
|
||||
'layout.theme.dark': 'Modo oscuro',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': '¿Necesitas ayuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar soporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Actualizar a Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Actualizar esta organización',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloquea funciones poderosas para tu organización',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contáctanos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si necesitas planes empresariales personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mes',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamaño de almacenamiento de documentos',
|
||||
'subscriptions.features.members': 'Miembros de la organización',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Soporte',
|
||||
'subscriptions.features.support-community': 'Soporte de la comunidad',
|
||||
'subscriptions.features.support-email': 'Soporte por correo',
|
||||
'subscriptions.features.support-priority': 'Soporte prioritario',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensual',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
|
||||
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
|
||||
'layout.upgrade-cta.button': 'Passer à Plus',
|
||||
'layout.upgrade-cta.button': 'Mettre à niveau maintenant',
|
||||
|
||||
'layout.theme.light': 'Mode clair',
|
||||
'layout.theme.dark': 'Mode sombre',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Besoin d\'aide ?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contacter le support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Passer à Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Mettre à niveau cette organisation',
|
||||
'subscriptions.upgrade-dialog.description': 'Débloquez des fonctionnalités puissantes pour votre organisation',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contactez-nous',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si vous avez besoin de plans d\'entreprise personnalisés.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actuel',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommandé',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mois',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturé annuellement',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Taille de stockage de documents',
|
||||
'subscriptions.features.members': 'Membres de l\'organisation',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Support communautaire',
|
||||
'subscriptions.features.support-email': 'Support par email',
|
||||
'subscriptions.features.support-priority': 'Support prioritaire',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensuel',
|
||||
'subscriptions.billing-interval.annual': 'Annuel',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Serve più spazio?',
|
||||
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
|
||||
'layout.upgrade-cta.button': 'Aggiorna a Plus',
|
||||
'layout.upgrade-cta.button': 'Aggiorna ora',
|
||||
|
||||
'layout.theme.light': 'Modalità chiara',
|
||||
'layout.theme.dark': 'Modalità scura',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Hai bisogno di aiuto?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatta il supporto',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Passa a Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Aggiorna questa organizzazione',
|
||||
'subscriptions.upgrade-dialog.description': 'Sblocca funzionalità potenti per la tua organizzazione',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contattaci',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se hai bisogno di piani aziendali personalizzati.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Piano attuale',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Consigliato',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mese',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} fatturato annualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
|
||||
|
||||
'subscriptions.plan.free.name': 'Piano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensione archiviazione documenti',
|
||||
'subscriptions.features.members': 'Membri dell\'organizzazione',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Supporto',
|
||||
'subscriptions.features.support-community': 'Supporto della comunità',
|
||||
'subscriptions.features.support-email': 'Supporto via email',
|
||||
'subscriptions.features.support-priority': 'Supporto prioritario',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensile',
|
||||
'subscriptions.billing-interval.annual': 'Annuale',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
|
||||
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
|
||||
'layout.upgrade-cta.button': 'Przejdź na Plus',
|
||||
'layout.upgrade-cta.button': 'Ulepsz teraz',
|
||||
|
||||
'layout.theme.light': 'Tryb jasny',
|
||||
'layout.theme.dark': 'Tryb ciemny',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Potrzebujesz pomocy?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Skontaktuj się z pomocą techniczną',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Przejdź na Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Ulepsz tę organizację',
|
||||
'subscriptions.upgrade-dialog.description': 'Odblokuj zaawansowane funkcje dla swojej organizacji',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Skontaktuj się z nami',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'jeśli potrzebujesz niestandardowych planów biznesowych.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Obecny plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Polecane',
|
||||
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} rozliczane rocznie',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan darmowy',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Rozmiar przechowywania dokumentów',
|
||||
'subscriptions.features.members': 'Członkowie organizacji',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Wsparcie',
|
||||
'subscriptions.features.support-community': 'Wsparcie społeczności',
|
||||
'subscriptions.features.support-email': 'Wsparcie e-mail',
|
||||
'subscriptions.features.support-priority': 'Wsparcie priorytetowe',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Miesięcznie',
|
||||
'subscriptions.billing-interval.annual': 'Rocznie',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
|
||||
'layout.upgrade-cta.button': 'Atualizar para Plus',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Fazer upgrade para Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Entre em contato',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se você precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Suporte',
|
||||
'subscriptions.features.support-community': 'Suporte da comunidade',
|
||||
'subscriptions.features.support-email': 'Suporte por e-mail',
|
||||
'subscriptions.features.support-priority': 'Suporte prioritário',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensal',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
@@ -551,7 +551,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
|
||||
'layout.upgrade-cta.button': 'Actualizar para Plus',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
@@ -627,17 +627,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar para Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para a sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contacte-nos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Suporte',
|
||||
'subscriptions.features.support-community': 'Suporte da comunidade',
|
||||
'subscriptions.features.support-email': 'Suporte por e-mail',
|
||||
'subscriptions.features.support-priority': 'Suporte prioritário',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensal',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
@@ -634,10 +634,12 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan curent',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomandat',
|
||||
'subscriptions.upgrade-dialog.per-month': '/lună',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturat anual',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensiune stocare documente',
|
||||
'subscriptions.features.members': 'Membri ai organizației',
|
||||
@@ -649,6 +651,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Asistență',
|
||||
'subscriptions.features.support-community': 'Asistență comunitate',
|
||||
'subscriptions.features.support-email': 'Asistență email',
|
||||
'subscriptions.features.support-priority': 'Asistență prioritară',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Lunar',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Config } from '../config/config';
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
export function isAuthErrorWithCode({ error, code }: { error: unknown; code: string }) {
|
||||
return get(error, 'code') === code;
|
||||
return get(error, ['code']) === code;
|
||||
}
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
|
||||
const enabledSsoProviders: SsoProviderConfig[] = [
|
||||
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
|
||||
...ssoProviders.filter(({ key }) => config.auth.providers[key]?.isEnabled),
|
||||
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
|
||||
key: providerId,
|
||||
name: providerName,
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { Accessor, ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
import { getDocumentIcon } from '../documents/document.models';
|
||||
import { searchDocuments } from '../documents/documents.services';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { cn } from '../shared/style/cn';
|
||||
import { debounce } from '../shared/utils/timing';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { deepMerge } from '../shared/utils/object';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
@@ -31,7 +31,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
return merge({}, buildTimeConfig, runtimeConfig);
|
||||
return deepMerge(buildTimeConfig, runtimeConfig);
|
||||
};
|
||||
|
||||
const retry = async () => {
|
||||
|
||||
@@ -40,10 +40,7 @@ export const buildTimeConfig = {
|
||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||
},
|
||||
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
||||
documentsStorage: {
|
||||
maxUploadSize: asNumber(import.meta.env.VITE_DOCUMENTS_STORAGE_MAX_UPLOAD_SIZE, 10 * 1024 * 1024),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof buildTimeConfig;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'documentsStorage' | 'intakeEmails' | 'organizations'>;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { defineHandler } from './demo-api-mock.models';
|
||||
import {
|
||||
apiKeyStorage,
|
||||
@@ -341,9 +341,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const tag = {
|
||||
id: createId({ prefix: 'tag' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
color: get(body, 'color'),
|
||||
description: get(body, 'description'),
|
||||
name: get(body, ['name']) as string,
|
||||
color: get(body, ['color']) as string,
|
||||
description: (get(body, ['description']) ?? null) as string | null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -396,7 +396,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tagId = get(body, 'tagId');
|
||||
const tagId = get(body, ['tagId']) as string;
|
||||
|
||||
assert(tagId, { status: 400 });
|
||||
|
||||
@@ -441,7 +441,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
handler: async ({ body }) => {
|
||||
const organization = {
|
||||
id: createId({ prefix: 'org' }),
|
||||
name: get(body, 'name'),
|
||||
name: get(body, ['name']) as string,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -480,7 +480,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
organization.name = get(body, 'name');
|
||||
organization.name = get(body, ['name']) as string;
|
||||
organization.updatedAt = new Date();
|
||||
|
||||
await organizationStorage.setItem(organizationId, organization);
|
||||
@@ -506,10 +506,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const taggingRule = {
|
||||
id: createId({ prefix: 'tr' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
description: get(body, 'description'),
|
||||
conditions: get(body, 'conditions'),
|
||||
actions: get(body, 'tagIds').map((tagId: string) => ({ tagId })),
|
||||
name: get(body, ['name']) as string,
|
||||
description: (get(body, ['description']) ?? '') as string,
|
||||
conditions: get(body, ['conditions']) as any,
|
||||
actions: (get(body, ['tagIds']) as string[]).map((tagId: string) => ({ tagId })),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -641,11 +641,11 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
const apiKey = {
|
||||
id: createId({ prefix: 'apiKey' }),
|
||||
name: get(body, 'name'),
|
||||
permissions: get(body, 'permissions'),
|
||||
organizationIds: get(body, 'organizationIds'),
|
||||
allOrganizations: get(body, 'allOrganizations'),
|
||||
expiresAt: get(body, 'expiresAt'),
|
||||
name: get(body, ['name']),
|
||||
permissions: get(body, ['permissions']),
|
||||
organizationIds: get(body, ['organizationIds']),
|
||||
allOrganizations: get(body, ['allOrganizations']),
|
||||
expiresAt: get(body, ['expiresAt']),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
prefix: token.slice(0, 11),
|
||||
@@ -694,10 +694,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const webhook: Webhook = {
|
||||
id: createId({ prefix: 'webhook' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
url: get(body, 'url'),
|
||||
name: get(body, ['name']) as string,
|
||||
url: get(body, ['url']) as string,
|
||||
enabled: true,
|
||||
events: get(body, 'events'),
|
||||
events: get(body, ['events']) as Webhook['events'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -761,6 +761,72 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
return { document: newDocument };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/subscription',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
// Demo mode uses free plan with no subscription
|
||||
return {
|
||||
subscription: null,
|
||||
plan: {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/usage',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId);
|
||||
|
||||
const totalDocumentsSize = documents.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
const deletedDocumentsSize = documents
|
||||
.filter(doc => doc.deletedAt)
|
||||
.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
|
||||
return {
|
||||
usage: {
|
||||
documentsStorage: {
|
||||
used: totalDocumentsSize,
|
||||
deleted: deletedDocumentsSize,
|
||||
limit: 1024 * 1024 * 500, // 500 MiB
|
||||
},
|
||||
intakeEmailsCount: {
|
||||
used: 0,
|
||||
limit: 1,
|
||||
},
|
||||
membersCount: {
|
||||
used: 1,
|
||||
limit: 3,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -2,23 +2,24 @@ import type { ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { throttle } from '@/modules/shared/utils/timing';
|
||||
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
uploadDocuments: (args: { files: File[] }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
|
||||
export function useDocumentUpload() {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -28,11 +29,11 @@ export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: ()
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId: getOrganizationId() });
|
||||
await uploadDocuments({ files });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -54,11 +55,10 @@ type Task = TaskSuccess | TaskError | {
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
export const DocumentUploadProvider: ParentComponent<{ organizationId: string }> = (props) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const { t } = useI18n();
|
||||
const { config } = useConfig();
|
||||
|
||||
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
|
||||
const [getTasks, setTasks] = createSignal<Task[]>([]);
|
||||
@@ -67,20 +67,33 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
|
||||
};
|
||||
|
||||
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
|
||||
const organizationLimitsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'subscription'],
|
||||
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const uploadDocuments = async ({ files }: { files: File[] }) => {
|
||||
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
|
||||
setState('open');
|
||||
|
||||
if (!organizationLimitsQuery.data) {
|
||||
await organizationLimitsQuery.promise;
|
||||
}
|
||||
|
||||
// Optimistic prevent upload if file is too large, the server will still validate it
|
||||
const maxUploadSize = organizationLimitsQuery.data?.plan.limits.maxFileSize;
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
const { maxUploadSize } = config.documentsStorage;
|
||||
updateTaskStatus({ file, status: 'uploading' });
|
||||
|
||||
if (maxUploadSize > 0 && file.size > maxUploadSize) {
|
||||
// maxUploadSize can also be null when self hosting which means no limit
|
||||
if (maxUploadSize && file.size > maxUploadSize) {
|
||||
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
|
||||
return;
|
||||
}
|
||||
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId: props.organizationId }));
|
||||
|
||||
if (error) {
|
||||
updateTaskStatus({ file, status: 'error', error });
|
||||
@@ -90,7 +103,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
updateTaskStatus({ file, status: 'success', document });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
throttledInvalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { useDocumentUpload } from './document-import-status.component';
|
||||
|
||||
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
|
||||
export const DocumentUploadArea: Component = () => {
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const params = useParams();
|
||||
|
||||
const getOrganizationId = () => props.organizationId ?? params.organizationId;
|
||||
|
||||
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId });
|
||||
const { promptImport, uploadDocuments } = useDocumentUpload();
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { icons as tablerIconSet } from '@iconify-json/tabler';
|
||||
import { values } from 'lodash-es';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
|
||||
|
||||
describe('files models', () => {
|
||||
describe('iconByFileType', () => {
|
||||
const icons = values(iconByFileType);
|
||||
const icons = Object.values(iconByFileType);
|
||||
|
||||
test('they must at least have the default icon', () => {
|
||||
expect(iconByFileType['*']).toBeDefined();
|
||||
|
||||
@@ -168,7 +168,16 @@ export async function searchDocuments({
|
||||
}
|
||||
|
||||
export async function getOrganizationDocumentsStats({ organizationId }: { organizationId: string }) {
|
||||
const { organizationStats } = await apiClient<{ organizationStats: { documentsCount: number; documentsSize: number } }>({
|
||||
const { organizationStats } = await apiClient<{
|
||||
organizationStats: {
|
||||
documentsCount: number;
|
||||
documentsSize: number;
|
||||
deletedDocumentsSize: number;
|
||||
deletedDocumentsCount: number;
|
||||
totalDocumentsCount: number;
|
||||
totalDocumentsSize: number;
|
||||
};
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/statistics`,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Component, JSX } from 'solid-js';
|
||||
import type { DocumentActivity } from '../documents.types';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
@@ -122,17 +122,14 @@ export const DocumentPage: Component = () => {
|
||||
setSearchParams({ tab: getTab() }, { replace: true });
|
||||
});
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
const documentQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const documentFileQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
@@ -160,14 +157,14 @@ export const DocumentPage: Component = () => {
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
if (!documentQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasDeleted } = await deleteDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
documentName: queries[0].data.document.name,
|
||||
documentName: documentQuery.data.document.name,
|
||||
});
|
||||
|
||||
if (!hasDeleted) {
|
||||
@@ -177,13 +174,13 @@ export const DocumentPage: Component = () => {
|
||||
navigate(`/organizations/${params.organizationId}/documents`);
|
||||
};
|
||||
|
||||
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
|
||||
const getDataUrl = () => documentFileQuery.data ? URL.createObjectURL(documentFileQuery.data) : undefined;
|
||||
|
||||
return (
|
||||
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
|
||||
<Suspense>
|
||||
<div class="md:flex-1 md:border-r">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
@@ -390,7 +387,7 @@ export const DocumentPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-50vh">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<DocumentPreview document={getDocument()} />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { castArray } from '@/modules/shared/utils/array';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
@@ -19,37 +18,30 @@ export const DocumentsPage: Component = () => {
|
||||
|
||||
const getFiltererTagIds = () => searchParams.tags ? castArray(searchParams.tags) : [];
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => query[2].data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const tagsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => tagsQuery.data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const hasFilters = () => getFiltererTagIds().length > 0;
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0 && !hasFilters()
|
||||
{documentsQuery.data?.documents?.length === 0 && !hasFilters()
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
@@ -83,15 +75,15 @@ export const DocumentsPage: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<Show when={hasFilters() && documentsQuery.data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
{t('documents.list.no-results')}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
documents={documentsQuery.data?.documents ?? []}
|
||||
documentsCount={documentsQuery.data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
|
||||
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
|
||||
@@ -15,29 +15,26 @@ export const OrganizationPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'stats'],
|
||||
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
|
||||
const statsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'stats'],
|
||||
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const { promptImport } = useDocumentUpload();
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0
|
||||
{documentsQuery.data?.documents?.length === 0
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
@@ -62,7 +59,7 @@ export const OrganizationPage: Component = () => {
|
||||
{t('organizations.details.upload-documents')}
|
||||
</Button>
|
||||
|
||||
<Show when={query[1].data?.organizationStats}>
|
||||
<Show when={statsQuery.data?.organizationStats}>
|
||||
{organizationStats => (
|
||||
<>
|
||||
<div class="border rounded-lg p-2 flex items-center gap-4 py-4 px-6">
|
||||
@@ -96,8 +93,8 @@ export const OrganizationPage: Component = () => {
|
||||
</h2>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
documents={documentsQuery.data?.documents ?? []}
|
||||
documentsCount={documentsQuery.data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
export const PRO_PLAN_ID = 'pro';
|
||||
|
||||
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type PlanLimits = {
|
||||
maxDocumentStorageBytes: number | null;
|
||||
maxIntakeEmailsCount: number | null;
|
||||
maxOrganizationsMembersCount: number | null;
|
||||
maxFileSize: number | null;
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FetchError } from 'ofetch';
|
||||
import { get } from 'lodash-es';
|
||||
import { getErrorStatus } from '../utils/errors';
|
||||
|
||||
export function shouldRefreshAuthTokens({ error }: { error: FetchError | unknown | undefined }) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return get(error, 'status') === 401;
|
||||
return getErrorStatus(error) === 401;
|
||||
}
|
||||
|
||||
export function buildAuthHeader({ accessToken }: { accessToken?: string | null | undefined } = {}): Record<string, string> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { castError } from '@corentinth/chisels';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { get } from '@/modules/shared/utils/get';
|
||||
|
||||
function codeToKey(code: string): TranslationKeys {
|
||||
// Better auth may returns different error codes like INVALID_ORIGIN, INVALID_CALLBACKURL when the origin is invalid
|
||||
@@ -23,9 +24,9 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
||||
}
|
||||
|
||||
if ('error' in args) {
|
||||
const { error } = args;
|
||||
const code = get(error, 'data.error.code') ?? get(error, 'code');
|
||||
const translation = code ? t(codeToKey(code)) : undefined;
|
||||
const error = castError(args.error);
|
||||
const code = get(error, ['data', 'error', 'code'], ['code']);
|
||||
const translation = code && typeof code === 'string' ? t(codeToKey(code)) : undefined;
|
||||
|
||||
if (translation) {
|
||||
return translation;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { get } from 'lodash-es';
|
||||
import { get } from '../utils/get';
|
||||
|
||||
export { isHttpErrorWithCode, isHttpErrorWithStatusCode, isRateLimitError };
|
||||
|
||||
function isHttpErrorWithCode({ error, code }: { error: unknown; code: string }) {
|
||||
return get(error, 'data.error.code') === code;
|
||||
return get(error, ['data', 'error', 'code']) === code;
|
||||
}
|
||||
|
||||
function isHttpErrorWithStatusCode({ error, statusCode }: { error: unknown; statusCode: number }) {
|
||||
return get(error, 'status') === statusCode;
|
||||
return get(error, ['status']) === statusCode;
|
||||
}
|
||||
|
||||
function isRateLimitError({ error }: { error: unknown }) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { castError } from '@corentinth/chisels';
|
||||
import { identity } from 'lodash-es';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createHook } from '../hooks/hooks';
|
||||
|
||||
export { useAsyncState };
|
||||
|
||||
function useAsyncState<Args, Return, ReturnFormatted = Return>(
|
||||
getter: (args: Args) => Promise<Return>,
|
||||
{ initialData, formatValue = identity, immediate = false }: { initialData?: ReturnFormatted; formatValue?: (value: Return) => ReturnFormatted; immediate?: boolean } = {},
|
||||
) {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
const [getError, setError] = createSignal<Error | undefined>(undefined);
|
||||
const [getData, setData] = createSignal<ReturnFormatted | undefined>(initialData);
|
||||
const [getStatus, setStatus] = createSignal<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const successHook = createHook<{ data: Return; args: Args }>();
|
||||
const errorHook = createHook<{ error: Error; args: Args }>();
|
||||
const finishHook = createHook<{ data: ReturnFormatted | undefined; error: Error | undefined; args: Args }>();
|
||||
|
||||
const execute = async (args: Args) => {
|
||||
setIsLoading(true);
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const data = await getter(args);
|
||||
|
||||
// eslint-disable-next-line ts/no-unsafe-function-type
|
||||
setData(formatValue(data) as Exclude<ReturnFormatted, Function>);
|
||||
setError(undefined);
|
||||
setStatus('success');
|
||||
successHook.trigger({ data, args });
|
||||
return data;
|
||||
} catch (err) {
|
||||
const error = castError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
setData(undefined);
|
||||
setError(error);
|
||||
setStatus('error');
|
||||
errorHook.trigger({ error, args });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
finishHook.trigger({ data: getData(), error: getError(), args });
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
execute({} as Args);
|
||||
}
|
||||
|
||||
return {
|
||||
getIsLoading,
|
||||
getError,
|
||||
getData,
|
||||
getStatus,
|
||||
execute,
|
||||
onSuccess: successHook.on,
|
||||
onError: errorHook.on,
|
||||
onFinish: finishHook.on,
|
||||
};
|
||||
}
|
||||
21
apps/papra-client/src/modules/shared/utils/array.test.ts
Normal file
21
apps/papra-client/src/modules/shared/utils/array.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { castArray } from './array';
|
||||
|
||||
describe('array', () => {
|
||||
describe('castArray', () => {
|
||||
test('wraps non-array values in an array', () => {
|
||||
expect(castArray(5)).toEqual([5]);
|
||||
expect(castArray('hello')).toEqual(['hello']);
|
||||
expect(castArray({ key: 'value' })).toEqual([{ key: 'value' }]);
|
||||
});
|
||||
|
||||
test('returns the same array if an array is provided', () => {
|
||||
expect(castArray([1, 2, 3])).toEqual([1, 2, 3]);
|
||||
expect(castArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||
expect(castArray([])).toEqual([]);
|
||||
|
||||
const objArray = [{ key: 'value1' }, { key: 'value2' }];
|
||||
expect(castArray(objArray)).toEqual(objArray);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
apps/papra-client/src/modules/shared/utils/array.ts
Normal file
3
apps/papra-client/src/modules/shared/utils/array.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function castArray<T>(value: T | T[]): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
9
apps/papra-client/src/modules/shared/utils/errors.ts
Normal file
9
apps/papra-client/src/modules/shared/utils/errors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isRecord } from './object';
|
||||
|
||||
export function getErrorStatus(error: unknown): number | undefined {
|
||||
if (isRecord(error) && 'status' in error && typeof error.status === 'number') {
|
||||
return error.status;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
160
apps/papra-client/src/modules/shared/utils/get.test.ts
Normal file
160
apps/papra-client/src/modules/shared/utils/get.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { get } from './get';
|
||||
|
||||
describe('get', () => {
|
||||
test('gets value from simple path', () => {
|
||||
const obj = { user: { name: 'John', age: 30 } };
|
||||
|
||||
expect(get(obj, ['user', 'name'])).toBe('John');
|
||||
expect(get(obj, ['user', 'age'])).toBe(30);
|
||||
});
|
||||
|
||||
test('returns undefined for missing path', () => {
|
||||
const obj = { user: { name: 'John' } };
|
||||
|
||||
expect(get(obj, ['user', 'missing'])).toBeUndefined();
|
||||
expect(get(obj, ['missing', 'path'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for null/undefined objects', () => {
|
||||
expect(get(null, ['any'])).toBeUndefined();
|
||||
expect(get(undefined, ['any'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for non-object values', () => {
|
||||
expect(get('string', ['any'])).toBeUndefined();
|
||||
expect(get(123, ['any'])).toBeUndefined();
|
||||
expect(get(true, ['any'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles deeply nested paths', () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(get(obj, ['level1', 'level2', 'level3', 'value'])).toBe('deep');
|
||||
});
|
||||
|
||||
test('returns first non-undefined value from multiple paths', () => {
|
||||
const obj = {
|
||||
primary: undefined,
|
||||
secondary: 'fallback',
|
||||
tertiary: 'another',
|
||||
};
|
||||
|
||||
expect(get(obj, ['missing'], ['secondary'])).toBe('fallback');
|
||||
expect(get(obj, ['primary'], ['secondary'])).toBe('fallback');
|
||||
expect(get(obj, ['missing'], ['another-missing'], ['tertiary'])).toBe('another');
|
||||
});
|
||||
|
||||
test('returns undefined if all paths are undefined', () => {
|
||||
const obj = { a: 1, b: 2 };
|
||||
|
||||
expect(get(obj, ['x'], ['y'], ['z'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles paths with undefined intermediate values', () => {
|
||||
const obj = {
|
||||
user: {
|
||||
profile: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
expect(get(obj, ['user', 'profile', 'name'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles arrays in paths', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{ name: 'John' },
|
||||
{ name: 'Jane' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(get(obj, ['users', '0', 'name'])).toBe('John');
|
||||
expect(get(obj, ['users', '1', 'name'])).toBe('Jane');
|
||||
});
|
||||
|
||||
test('handles empty path array', () => {
|
||||
const obj = { value: 'test' };
|
||||
|
||||
expect(get(obj, [])).toBe(obj);
|
||||
});
|
||||
|
||||
test('works with numeric keys', () => {
|
||||
const obj = {
|
||||
123: { value: 'numeric key' },
|
||||
};
|
||||
|
||||
expect(get(obj, ['123', 'value'])).toBe('numeric key');
|
||||
});
|
||||
|
||||
test('handles objects with null prototype', () => {
|
||||
const obj = Object.create(null);
|
||||
obj.key = 'value';
|
||||
|
||||
expect(get(obj, ['key'])).toBe('value');
|
||||
});
|
||||
|
||||
test('returns value even if it is falsy (but not undefined)', () => {
|
||||
const obj = {
|
||||
zero: 0,
|
||||
emptyString: '',
|
||||
false: false,
|
||||
null: null,
|
||||
};
|
||||
|
||||
expect(get(obj, ['zero'])).toBe(0);
|
||||
expect(get(obj, ['emptyString'])).toBe('');
|
||||
expect(get(obj, ['false'])).toBe(false);
|
||||
expect(get(obj, ['null'])).toBe(null);
|
||||
});
|
||||
|
||||
test('stops at first non-undefined value in fallback chain', () => {
|
||||
const obj = {
|
||||
first: undefined,
|
||||
second: null, // null is not undefined
|
||||
third: 'value',
|
||||
};
|
||||
|
||||
expect(get(obj, ['first'], ['second'], ['third'])).toBe(null);
|
||||
});
|
||||
|
||||
test('handles complex real-world scenario', () => {
|
||||
const apiResponse = {
|
||||
data: {
|
||||
user: {
|
||||
profile: {
|
||||
contact: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
fallbackEmail: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
// Try to get user email, fallback to admin email
|
||||
expect(
|
||||
get(apiResponse, ['data', 'user', 'profile', 'contact', 'email'], ['meta', 'fallbackEmail']),
|
||||
).toBe('user@example.com');
|
||||
|
||||
// If user email is missing, get admin email
|
||||
const apiResponseWithoutUserEmail = {
|
||||
data: { user: {} },
|
||||
meta: { fallbackEmail: 'admin@example.com' },
|
||||
};
|
||||
|
||||
expect(
|
||||
get(apiResponseWithoutUserEmail, ['data', 'user', 'profile', 'contact', 'email'], ['meta', 'fallbackEmail']),
|
||||
).toBe('admin@example.com');
|
||||
});
|
||||
});
|
||||
42
apps/papra-client/src/modules/shared/utils/get.ts
Normal file
42
apps/papra-client/src/modules/shared/utils/get.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { isObject } from './object';
|
||||
|
||||
/**
|
||||
* Gets a value from an object at the specified path.
|
||||
* If multiple paths are provided, returns the first non-undefined value.
|
||||
*
|
||||
* @param obj - The object to query
|
||||
* @param paths - One or more paths as string arrays (e.g., ['user', 'name'])
|
||||
* @returns The value at the path, or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* const obj = { user: { name: 'John', age: 30 }, fallback: 'default' };
|
||||
*
|
||||
* get(obj, ['user', 'name']); // 'John'
|
||||
* get(obj, ['user', 'missing']); // undefined
|
||||
* get(obj, ['missing'], ['fallback']); // 'default' (first non-undefined)
|
||||
* get(obj, ['a', 'b', 'c']); // undefined
|
||||
*/
|
||||
export function get(obj: unknown, ...paths: string[][]): unknown {
|
||||
if (!isObject(obj)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
let current: any = obj;
|
||||
|
||||
for (const key of path) {
|
||||
if (isObject(current) && key in current) {
|
||||
current = (current as Record<string, any>)[key];
|
||||
} else {
|
||||
current = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current !== undefined) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
231
apps/papra-client/src/modules/shared/utils/object.test.ts
Normal file
231
apps/papra-client/src/modules/shared/utils/object.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { deepMerge } from './object';
|
||||
|
||||
describe('object utilities', () => {
|
||||
describe('deepMerge', () => {
|
||||
test('merges two flat objects', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { y: 3, z: 4 };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ x: 1, y: 3, z: 4 });
|
||||
});
|
||||
|
||||
test('does not mutate original objects', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { y: 3, z: 4 };
|
||||
|
||||
deepMerge(a, b);
|
||||
|
||||
expect(a).toEqual({ x: 1, y: 2 });
|
||||
expect(b).toEqual({ y: 3, z: 4 });
|
||||
});
|
||||
|
||||
test('creates deep copies without shared references', () => {
|
||||
const a = { nested: { value: 1 }, arr: [1, 2, 3] };
|
||||
const b = { other: 'test' };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
// Mutate the result
|
||||
result.nested.value = 999;
|
||||
result.arr.push(4);
|
||||
|
||||
// Original should be unchanged
|
||||
expect(a.nested.value).toBe(1);
|
||||
expect(a.arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('deeply merges nested objects', () => {
|
||||
const a = { nested: { a: 1, b: 2 }, x: 1 };
|
||||
const b = { nested: { b: 3, c: 4 }, y: 2 };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
nested: { a: 1, b: 3, c: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test('handles multiple levels of nesting', () => {
|
||||
const a = {
|
||||
level1: {
|
||||
level2: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
},
|
||||
x: 1,
|
||||
},
|
||||
};
|
||||
const b = {
|
||||
level1: {
|
||||
level2: {
|
||||
b: 3,
|
||||
c: 4,
|
||||
},
|
||||
y: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
a: 1,
|
||||
b: 3,
|
||||
c: 4,
|
||||
},
|
||||
x: 1,
|
||||
y: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces arrays instead of merging them', () => {
|
||||
const a = { arr: [1, 2, 3] };
|
||||
const b = { arr: [4, 5] };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ arr: [4, 5] });
|
||||
});
|
||||
|
||||
test('replaces primitives with objects', () => {
|
||||
const a = { value: 'string' };
|
||||
const b = { value: { nested: 'object' } };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ value: { nested: 'object' } });
|
||||
});
|
||||
|
||||
test('replaces objects with primitives', () => {
|
||||
const a = { value: { nested: 'object' } };
|
||||
const b = { value: 'string' };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ value: 'string' });
|
||||
});
|
||||
|
||||
test('handles null values', () => {
|
||||
const a = { value: { nested: 'object' } };
|
||||
const b = { value: null };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ value: null });
|
||||
});
|
||||
|
||||
test('handles undefined values', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { y: undefined, z: 3 };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ x: 1, y: undefined, z: 3 });
|
||||
});
|
||||
|
||||
test('handles empty objects', () => {
|
||||
const a = { x: 1 };
|
||||
const b = {};
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
test('merges into empty object', () => {
|
||||
const a = {};
|
||||
const b = { x: 1, y: 2 };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test('does not merge Date objects', () => {
|
||||
const date1 = new Date('2024-01-01');
|
||||
const date2 = new Date('2024-12-31');
|
||||
|
||||
const a = { date: date1 };
|
||||
const b = { date: date2 };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result.date).toBe(date2);
|
||||
});
|
||||
|
||||
test('merges class instances as plain objects', () => {
|
||||
class MyClass {
|
||||
constructor(public value: number) {}
|
||||
}
|
||||
|
||||
const a = { instance: new MyClass(1) };
|
||||
const b = { instance: new MyClass(2) };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
// Class instances that look like plain objects get merged
|
||||
expect(result.instance.value).toBe(2);
|
||||
});
|
||||
|
||||
test('preserves type information', () => {
|
||||
const a = { x: 1 as number };
|
||||
const b = { y: 'hello' as string };
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
// Type assertion to verify TypeScript types
|
||||
const x: number = result.x;
|
||||
const y: string = result.y;
|
||||
|
||||
expect(x).toBe(1);
|
||||
expect(y).toBe('hello');
|
||||
});
|
||||
|
||||
test('handles complex nested structure', () => {
|
||||
const a = {
|
||||
config: {
|
||||
api: {
|
||||
baseUrl: 'https://old.com',
|
||||
timeout: 5000,
|
||||
},
|
||||
features: {
|
||||
darkMode: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const b = {
|
||||
config: {
|
||||
api: {
|
||||
baseUrl: 'https://new.com',
|
||||
},
|
||||
features: {
|
||||
experimental: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = deepMerge(a, b);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
api: {
|
||||
baseUrl: 'https://new.com',
|
||||
timeout: 5000,
|
||||
},
|
||||
features: {
|
||||
darkMode: false,
|
||||
experimental: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
66
apps/papra-client/src/modules/shared/utils/object.ts
Normal file
66
apps/papra-client/src/modules/shared/utils/object.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Expand } from '@corentinth/chisels';
|
||||
|
||||
export function isObject(value: unknown): value is object {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a plain record object (not a Date, Array, or other special object)
|
||||
*/
|
||||
export function isRecord(obj: unknown): obj is Record<string, unknown> {
|
||||
return (
|
||||
isObject(obj)
|
||||
&& !Array.isArray(obj)
|
||||
// Exclude Date objects and RegExp objects, and other built-in types
|
||||
&& Object.prototype.toString.call(obj) === '[object Object]'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges multiple objects into a new object.
|
||||
* Later objects override properties from earlier objects.
|
||||
* Only plain objects are merged recursively - arrays and other types are replaced.
|
||||
*
|
||||
* @param objects - Objects to merge
|
||||
* @returns A new merged object
|
||||
*
|
||||
* @example
|
||||
* const a = { x: 1, nested: { a: 1 } };
|
||||
* const b = { y: 2, nested: { b: 2 } };
|
||||
* const c = { z: 3, nested: { c: 3 } };
|
||||
* const result = deepMerge(a, b, c);
|
||||
* // { x: 1, y: 2, z: 3, nested: { a: 1, b: 2, c: 3 } }
|
||||
*/
|
||||
export function deepMerge<T extends Record<string, any>[]>(
|
||||
...objects: T
|
||||
): T extends [infer First, ...infer Rest]
|
||||
? Rest extends Record<string, any>[]
|
||||
? Expand<First & DeepMergeAll<Rest>>
|
||||
: First
|
||||
: Record<string, unknown> {
|
||||
return objects.reduce((prev, obj) => {
|
||||
const result = { ...prev };
|
||||
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const pVal = result[key];
|
||||
const oVal = obj[key];
|
||||
|
||||
if (isRecord(pVal) && isRecord(oVal)) {
|
||||
result[key] = deepMerge(pVal, oVal);
|
||||
} else if (isRecord(oVal)) {
|
||||
result[key] = deepMerge({}, oVal);
|
||||
} else if (Array.isArray(oVal)) {
|
||||
result[key] = [...oVal];
|
||||
} else {
|
||||
result[key] = oVal;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, {}) as any;
|
||||
}
|
||||
|
||||
// Helper type for merging multiple objects
|
||||
type DeepMergeAll<T extends readonly any[]> = T extends [infer First, ...infer Rest]
|
||||
? First & DeepMergeAll<Rest>
|
||||
: unknown;
|
||||
312
apps/papra-client/src/modules/shared/utils/timing.test.ts
Normal file
312
apps/papra-client/src/modules/shared/utils/timing.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { debounce, throttle } from './timing';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('delays function execution until after wait time', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc();
|
||||
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('only executes the last call when invoked multiple times rapidly', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc('first');
|
||||
debouncedFunc('second');
|
||||
debouncedFunc('third');
|
||||
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
test('executes multiple times if calls are spaced out beyond wait time', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 60);
|
||||
|
||||
debouncedFunc('first');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('first');
|
||||
|
||||
debouncedFunc('second');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(func).toHaveBeenCalledTimes(2);
|
||||
expect(func).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
test('preserves function arguments correctly', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc('arg1', 'arg2', 'arg3');
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(func).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
|
||||
});
|
||||
|
||||
test('works with async functions', () => {
|
||||
const asyncFunc = vi.fn(async (value: string) => {
|
||||
return `processed-${value}`;
|
||||
});
|
||||
|
||||
const debouncedFunc = debounce(asyncFunc, 100);
|
||||
|
||||
debouncedFunc('test');
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(asyncFunc).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFunc).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
test('works with functions that have multiple parameter types', () => {
|
||||
const func = vi.fn((str: string, num: number, obj: { key: string }) => {
|
||||
return { str, num, obj };
|
||||
});
|
||||
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc('hello', 42, { key: 'value' });
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(func).toHaveBeenCalledWith('hello', 42, { key: 'value' });
|
||||
});
|
||||
|
||||
test('handles zero wait time', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 0);
|
||||
|
||||
debouncedFunc('test');
|
||||
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
test('cancels previous timeout when called again before wait time', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc('first');
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
debouncedFunc('second');
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
// First call should be cancelled
|
||||
expect(func).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(60);
|
||||
|
||||
// Only second call should execute
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
test('works with no arguments', () => {
|
||||
const func = vi.fn();
|
||||
const debouncedFunc = debounce(func, 100);
|
||||
|
||||
debouncedFunc();
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('preserves type safety for function parameters', () => {
|
||||
// Type-only test - this will fail TypeScript compilation if types are wrong
|
||||
const typedFunc = (name: string, age: number) => ({ name, age });
|
||||
const debouncedTypedFunc = debounce(typedFunc, 100);
|
||||
|
||||
// This should compile without errors
|
||||
debouncedTypedFunc('John', 30);
|
||||
|
||||
// These should cause TypeScript errors (commented out):
|
||||
// debouncedTypedFunc(123, 'wrong'); // Wrong argument types
|
||||
// debouncedTypedFunc('John'); // Missing argument
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('invokes function immediately on first call', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('test');
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
test('ignores rapid calls within wait period', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('first');
|
||||
throttledFunc('second');
|
||||
throttledFunc('third');
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith('first');
|
||||
});
|
||||
|
||||
test('invokes function again after wait period', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('first');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
throttledFunc('second');
|
||||
expect(func).toHaveBeenCalledTimes(2);
|
||||
expect(func).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
test('schedules trailing call if invoked during wait period', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('first');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
throttledFunc('second');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should invoke after remaining wait time
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(func).toHaveBeenCalledTimes(2);
|
||||
expect(func).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
test('uses latest arguments for trailing call', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('first');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(30);
|
||||
throttledFunc('second');
|
||||
|
||||
vi.advanceTimersByTime(30);
|
||||
throttledFunc('third');
|
||||
|
||||
vi.advanceTimersByTime(40);
|
||||
expect(func).toHaveBeenCalledTimes(2);
|
||||
expect(func).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
test('works with multiple parameter types', () => {
|
||||
const func = vi.fn((str: string, num: number, obj: { key: string }) => {
|
||||
return { str, num, obj };
|
||||
});
|
||||
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc('hello', 42, { key: 'value' });
|
||||
|
||||
expect(func).toHaveBeenCalledWith('hello', 42, { key: 'value' });
|
||||
});
|
||||
|
||||
test('works with async functions', () => {
|
||||
const asyncFunc = vi.fn(async (value: string) => {
|
||||
return `processed-${value}`;
|
||||
});
|
||||
|
||||
const throttledFunc = throttle(asyncFunc, 100);
|
||||
|
||||
throttledFunc('test');
|
||||
|
||||
expect(asyncFunc).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFunc).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
test('works with no arguments', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
throttledFunc();
|
||||
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
expect(func).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('preserves type safety for function parameters', () => {
|
||||
// Type-only test - this will fail TypeScript compilation if types are wrong
|
||||
const typedFunc = (name: string, age: number) => ({ name, age });
|
||||
const throttledTypedFunc = throttle(typedFunc, 100);
|
||||
|
||||
// This should compile without errors
|
||||
throttledTypedFunc('John', 30);
|
||||
|
||||
// These should cause TypeScript errors (commented out):
|
||||
// throttledTypedFunc(123, 'wrong'); // Wrong argument types
|
||||
// throttledTypedFunc('John'); // Missing argument
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('handles multiple invocations over time correctly', () => {
|
||||
const func = vi.fn();
|
||||
const throttledFunc = throttle(func, 100);
|
||||
|
||||
// First call - immediate
|
||||
throttledFunc('call1');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Within wait period - scheduled
|
||||
vi.advanceTimersByTime(50);
|
||||
throttledFunc('call2');
|
||||
expect(func).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Complete wait period - trailing call executes
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(func).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Wait full period before next call
|
||||
vi.advanceTimersByTime(100);
|
||||
throttledFunc('call3');
|
||||
expect(func).toHaveBeenCalledTimes(3);
|
||||
expect(func).toHaveBeenCalledWith('call3');
|
||||
});
|
||||
});
|
||||
87
apps/papra-client/src/modules/shared/utils/timing.ts
Normal file
87
apps/papra-client/src/modules/shared/utils/timing.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Creates a debounced function that delays invoking func until after wait milliseconds
|
||||
* have elapsed since the last time the debounced function was invoked.
|
||||
*
|
||||
* @param func - The function to debounce
|
||||
* @param waitMs - The number of milliseconds to delay
|
||||
* @returns The debounced function
|
||||
*
|
||||
* @example
|
||||
* const search = debounce(async (query: string) => {
|
||||
* const results = await searchAPI(query);
|
||||
* return results;
|
||||
* }, 300);
|
||||
*
|
||||
* search('hello'); // Only the last call within 300ms will execute
|
||||
*/
|
||||
export function debounce<Args extends unknown[], Return>(
|
||||
func: (...args: Args) => Return,
|
||||
waitMs: number = 500,
|
||||
): (...args: Args) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: Args): void => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
func(...args);
|
||||
timeoutId = undefined;
|
||||
}, waitMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled function that only invokes func at most once per every wait milliseconds.
|
||||
* The throttled function will invoke func on the leading edge of the timeout.
|
||||
*
|
||||
* @param func - The function to throttle
|
||||
* @param waitMs - The number of milliseconds to throttle invocations to
|
||||
* @returns The throttled function
|
||||
*
|
||||
* @example
|
||||
* const handleScroll = throttle(() => {
|
||||
* console.log('Scroll event');
|
||||
* }, 100);
|
||||
*
|
||||
* window.addEventListener('scroll', handleScroll);
|
||||
*/
|
||||
export function throttle<Args extends unknown[], Return>(
|
||||
func: (...args: Args) => Return,
|
||||
waitMs: number = 500,
|
||||
): (...args: Args) => void {
|
||||
let lastCallTime: number | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: Args): void => {
|
||||
const now = Date.now();
|
||||
|
||||
if (lastCallTime === undefined) {
|
||||
// First call - invoke immediately
|
||||
func(...args);
|
||||
lastCallTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceLastCall = now - lastCallTime;
|
||||
|
||||
if (timeSinceLastCall >= waitMs) {
|
||||
// Enough time has passed - invoke immediately
|
||||
func(...args);
|
||||
lastCallTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule invocation for when the wait period ends
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
func(...args);
|
||||
lastCallTime = Date.now();
|
||||
timeoutId = undefined;
|
||||
}, waitMs - timeSinceLastCall);
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,22 @@ import type { Component, JSX } from 'solid-js';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { PLUS_PLAN_ID } from '@/modules/plans/plans.constants';
|
||||
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '@/modules/plans/plans.constants';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
import { getCheckoutUrl } from '../subscriptions.services';
|
||||
|
||||
// Hardcoded global reduction configuration, will be replaced by a dynamic configuration later
|
||||
const globalReduction = {
|
||||
enabled: true,
|
||||
multiplier: 0.5,
|
||||
// 31 december 2025 23h59 Paris time
|
||||
untilDate: new Date('2025-12-31T22:59:59Z'),
|
||||
};
|
||||
|
||||
type BillingInterval = 'monthly' | 'annual';
|
||||
|
||||
type PlanCardProps = {
|
||||
name: string;
|
||||
features: {
|
||||
@@ -20,8 +30,10 @@ type PlanCardProps = {
|
||||
};
|
||||
isRecommended?: boolean;
|
||||
isCurrent?: boolean;
|
||||
price: number;
|
||||
onUpgrade?: () => Promise<void>;
|
||||
billingInterval: BillingInterval;
|
||||
monthlyPrice: number;
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
@@ -66,21 +78,47 @@ const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
setIsUpgradeLoading(false);
|
||||
};
|
||||
|
||||
const getIsReductionActive = ({ now = new Date() }: { now?: Date } = {}) => globalReduction.enabled && now < globalReduction.untilDate;
|
||||
const getReductionMultiplier = ({ now = new Date() }: { now?: Date } = {}) => getIsReductionActive({ now }) ? globalReduction.multiplier : 1;
|
||||
|
||||
const getMonthlyPrice = ({ now = new Date() }: { now?: Date } = {}) => {
|
||||
const multiplier = getReductionMultiplier({ now });
|
||||
const basePrice = props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice;
|
||||
|
||||
return Math.round(100 * basePrice * multiplier) / 100;
|
||||
};
|
||||
|
||||
const getAnnualPrice = () => {
|
||||
const multiplier = getReductionMultiplier();
|
||||
return Math.round(100 * props.annualPrice * multiplier) / 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="border rounded-xl">
|
||||
<div class="p-4">
|
||||
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between">
|
||||
<span>{props.name}</span>
|
||||
{props.isCurrent && <span class="text-xs font-medium text-muted-foreground bg-muted rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.current-plan')}</span>}
|
||||
{props.isRecommended && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.recommended')}</div>}
|
||||
</div>
|
||||
<div class="text-xl font-semibold flex items-center gap-2">
|
||||
$
|
||||
{props.price}
|
||||
<span class="text-sm font-normal text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between mb-1">
|
||||
<span class="min-h-24px">{props.name}</span>
|
||||
{getIsReductionActive() && props.annualPrice > 0 && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{`-${100 * (1 - getReductionMultiplier())}%`}</div>}
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
{getIsReductionActive() && props.annualPrice > 0 && (
|
||||
<span class="text-lg text-muted-foreground relative after:(content-[''] absolute left--5px right--5px top-1/2 h-2px bg-muted-foreground/40 rounded-full -rotate-12 origin-center)">{`$${(props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice)}`}</span>
|
||||
)}
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-semibold">{`$${getMonthlyPrice()}`}</span>
|
||||
<span class="text-sm text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
props.annualPrice > 0 && (
|
||||
<div class="overflow-hidden transition-all duration-300" style={{ 'max-height': props.billingInterval === 'annual' ? '24px' : '0px', 'opacity': props.billingInterval === 'annual' ? '1' : '0' }}>
|
||||
<span class="text-xs text-muted-foreground">{t('subscriptions.upgrade-dialog.billed-annually', { price: getAnnualPrice() })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<hr class="my-6" />
|
||||
|
||||
<div class="flex flex-col gap-3 ">
|
||||
{featureItems.map(feature => (
|
||||
@@ -100,7 +138,7 @@ const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
|
||||
{ props.onUpgrade && (
|
||||
<>
|
||||
<hr class="my-4" />
|
||||
<hr class="my-6" />
|
||||
|
||||
<Button onClick={upgrade} class="w-full" autofocus isLoading={getIsUpgradeLoading()}>
|
||||
{t('subscriptions.upgrade-dialog.upgrade-now')}
|
||||
@@ -121,11 +159,11 @@ type UpgradeDialogProps = {
|
||||
export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [getIsOpen, setIsOpen] = createSignal(false);
|
||||
const defaultBillingInterval: 'monthly' | 'annual' = 'annual';
|
||||
const [getBillingInterval, setBillingInterval] = createSignal<'monthly' | 'annual'>(defaultBillingInterval);
|
||||
const defaultBillingInterval: BillingInterval = 'annual';
|
||||
const [getBillingInterval, setBillingInterval] = createSignal<BillingInterval>(defaultBillingInterval);
|
||||
|
||||
const onUpgrade = async () => {
|
||||
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId: PLUS_PLAN_ID, billingInterval: getBillingInterval() });
|
||||
const onUpgrade = async (planId: string) => {
|
||||
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId, billingInterval: getBillingInterval() });
|
||||
window.location.href = checkoutUrl;
|
||||
};
|
||||
|
||||
@@ -158,14 +196,23 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const getPlanPrice = (plan: { monthlyPrice: number; annualPrice: number }) => {
|
||||
return getBillingInterval() === 'monthly' ? plan.monthlyPrice : Math.round(100 * plan.annualPrice / 12) / 100;
|
||||
const proPlan = {
|
||||
name: t('subscriptions.plan.pro.name'),
|
||||
monthlyPrice: 30,
|
||||
annualPrice: 300,
|
||||
features: {
|
||||
storageSize: 50,
|
||||
members: 50,
|
||||
emailIntakes: 100,
|
||||
maxUploadSize: 500,
|
||||
support: t('subscriptions.features.support-priority'),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={getIsOpen()} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger as={props.children} />
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogContent class="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
@@ -193,27 +240,25 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class={cn('text-sm pr-1.5', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
|
||||
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
>
|
||||
{t('subscriptions.billing-interval.annual')}
|
||||
<span class="ml-2 text-xs text-muted-foreground rounded bg-primary/10 text-primary px-1 py-0.5">-20%</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 ">
|
||||
<div>
|
||||
<PlanCard {...currentPlan} price={getPlanPrice(currentPlan)} />
|
||||
|
||||
<p class="text-muted-foreground text-xs p-4 ml-1">
|
||||
<a href="https://papra.app/contact" class="underline" target="_blank" rel="noreferrer">{t('subscriptions.upgrade-dialog.contact-us')}</a>
|
||||
{' '}
|
||||
{t('subscriptions.upgrade-dialog.enterprise-plans')}
|
||||
</p>
|
||||
</div>
|
||||
<PlanCard {...plusPlan} onUpgrade={onUpgrade} price={getPlanPrice(plusPlan)} />
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<PlanCard {...currentPlan} billingInterval={getBillingInterval()} />
|
||||
<PlanCard {...plusPlan} onUpgrade={() => onUpgrade(PLUS_PLAN_ID)} billingInterval={getBillingInterval()} />
|
||||
<PlanCard {...proPlan} onUpgrade={() => onUpgrade(PRO_PLAN_ID)} billingInterval={getBillingInterval()} />
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-xs text-center mt-2">
|
||||
<a href="https://papra.app/contact" class="underline" target="_blank" rel="noreferrer">{t('subscriptions.upgrade-dialog.contact-us')}</a>
|
||||
{' '}
|
||||
{t('subscriptions.upgrade-dialog.enterprise-plans')}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -24,11 +24,11 @@ export const UsageWarningCard: Component<{ organizationId: string }> = (props) =
|
||||
const getStorageSizeUsedPercent = () => {
|
||||
const { data: usageData } = query;
|
||||
|
||||
if (!usageData || usageData.limits.maxDocumentsSize === null) {
|
||||
if (!usageData || usageData.usage.documentsStorage.limit === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
|
||||
return (usageData.usage.documentsStorage.used / usageData.usage.documentsStorage.limit) * 100;
|
||||
};
|
||||
|
||||
const shouldShow = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlanLimits } from '../plans/plans.types';
|
||||
import type { OrganizationSubscription } from './subscriptions.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
|
||||
@@ -24,7 +25,14 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
|
||||
}
|
||||
|
||||
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
|
||||
const { subscription, plan } = await apiClient<{ subscription: OrganizationSubscription; plan: { id: string; name: string } }>({
|
||||
const { subscription, plan } = await apiClient<{
|
||||
subscription: OrganizationSubscription;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
limits: PlanLimits;
|
||||
};
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/subscription`,
|
||||
});
|
||||
@@ -35,11 +43,11 @@ export async function fetchOrganizationSubscription({ organizationId }: { organi
|
||||
export async function fetchOrganizationUsage({ organizationId }: { organizationId: string }) {
|
||||
const { usage, limits } = await apiClient<{
|
||||
usage: {
|
||||
documentsStorage: { used: number; limit: number | null };
|
||||
documentsStorage: { used: number; deleted: number; limit: number | null };
|
||||
intakeEmailsCount: { used: number; limit: number | null };
|
||||
membersCount: { used: number; limit: number | null };
|
||||
};
|
||||
limits: { maxDocumentsSize: number | null; maxIntakeEmailsCount: number | null; maxOrganizationsMembersCount: number | null };
|
||||
limits: PlanLimits;
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/usage`,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import posthog from 'posthog-js';
|
||||
import { PostHog } from 'posthog-js-lite';
|
||||
import { buildTimeConfig, isDev } from '../config/config';
|
||||
|
||||
type TrackingServices = {
|
||||
capture: (args: {
|
||||
event: string;
|
||||
properties?: Record<string, unknown>;
|
||||
properties?: Record<string, string | number | boolean>;
|
||||
}) => void;
|
||||
|
||||
reset: () => void;
|
||||
@@ -38,13 +38,7 @@ function createTrackingServices(): TrackingServices {
|
||||
return dummyTrackingServices;
|
||||
}
|
||||
|
||||
posthog.init(
|
||||
apiKey,
|
||||
{
|
||||
api_host: host,
|
||||
capture_pageview: false,
|
||||
},
|
||||
);
|
||||
const posthog = new PostHog(apiKey, { host });
|
||||
|
||||
return {
|
||||
capture: ({ event, properties }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
SelectItemProps,
|
||||
SelectTriggerProps,
|
||||
} from '@kobalte/core/select';
|
||||
import type { ParentProps, ValidComponent } from 'solid-js';
|
||||
import type { JSX, ParentProps, ValidComponent } from 'solid-js';
|
||||
import { Select as SelectPrimitive } from '@kobalte/core/select';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
@@ -17,12 +17,13 @@ export const SelectItemDescription = SelectPrimitive.ItemDescription;
|
||||
export const SelectHiddenSelect = SelectPrimitive.HiddenSelect;
|
||||
export const SelectSection = SelectPrimitive.Section;
|
||||
|
||||
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<SelectTriggerProps<T> & { class?: string }>;
|
||||
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<SelectTriggerProps<T> & { class?: string; caretIcon?: JSX.Element }>;
|
||||
|
||||
export function SelectTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, selectTriggerProps<T>>) {
|
||||
const [local, rest] = splitProps(props as selectTriggerProps, [
|
||||
'class',
|
||||
'children',
|
||||
'caretIcon',
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -34,23 +35,27 @@ export function SelectTrigger<T extends ValidComponent = 'button'>(props: Polymo
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<SelectPrimitive.Icon
|
||||
as="svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
class="size-4 opacity-50 flex items-center justify-center"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
{local.caretIcon !== undefined
|
||||
? local.caretIcon
|
||||
: (
|
||||
<SelectPrimitive.Icon
|
||||
as="svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
class="size-4 opacity-50 flex items-center justify-center"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
|
||||
<SideNav
|
||||
mainMenu={getNavigationItems()}
|
||||
|
||||
@@ -3,13 +3,13 @@ import type { Component, ParentComponent } from 'solid-js';
|
||||
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { createQueries, useQuery } from '@tanstack/solid-query';
|
||||
import { get } from 'lodash-es';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, on, Show } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization, fetchOrganizations } from '@/modules/organizations/organizations.services';
|
||||
import { getErrorStatus } from '@/modules/shared/utils/errors';
|
||||
import { UpgradeDialog } from '@/modules/subscriptions/components/upgrade-dialog.component';
|
||||
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { Button } from '../components/button';
|
||||
@@ -27,7 +27,7 @@ const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
|
||||
const { config } = useConfig();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', 'subscription'],
|
||||
queryKey: ['organizations', props.organizationId, 'subscription'],
|
||||
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -40,27 +40,28 @@ const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={shouldShowUpgradeCTA()}>
|
||||
<div>
|
||||
<Show when={shouldShowUpgradeCTA()}>
|
||||
|
||||
<div class="p-4 mx-4 mt-4 bg-background bg-gradient-to-br from-primary/15 to-transparent rounded-lg">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<div class="i-tabler-sparkles size-4 text-primary"></div>
|
||||
{t('layout.upgrade-cta.title')}
|
||||
<div class="p-4 mx-4 mt-4 bg-background bg-gradient-to-br from-primary/15 to-transparent rounded-lg">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<div class="i-tabler-sparkles size-4 text-primary"></div>
|
||||
{t('layout.upgrade-cta.title')}
|
||||
</div>
|
||||
<div class="text-xs mt-1 mb-3 text-muted-foreground">
|
||||
{t('layout.upgrade-cta.description')}
|
||||
</div>
|
||||
<UpgradeDialog organizationId={props.organizationId}>
|
||||
{dialogProps => (
|
||||
<Button size="sm" class="w-full font-semibold" {...dialogProps}>
|
||||
{t('layout.upgrade-cta.button')}
|
||||
<div class="i-tabler-arrow-right size-4 ml-1"></div>
|
||||
</Button>
|
||||
)}
|
||||
</UpgradeDialog>
|
||||
</div>
|
||||
<div class="text-xs mt-1 mb-3 text-muted-foreground">
|
||||
{t('layout.upgrade-cta.description')}
|
||||
</div>
|
||||
<UpgradeDialog organizationId={props.organizationId}>
|
||||
{dialogProps => (
|
||||
<Button size="sm" class="w-full font-semibold" {...dialogProps}>
|
||||
{t('layout.upgrade-cta.button')}
|
||||
<div class="i-tabler-arrow-right size-4 ml-1"></div>
|
||||
</Button>
|
||||
)}
|
||||
</UpgradeDialog>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,24 +112,21 @@ const OrganizationLayoutSideNav: Component = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
const organizationsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
const organizationQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
createEffect(on(
|
||||
() => queries[1].error,
|
||||
() => organizationQuery.error,
|
||||
(error) => {
|
||||
if (error) {
|
||||
const status = get(error, 'status');
|
||||
const status = getErrorStatus(error);
|
||||
|
||||
if (status && [
|
||||
400, // when the id of the organization is not valid
|
||||
@@ -147,12 +145,13 @@ const OrganizationLayoutSideNav: Component = () => {
|
||||
footer={() => <UpgradeCTAFooter organizationId={params.organizationId} />}
|
||||
header={() =>
|
||||
(
|
||||
<div class="px-6 pt-4 max-w-285px min-w-0">
|
||||
<div class="p-4 pb-0 min-w-0 max-w-full">
|
||||
<Select
|
||||
options={[...queries[0].data?.organizations ?? [], { id: 'create' }]}
|
||||
class="w-full"
|
||||
options={[...organizationsQuery.data?.organizations ?? [], { id: 'create' }]}
|
||||
optionValue="id"
|
||||
optionTextValue="name"
|
||||
value={queries[0].data?.organizations.find(organization => organization.id === params.organizationId)}
|
||||
value={organizationsQuery.data?.organizations.find(organization => organization.id === params.organizationId)}
|
||||
onChange={(value) => {
|
||||
if (!value || value.id === params.organizationId) {
|
||||
return;
|
||||
@@ -176,11 +175,23 @@ const OrganizationLayoutSideNav: Component = () => {
|
||||
<SelectItem class="cursor-pointer" item={props.item}>{props.item.rawValue.name}</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue<Organization> class="truncate">
|
||||
{state => state.selectedOption().name}
|
||||
<SelectTrigger class="hover:bg-accent/50 transition rounded-lg h-auto pl-2" caretIcon={<div class="i-tabler-chevron-down size-4 opacity-50 ml-2 flex-shrink-0" />}>
|
||||
<SelectValue<Organization | undefined> class="flex items-center gap-2 min-w-0">
|
||||
{state => (
|
||||
<>
|
||||
<span class="p-1.5 rounded text-lg font-bold flex items-center bg-muted light:border dark:bg-primary/10 text-primary transition flex-shrink-0">
|
||||
<div class="i-tabler-file-text size-5.5"></div>
|
||||
</span>
|
||||
|
||||
<span class="truncate text-base font-medium">
|
||||
{state.selectedOption()?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent />
|
||||
</Select>
|
||||
|
||||
@@ -204,7 +215,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
||||
() => query.error,
|
||||
(error) => {
|
||||
if (error) {
|
||||
const status = get(error, 'status');
|
||||
const status = getErrorStatus(error);
|
||||
|
||||
if (status && [401, 403].includes(status)) {
|
||||
navigate('/');
|
||||
@@ -214,7 +225,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
||||
));
|
||||
|
||||
return (
|
||||
<DocumentUploadProvider>
|
||||
<DocumentUploadProvider organizationId={params.organizationId}>
|
||||
<SidenavLayout
|
||||
children={props.children}
|
||||
sideNav={OrganizationLayoutSideNav}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SettingsLayout: ParentComponent = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
|
||||
<SideNav
|
||||
mainMenu={getMainMenuItems()}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { Component, ComponentProps, JSX, ParentComponent } from 'solid-js';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { Show, Suspense } from 'solid-js';
|
||||
@@ -16,7 +15,6 @@ import { useThemeStore } from '@/modules/theme/theme.store';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
@@ -43,57 +41,10 @@ export const SideNav: Component<{
|
||||
footer?: Component;
|
||||
preFooter?: Component;
|
||||
}> = (props) => {
|
||||
const getShortSideNavItems = () => [
|
||||
{
|
||||
label: 'All organizations',
|
||||
to: '/organizations',
|
||||
icon: 'i-tabler-building-community',
|
||||
},
|
||||
{
|
||||
label: 'GitHub repository',
|
||||
href: 'https://github.com/papra-hq/papra',
|
||||
icon: 'i-tabler-brand-github',
|
||||
},
|
||||
{
|
||||
label: 'Bluesky',
|
||||
href: 'https://bsky.app/profile/papra.app',
|
||||
icon: 'i-tabler-brand-bluesky',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="flex h-full">
|
||||
<div class="w-65px border-r bg-card pt-4 pb-6 flex flex-col">
|
||||
<Button variant="link" size="icon" as={A} href="/" class="text-lg font-bold hover:no-underline flex items-center text-primary mb-4 mx-auto">
|
||||
<div class="i-tabler-file-text size-10 transform rotate-12deg hover:rotate-25deg transition"></div>
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
{getShortSideNavItems().map(menuItem => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
as={(tooltipProps: TooltipTriggerProps) => (
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-lg font-bold hover:no-underline flex items-center text-foreground dark:text-muted-foreground hover:text-primary"
|
||||
{...tooltipProps}
|
||||
aria-label={menuItem.label}
|
||||
{...(menuItem.href
|
||||
? { as: 'a', href: menuItem.href, target: '_blank', rel: 'noopener noreferrer' }
|
||||
: { as: A, href: menuItem.to })}
|
||||
>
|
||||
<div class={cn(menuItem.icon, 'size-5')} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipContent>{menuItem.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
|
||||
<div class="h-full flex flex-col pb-6 flex-1">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{props.header && <props.header />}
|
||||
|
||||
{props.mainMenu && (
|
||||
@@ -179,11 +130,11 @@ export const SidenavLayout: ParentComponent<{
|
||||
const { getPendingInvitationsCount } = usePendingInvitationsCount();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
|
||||
const { promptImport, uploadDocuments } = useDocumentUpload();
|
||||
|
||||
return (
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<props.sideNav />
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { UserMe } from '../users.types';
|
||||
import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createQueries } from '@tanstack/solid-query';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createContext, createSignal, Show, useContext } from 'solid-js';
|
||||
import { fetchCurrentUser } from '../users.services';
|
||||
|
||||
@@ -26,23 +26,18 @@ export function useCurrentUser() {
|
||||
export const CurrentUserProvider: ParentComponent = (props) => {
|
||||
const [getLatestOrganizationId, setLatestOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['users', 'me'],
|
||||
queryFn: fetchCurrentUser,
|
||||
},
|
||||
|
||||
],
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['users', 'me'],
|
||||
queryFn: fetchCurrentUser,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Show when={queries[0].data}>
|
||||
<Show when={query.data}>
|
||||
<currentUserContext.Provider
|
||||
value={{
|
||||
user: queries[0].data!.user,
|
||||
user: query.data!.user,
|
||||
refreshCurrentUser: async () => {
|
||||
queries[0].refetch();
|
||||
query.refetch();
|
||||
},
|
||||
|
||||
getLatestOrganizationId,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { uniq, values } from 'lodash-es';
|
||||
import {
|
||||
defineConfig,
|
||||
presetIcons,
|
||||
@@ -113,9 +112,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
safelist: [
|
||||
...uniq([
|
||||
...values(iconByFileType),
|
||||
...values(documentActivityIcon),
|
||||
...new Set([
|
||||
...Object.values(iconByFileType),
|
||||
...Object.values(documentActivityIcon),
|
||||
...(ssoProviders.map(p => p.icon)),
|
||||
]),
|
||||
],
|
||||
|
||||
19
apps/papra-server/.dockerignore
Normal file
19
apps/papra-server/.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
/.git
|
||||
/node_modules
|
||||
.dockerignore
|
||||
.env
|
||||
Dockerfile
|
||||
fly.toml
|
||||
*.vars
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
*.md
|
||||
66
apps/papra-server/Dockerfile
Normal file
66
apps/papra-server/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Adjust NODE_VERSION as desired
|
||||
ARG NODE_VERSION=22.19.0
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
|
||||
LABEL fly_launch_runtime="Node.js"
|
||||
|
||||
# Install pnpm
|
||||
ARG PNPM_VERSION=10.12.3
|
||||
RUN npm install -g pnpm@${PNPM_VERSION}
|
||||
|
||||
# Node.js app lives here
|
||||
WORKDIR /app
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build node modules
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
|
||||
|
||||
# Copy monorepo configuration files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
|
||||
# Copy package.json files for all workspace packages
|
||||
COPY packages/lecture/package.json ./packages/lecture/package.json
|
||||
COPY packages/webhooks/package.json ./packages/webhooks/package.json
|
||||
COPY apps/papra-server/package.json ./apps/papra-server/package.json
|
||||
|
||||
# Install all dependencies (workspace-aware)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code for dependencies
|
||||
COPY packages/lecture ./packages/lecture
|
||||
COPY packages/webhooks ./packages/webhooks
|
||||
|
||||
# Build workspace dependencies
|
||||
RUN pnpm --filter @papra/lecture build
|
||||
RUN pnpm --filter @papra/webhooks build
|
||||
|
||||
# Copy server application code
|
||||
COPY apps/papra-server ./apps/papra-server
|
||||
|
||||
# Build server application
|
||||
RUN pnpm --filter @papra/app-server build
|
||||
|
||||
# Prune dev dependencies
|
||||
RUN pnpm --filter @papra/app-server --prod --legacy deploy /app/production
|
||||
|
||||
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
ENV NODE_ENV="production"
|
||||
ENV PORT=1221
|
||||
|
||||
# Copy built application from production deployment
|
||||
COPY --from=build /app/production /app
|
||||
|
||||
# Start the server by default, this can be overwritten at runtime
|
||||
EXPOSE 1221
|
||||
CMD [ "pnpm", "--silent", "start:with-migrations" ]
|
||||
35
apps/papra-server/fly.toml
Normal file
35
apps/papra-server/fly.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
# https://fly.io/docs/reference/configuration/
|
||||
|
||||
app = 'papra-server'
|
||||
primary_region = 'cdg'
|
||||
kill_timeout = 10 # seconds
|
||||
|
||||
[build]
|
||||
dockerfile = "./Dockerfile"
|
||||
|
||||
[deploy]
|
||||
release_command = "pnpm --silent migrate:up:prod"
|
||||
strategy = "rolling"
|
||||
|
||||
[processes]
|
||||
web = "pnpm --silent start"
|
||||
|
||||
[http_service]
|
||||
internal_port = 1221
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = [ 'web' ]
|
||||
|
||||
[checks]
|
||||
|
||||
[checks.http]
|
||||
type = "http"
|
||||
method = "get"
|
||||
path = "/api/health"
|
||||
port = 1221
|
||||
interval = "15s"
|
||||
grace_period = "20s"
|
||||
timeout = "3s"
|
||||
processes = [ "web" ]
|
||||
@@ -29,6 +29,7 @@ const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: config.server.port,
|
||||
hostname: config.server.hostname,
|
||||
},
|
||||
({ port }) => logger.info({ port }, 'Server started'),
|
||||
);
|
||||
|
||||
@@ -73,9 +73,6 @@ describe('config models', () => {
|
||||
intakeEmails: {
|
||||
isEnabled: true,
|
||||
},
|
||||
documentsStorage: {
|
||||
maxUploadSize: 10485760,
|
||||
},
|
||||
organizations: {
|
||||
deletedOrganizationsPurgeDaysDelay: 30,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ export function getPublicConfig({ config }: { config: Config }) {
|
||||
'auth.providers.github.isEnabled',
|
||||
'auth.providers.google.isEnabled',
|
||||
'documents.deletedDocumentsRetentionDays',
|
||||
'documentsStorage.maxUploadSize',
|
||||
'intakeEmails.isEnabled',
|
||||
'organizations.deletedOrganizationsPurgeDaysDelay',
|
||||
]),
|
||||
|
||||
@@ -61,6 +61,12 @@ export const configDefinition = {
|
||||
default: 1221,
|
||||
env: 'PORT',
|
||||
},
|
||||
hostname: {
|
||||
doc: 'The hostname to bind to when using node server',
|
||||
schema: z.string(),
|
||||
default: '0.0.0.0',
|
||||
env: 'SERVER_HOSTNAME',
|
||||
},
|
||||
routeTimeoutMs: {
|
||||
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
|
||||
@@ -155,6 +155,8 @@ describe('documents repository', () => {
|
||||
{ id: 'doc-2', organizationId: 'organization-1', createdBy: 'user-1', name: 'File 2', originalName: 'document-2.pdf', content: 'lorem', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 10, originalSha256Hash: 'hash2' },
|
||||
{ id: 'doc-3', organizationId: 'organization-1', createdBy: 'user-1', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 5, originalSha256Hash: 'hash3' },
|
||||
{ id: 'doc-4', organizationId: 'organization-2', createdBy: 'user-2', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 100, originalSha256Hash: 'hash4' },
|
||||
{ id: 'doc-5', organizationId: 'organization-1', createdBy: 'user-2', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 100, originalSha256Hash: 'hash5', deletedAt: new Date(0), isDeleted: true },
|
||||
{ id: 'doc-6', organizationId: 'organization-1', createdBy: 'user-2', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 100, originalSha256Hash: 'hash6', deletedAt: new Date(0), isDeleted: true },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -167,6 +169,10 @@ describe('documents repository', () => {
|
||||
expect(stats).to.deep.equal({
|
||||
documentsCount: 3,
|
||||
documentsSize: 215,
|
||||
totalDocumentsSize: 415,
|
||||
totalDocumentsCount: 5,
|
||||
deletedDocumentsCount: 2,
|
||||
deletedDocumentsSize: 200,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,6 +198,10 @@ describe('documents repository', () => {
|
||||
expect(stats).to.deep.equal({
|
||||
documentsCount: 0,
|
||||
documentsSize: 0,
|
||||
totalDocumentsSize: 0,
|
||||
totalDocumentsCount: 0,
|
||||
deletedDocumentsCount: 0,
|
||||
deletedDocumentsSize: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +217,10 @@ describe('documents repository', () => {
|
||||
expect(stats).to.deep.equal({
|
||||
documentsCount: 0,
|
||||
documentsSize: 0,
|
||||
totalDocumentsSize: 0,
|
||||
totalDocumentsCount: 0,
|
||||
deletedDocumentsCount: 0,
|
||||
deletedDocumentsSize: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Database } from '../app/database/database.types';
|
||||
import type { DbInsertableDocument } from './documents.types';
|
||||
import { injectArguments, safely } from '@corentinth/chisels';
|
||||
import { subDays } from 'date-fns';
|
||||
import { and, count, desc, eq, getTableColumns, lt, sql, sum } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, getTableColumns, lt, sql } from 'drizzle-orm';
|
||||
import { omit } from 'lodash-es';
|
||||
import { createIterator } from '../app/database/database.usecases';
|
||||
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
|
||||
@@ -332,14 +332,17 @@ async function searchOrganizationDocuments({ organizationId, searchQuery, pageIn
|
||||
async function getOrganizationStats({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [record] = await db
|
||||
.select({
|
||||
documentsCount: count(documentsTable.id),
|
||||
documentsSize: sum(documentsTable.originalSize),
|
||||
totalDocumentsCount: count(documentsTable.id),
|
||||
totalDocumentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}), 0)`.as('totalDocumentsSize'),
|
||||
deletedDocumentsCount: sql<number>`COUNT(${documentsTable.id}) FILTER (WHERE ${documentsTable.isDeleted} = true)`.as('deletedDocumentsCount'),
|
||||
documentsCount: sql<number>`COUNT(${documentsTable.id}) FILTER (WHERE ${documentsTable.isDeleted} = false)`.as('documentsCount'),
|
||||
documentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}) FILTER (WHERE ${documentsTable.isDeleted} = false), 0)`.as('documentsSize'),
|
||||
deletedDocumentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}) FILTER (WHERE ${documentsTable.isDeleted} = true), 0)`.as('deletedDocumentsSize'),
|
||||
})
|
||||
.from(documentsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(documentsTable.organizationId, organizationId),
|
||||
eq(documentsTable.isDeleted, false),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -347,11 +350,15 @@ async function getOrganizationStats({ organizationId, db }: { organizationId: st
|
||||
throw createOrganizationNotFoundError();
|
||||
}
|
||||
|
||||
const { documentsCount, documentsSize } = record;
|
||||
const { documentsCount, documentsSize, deletedDocumentsCount, deletedDocumentsSize, totalDocumentsCount, totalDocumentsSize } = record;
|
||||
|
||||
return {
|
||||
documentsCount,
|
||||
documentsSize: Number(documentsSize ?? 0),
|
||||
deletedDocumentsCount,
|
||||
deletedDocumentsSize,
|
||||
totalDocumentsCount,
|
||||
totalDocumentsSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -352,12 +352,23 @@ function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionCon
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { documentsCount, documentsSize } = await documentsRepository.getOrganizationStats({ organizationId });
|
||||
const {
|
||||
documentsCount,
|
||||
documentsSize,
|
||||
deletedDocumentsCount,
|
||||
deletedDocumentsSize,
|
||||
totalDocumentsCount,
|
||||
totalDocumentsSize,
|
||||
} = await documentsRepository.getOrganizationStats({ organizationId });
|
||||
|
||||
return context.json({
|
||||
organizationStats: {
|
||||
documentsCount,
|
||||
documentsSize,
|
||||
deletedDocumentsCount,
|
||||
deletedDocumentsSize,
|
||||
totalDocumentsCount,
|
||||
totalDocumentsSize,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { INTAKE_EMAIL_ID_REGEX, RFC_5322_EMAIL_ADDRESS_REGEX } from './intake-emails.constants';
|
||||
|
||||
export const permissiveEmailAddressSchema = z.string().regex(RFC_5322_EMAIL_ADDRESS_REGEX);
|
||||
|
||||
export const emailInfoSchema = z.object({
|
||||
address: z.string().email(),
|
||||
address: permissiveEmailAddressSchema,
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -26,5 +28,4 @@ export function parseJson(content: string, ctx: z.RefinementCtx) {
|
||||
|
||||
export const intakeEmailIdSchema = z.string().regex(INTAKE_EMAIL_ID_REGEX);
|
||||
|
||||
export const permissiveEmailAddressSchema = z.string().regex(RFC_5322_EMAIL_ADDRESS_REGEX);
|
||||
export const allowedOriginsSchema = z.array(permissiveEmailAddressSchema.toLowerCase()).optional();
|
||||
|
||||
@@ -7,15 +7,14 @@ import { createForbiddenError } from '../app/auth/auth.errors';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||
import { createTestLogger } from '../shared/logger/logger.test-utils';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createUsersRepository } from '../users/users.repository';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createMaxOrganizationMembersCountReachedError, createOrganizationDocumentStorageLimitReachedError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
|
||||
import { createMaxOrganizationMembersCountReachedError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
|
||||
import { createOrganizationsRepository } from './organizations.repository';
|
||||
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
||||
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, purgeExpiredSoftDeletedOrganization, purgeExpiredSoftDeletedOrganizations, removeMemberFromOrganization, softDeleteOrganization } from './organizations.usecases';
|
||||
import { checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, purgeExpiredSoftDeletedOrganization, purgeExpiredSoftDeletedOrganizations, removeMemberFromOrganization, softDeleteOrganization } from './organizations.usecases';
|
||||
|
||||
describe('organizations usecases', () => {
|
||||
describe('ensureUserIsInOrganization', () => {
|
||||
@@ -158,76 +157,6 @@ describe('organizations usecases', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfOrganizationCanCreateNewDocument', () => {
|
||||
test('it is possible to create a new document if the organization has enough allowed storage space defined in the organization plan', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'org_sub_1',
|
||||
organizationId: 'organization-1',
|
||||
planId: PLUS_PLAN_ID,
|
||||
seatsCount: 10,
|
||||
customerId: 'cus_123',
|
||||
status: 'active',
|
||||
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
|
||||
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
|
||||
cancelAtPeriodEnd: false,
|
||||
}],
|
||||
documents: [{
|
||||
id: 'doc_1',
|
||||
organizationId: 'organization-1',
|
||||
originalSize: 100,
|
||||
mimeType: 'text/plain',
|
||||
originalName: 'test.txt',
|
||||
originalStorageKey: 'test.txt',
|
||||
originalSha256Hash: '123',
|
||||
name: 'test.txt',
|
||||
}],
|
||||
});
|
||||
|
||||
const plansRepository = {
|
||||
getOrganizationPlanById: async () => ({
|
||||
organizationPlan: {
|
||||
id: PLUS_PLAN_ID,
|
||||
name: 'Plus',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 512,
|
||||
maxIntakeEmailsCount: 10,
|
||||
maxOrganizationsMembersCount: 100,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as PlansRepository;
|
||||
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
|
||||
// no throw as the document size is less than the allowed storage space
|
||||
await checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId: 'organization-1',
|
||||
newDocumentSize: 100,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
// throw as the document size is greater than the allowed storage space
|
||||
await expect(
|
||||
checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId: 'organization-1',
|
||||
newDocumentSize: 413,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
createOrganizationDocumentStorageLimitReachedError(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreateOrganizationCustomerId', () => {
|
||||
describe(`in order to handle organization subscriptions, we need a stripe customer id per organization
|
||||
as stripe require an email per customer, we use the organization owner's email`, () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizati
|
||||
import {
|
||||
createMaxOrganizationMembersCountReachedError,
|
||||
createOnlyPreviousOwnerCanRestoreError,
|
||||
createOrganizationDocumentStorageLimitReachedError,
|
||||
createOrganizationInvitationAlreadyExistsError,
|
||||
createOrganizationNotDeletedError,
|
||||
createOrganizationNotFoundError,
|
||||
@@ -82,28 +81,6 @@ export async function checkIfUserCanCreateNewOrganization({
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId,
|
||||
newDocumentSize,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
documentsRepository,
|
||||
}: {
|
||||
organizationId: string;
|
||||
newDocumentSize: number;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
documentsRepository: DocumentsRepository;
|
||||
}) {
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository });
|
||||
|
||||
const { documentsSize } = await documentsRepository.getOrganizationStats({ organizationId });
|
||||
|
||||
if (documentsSize + newDocumentSize > organizationPlan.limits.maxDocumentStorageBytes) {
|
||||
throw createOrganizationDocumentStorageLimitReachedError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateOrganizationCustomerId({
|
||||
organizationId,
|
||||
subscriptionsServices,
|
||||
@@ -460,7 +437,7 @@ export async function getOrganizationStorageLimits({
|
||||
}) {
|
||||
const [
|
||||
{ organizationPlan },
|
||||
{ documentsSize },
|
||||
{ totalDocumentsSize },
|
||||
] = await Promise.all([
|
||||
getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository }),
|
||||
documentsRepository.getOrganizationStats({ organizationId }),
|
||||
@@ -469,7 +446,7 @@ export async function getOrganizationStorageLimits({
|
||||
const { maxDocumentStorageBytes, maxFileSize } = organizationPlan.limits;
|
||||
|
||||
return {
|
||||
availableDocumentStorageBytes: maxDocumentStorageBytes - documentsSize,
|
||||
availableDocumentStorageBytes: maxDocumentStorageBytes - totalDocumentsSize,
|
||||
maxDocumentStorageBytes,
|
||||
maxFileSize,
|
||||
};
|
||||
|
||||
@@ -21,4 +21,16 @@ export const organizationPlansConfig = {
|
||||
default: 'change-me',
|
||||
env: 'PLANS_PLUS_PLAN_ANNUAL_PRICE_ID',
|
||||
},
|
||||
proPlanMonthlyPriceId: {
|
||||
doc: 'The monthly price id of the pro plan (useless for self-hosting)',
|
||||
schema: z.string(),
|
||||
default: 'change-me',
|
||||
env: 'PLANS_PRO_PLAN_MONTHLY_PRICE_ID',
|
||||
},
|
||||
proPlanAnnualPriceId: {
|
||||
doc: 'The annual price id of the pro plan (useless for self-hosting)',
|
||||
schema: z.string(),
|
||||
default: 'change-me',
|
||||
env: 'PLANS_PRO_PLAN_ANNUAL_PRICE_ID',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
export const PRO_PLAN_ID = 'pro';
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Config } from '../config/config.types';
|
||||
import type { OrganizationPlanRecord } from './plans.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
||||
import { createPlanNotFoundError } from './plans.errors';
|
||||
|
||||
export type PlansRepository = ReturnType<typeof createPlansRepository>;
|
||||
@@ -48,6 +48,18 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
maxFileSize: 1024 * 1024 * 100, // 100 MiB
|
||||
},
|
||||
},
|
||||
[PRO_PLAN_ID]: {
|
||||
id: PRO_PLAN_ID,
|
||||
name: 'Pro',
|
||||
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
||||
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
|
||||
maxIntakeEmailsCount: 100,
|
||||
maxOrganizationsMembersCount: 50,
|
||||
maxFileSize: 1024 * 1024 * 500, // 500 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { organizationPlans };
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PlansRepository } from './plans.repository';
|
||||
import { FREE_PLAN_ID } from './plans.constants';
|
||||
|
||||
export async function getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository }: { organizationId: string; subscriptionsRepository: SubscriptionsRepository; plansRepository: PlansRepository }) {
|
||||
const { subscription } = await subscriptionsRepository.getOrganizationSubscription({ organizationId });
|
||||
const { subscription } = await subscriptionsRepository.getActiveOrganizationSubscription({ organizationId });
|
||||
|
||||
const planId = subscription?.planId ?? FREE_PLAN_ID;
|
||||
|
||||
|
||||
@@ -14,4 +14,10 @@ export const subscriptionsConfig = {
|
||||
default: 'change-me',
|
||||
env: 'STRIPE_WEBHOOK_SECRET',
|
||||
},
|
||||
globalCouponId: {
|
||||
doc: 'The Stripe coupon ID to apply globally for launch promotions',
|
||||
schema: z.string().optional(),
|
||||
default: undefined,
|
||||
env: 'GLOBAL_COUPON_ID',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { DbInsertableSubscription } from './subscriptions.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { omitUndefined } from '../shared/utils';
|
||||
import { organizationSubscriptionsTable } from './subscriptions.tables';
|
||||
|
||||
@@ -10,7 +10,7 @@ export type SubscriptionsRepository = ReturnType<typeof createSubscriptionsRepos
|
||||
export function createSubscriptionsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationSubscription,
|
||||
getActiveOrganizationSubscription,
|
||||
createSubscription,
|
||||
updateSubscription,
|
||||
},
|
||||
@@ -20,12 +20,15 @@ export function createSubscriptionsRepository({ db }: { db: Database }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function getOrganizationSubscription({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
async function getActiveOrganizationSubscription({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(organizationSubscriptionsTable)
|
||||
.where(
|
||||
eq(organizationSubscriptionsTable.organizationId, organizationId),
|
||||
and(
|
||||
eq(organizationSubscriptionsTable.organizationId, organizationId),
|
||||
ne(organizationSubscriptionsTable.status, 'canceled'),
|
||||
),
|
||||
);
|
||||
|
||||
return { subscription };
|
||||
|
||||
@@ -9,7 +9,7 @@ import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId } from '../organizations/organizations.usecases';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from '../plans/plans.constants';
|
||||
import { getPriceIdForBillingInterval } from '../plans/plans.models';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
@@ -69,7 +69,7 @@ function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsService
|
||||
'/api/organizations/:organizationId/checkout-session',
|
||||
requireAuthentication(),
|
||||
validateJsonBody(z.object({
|
||||
planId: z.enum([PLUS_PLAN_ID]),
|
||||
planId: z.enum([PLUS_PLAN_ID, PRO_PLAN_ID]),
|
||||
billingInterval: z.enum(['monthly', 'annual']).default('monthly'),
|
||||
})),
|
||||
validateParams(z.object({
|
||||
@@ -172,7 +172,7 @@ function setupGetOrganizationSubscriptionRoute({ app, db, config }: RouteDefinit
|
||||
organizationsRepository,
|
||||
});
|
||||
|
||||
const { subscription } = await subscriptionsRepository.getOrganizationSubscription({
|
||||
const { subscription } = await subscriptionsRepository.getActiveOrganizationSubscription({
|
||||
organizationId,
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ function setupGetOrganizationSubscriptionUsageRoute({ app, db, config }: RouteDe
|
||||
|
||||
const [
|
||||
{ organizationPlan },
|
||||
{ documentsSize },
|
||||
{ totalDocumentsSize, deletedDocumentsSize },
|
||||
{ intakeEmailCount },
|
||||
{ membersCount },
|
||||
] = await Promise.all([
|
||||
@@ -228,16 +228,18 @@ function setupGetOrganizationSubscriptionUsageRoute({ app, db, config }: RouteDe
|
||||
]);
|
||||
|
||||
const nullifiedLimits = {
|
||||
maxDocumentsSize: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
|
||||
maxDocumentStorageBytes: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
|
||||
maxIntakeEmailsCount: nullifyPositiveInfinity(organizationPlan.limits.maxIntakeEmailsCount),
|
||||
maxOrganizationsMembersCount: nullifyPositiveInfinity(organizationPlan.limits.maxOrganizationsMembersCount),
|
||||
maxFileSize: nullifyPositiveInfinity(organizationPlan.limits.maxFileSize),
|
||||
};
|
||||
|
||||
return context.json({
|
||||
usage: {
|
||||
documentsStorage: {
|
||||
used: documentsSize,
|
||||
limit: nullifiedLimits.maxDocumentsSize,
|
||||
used: totalDocumentsSize,
|
||||
deleted: deletedDocumentsSize,
|
||||
limit: nullifiedLimits.maxDocumentStorageBytes,
|
||||
},
|
||||
intakeEmailsCount: {
|
||||
used: intakeEmailCount,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Logger } from '@crowlog/logger';
|
||||
import type { Buffer } from 'node:buffer';
|
||||
import type { Config } from '../config/config.types';
|
||||
import { buildUrl, injectArguments } from '@corentinth/chisels';
|
||||
import { buildUrl, injectArguments, safely } from '@corentinth/chisels';
|
||||
import Stripe from 'stripe';
|
||||
import { getClientBaseUrl } from '../config/config.models';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { isNil } from '../shared/utils';
|
||||
|
||||
export type SubscriptionsServices = ReturnType<typeof createSubscriptionsServices>;
|
||||
|
||||
@@ -16,6 +19,7 @@ export function createSubscriptionsServices({ config }: { config: Config }) {
|
||||
parseWebhookEvent,
|
||||
getCustomerPortalUrl,
|
||||
getCheckoutSession,
|
||||
getCoupon,
|
||||
},
|
||||
{ stripeClient, config },
|
||||
);
|
||||
@@ -53,6 +57,16 @@ export async function createCheckoutUrl({
|
||||
const successUrl = buildUrl({ baseUrl: clientBaseUrl, path: '/checkout-success?sessionId={CHECKOUT_SESSION_ID}' });
|
||||
const cancelUrl = buildUrl({ baseUrl: clientBaseUrl, path: '/checkout-cancel' });
|
||||
|
||||
const { globalCouponId } = config.subscriptions;
|
||||
|
||||
const { coupon } = await getCoupon({ stripeClient, couponId: globalCouponId });
|
||||
|
||||
// If there's no coupon or if the coupon is invalid (expired), we just don't apply any discount but allow promotion codes
|
||||
// to be used at checkout, can't do both at the same time
|
||||
const discountDetails = isNil(coupon)
|
||||
? { allow_promotion_codes: true }
|
||||
: { discounts: [{ coupon: coupon.id }] };
|
||||
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ['card'],
|
||||
@@ -66,7 +80,6 @@ export async function createCheckoutUrl({
|
||||
},
|
||||
],
|
||||
mode: 'subscription',
|
||||
allow_promotion_codes: true,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
@@ -74,6 +87,7 @@ export async function createCheckoutUrl({
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
...discountDetails,
|
||||
});
|
||||
|
||||
return { checkoutUrl: session.url };
|
||||
@@ -111,3 +125,34 @@ async function getCheckoutSession({ stripeClient, sessionId }: { stripeClient: S
|
||||
|
||||
return { checkoutSession };
|
||||
}
|
||||
|
||||
async function getCoupon({ stripeClient, couponId, logger = createLogger({ namespace: 'subscriptions:services:getCoupon' }) }: { stripeClient: Stripe; couponId?: string; logger?: Logger }) {
|
||||
if (isNil(couponId)) {
|
||||
return { coupon: null };
|
||||
}
|
||||
|
||||
const [coupon, error] = await safely(stripeClient.coupons.retrieve(couponId));
|
||||
|
||||
if (!isNil(error)) {
|
||||
logger.error({ error }, 'Error while retrieving coupon');
|
||||
return { coupon: null };
|
||||
}
|
||||
|
||||
if (isNil(coupon)) {
|
||||
logger.error({ couponId }, 'Failed to retrieve coupon');
|
||||
return { coupon: null };
|
||||
}
|
||||
|
||||
if (!coupon.valid) {
|
||||
logger.warn({ couponId, couponName: coupon.name }, 'Coupon is not valid');
|
||||
return { coupon: null };
|
||||
}
|
||||
|
||||
return {
|
||||
coupon: {
|
||||
id: coupon.id,
|
||||
name: coupon.name ?? undefined,
|
||||
percentOff: coupon.percent_off ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,5 +68,52 @@ export async function handleStripeWebhookEvent({
|
||||
cancelAtPeriodEnd,
|
||||
planId: organizationPlan.id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'customer.subscription.deleted') {
|
||||
const subscriptionId = event.data.object.id;
|
||||
|
||||
if (isNil(subscriptionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await subscriptionsRepository.updateSubscription({
|
||||
subscriptionId,
|
||||
status: 'canceled',
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'invoice.payment_failed') {
|
||||
const subscriptionId = get(event, 'data.object.subscription') as string | undefined;
|
||||
|
||||
if (isNil(subscriptionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await subscriptionsRepository.updateSubscription({
|
||||
subscriptionId,
|
||||
status: 'past_due',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'invoice.payment_succeeded') {
|
||||
const subscriptionId = get(event, 'data.object.subscription') as string | undefined;
|
||||
const currentPeriodEnd = coerceStripeTimestampToDate(get(event, 'data.object.lines.data[0].period.end'));
|
||||
const currentPeriodStart = coerceStripeTimestampToDate(get(event, 'data.object.lines.data[0].period.start'));
|
||||
|
||||
if (isNil(subscriptionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await subscriptionsRepository.updateSubscription({
|
||||
subscriptionId,
|
||||
status: 'active',
|
||||
currentPeriodEnd,
|
||||
currentPeriodStart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ export const tasksConfig = {
|
||||
},
|
||||
worker: {
|
||||
id: {
|
||||
doc: 'The id of the task worker, used to identify the worker in the Cadence cluster in case of multiple workers',
|
||||
doc: 'The id of the task worker, used to identify the worker in the Cadence cluster in case of multiple workers, should be unique per instance',
|
||||
schema: z.string().optional(),
|
||||
env: ['TASKS_WORKER_ID', 'DYNO', 'RENDER_SERVICE_ID'],
|
||||
env: ['TASKS_WORKER_ID', 'DYNO', 'RENDER_SERVICE_ID', 'FLY_MACHINE_ID'],
|
||||
},
|
||||
},
|
||||
hardDeleteExpiredDocuments: {
|
||||
|
||||
@@ -148,7 +148,7 @@ function setupUpdateWebhookRoute({ app, db }: RouteDefinitionContext) {
|
||||
});
|
||||
|
||||
return context.json({
|
||||
webhook,
|
||||
webhook: omit(webhook, ['secret']),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,4 +8,24 @@ dist
|
||||
db.sqlite
|
||||
local-documents
|
||||
.env
|
||||
**/.env
|
||||
**/.env
|
||||
|
||||
/.git
|
||||
/node_modules
|
||||
.dockerignore
|
||||
.env
|
||||
Dockerfile
|
||||
fly.toml
|
||||
*.vars
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
*.md
|
||||
@@ -1,4 +1,30 @@
|
||||
# @papra/app-server
|
||||
# @papra/docker
|
||||
|
||||
## 25.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#567](https://github.com/papra-hq/papra/pull/567) [`d7df2f0`](https://github.com/papra-hq/papra/commit/d7df2f095b8cdcdf5ac068a7e1ff6ead12a874c6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed unnecessary left icon navbar
|
||||
|
||||
- [#556](https://github.com/papra-hq/papra/pull/556) [`f66a9f5`](https://github.com/papra-hq/papra/commit/f66a9f5d1b3fe7a918802f9d6d1a90b073bd50c8) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added deleted and total document counts and sizes in the `/api/organizations/:organizationId/documents/statistics` route
|
||||
|
||||
- [#570](https://github.com/papra-hq/papra/pull/570) [`c3ffa83`](https://github.com/papra-hq/papra/commit/c3ffa8387e2e757098d5344023363897e7e0a416) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added server hostname configuration
|
||||
|
||||
- [#552](https://github.com/papra-hq/papra/pull/552) [`8aabd28`](https://github.com/papra-hq/papra/commit/8aabd28168fe7e77f5186ae7dd79e1f5d0bb7288) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Lighten the client bundle by removing lodash dep
|
||||
|
||||
- [#550](https://github.com/papra-hq/papra/pull/550) [`1a7a14b`](https://github.com/papra-hq/papra/commit/1a7a14b3ed4caf1d9fec86a034249f3f7267d4e8) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix weird navigation freeze when direct navigation to organizations
|
||||
|
||||
- [#548](https://github.com/papra-hq/papra/pull/548) [`17cebde`](https://github.com/papra-hq/papra/commit/17cebde051eb2a09b9ac7bfc32674afc15e60ad2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Made the validation more permissive for incoming intake email webhook addresses, allowing RFC 5322 compliant email addresses instead of just simple emails.
|
||||
|
||||
- [#565](https://github.com/papra-hq/papra/pull/565) [`e4295e1`](https://github.com/papra-hq/papra/commit/e4295e14abf3a0bce9db10f41d46fd86c4bb4cb5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevent small flash of wrong theme on initial load for slower connections
|
||||
|
||||
- [#566](https://github.com/papra-hq/papra/pull/566) [`92daaa3`](https://github.com/papra-hq/papra/commit/92daaa35bb5e3b515b7eeda837f0a9e7dc0005f1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Redacted webhook signing secret in api update response
|
||||
|
||||
- [#560](https://github.com/papra-hq/papra/pull/560) [`54cc140`](https://github.com/papra-hq/papra/commit/54cc14052c5c6bc5e0b29a8feb92604d13e0fd52) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Reduced the client bundle size by switching to posthog-lite
|
||||
|
||||
- [#555](https://github.com/papra-hq/papra/pull/555) [`c5b337f`](https://github.com/papra-hq/papra/commit/c5b337f3bb63fb0fc700dae08bacf0095f9b98e0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Use organization max file size limit for pre-upload validation
|
||||
|
||||
- [#567](https://github.com/papra-hq/papra/pull/567) [`d7df2f0`](https://github.com/papra-hq/papra/commit/d7df2f095b8cdcdf5ac068a7e1ff6ead12a874c6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Redesigned the organization picker in the sidenav
|
||||
|
||||
## 25.10.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@papra/docker",
|
||||
"version": "25.10.0",
|
||||
"version": "25.10.1",
|
||||
"private": true,
|
||||
"description": "Docker image version tracker for Papra, calver-ish versioned.",
|
||||
"repository": {
|
||||
|
||||
3141
pnpm-lock.yaml
generated
3141
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user