Compare commits

..

20 Commits

Author SHA1 Message Date
Corentin Thomasset
d9263dc703 chore(release): update versions (#549)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-23 21:51:13 +02:00
Corentin Thomasset
c3ffa8387e feat(config): add hostname configuration (#570) 2025-10-23 21:48:39 +02:00
Corentin Thomasset
d40514c043 feat(subscriptions): enhance subscription webhook handling (#569) 2025-10-23 21:23:11 +02:00
Corentin Thomasset
d7df2f095b refactor(layouts): removed icons bar (#567)
* refactor(layouts): removed icons bar

* Update .changeset/bumpy-pens-study.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 14:58:31 +02:00
Corentin Thomasset
afdcc1c5ba feat(demo): add subscription and usage endpoints (#559) 2025-10-19 23:18:33 +02:00
Corentin Thomasset
92daaa35bb fix(webhooks): omit secret from webhook response in update route (#566) 2025-10-19 14:04:59 +00:00
Corentin Thomasset
e4295e14ab fix(theme): prevent flash of wrong theme on load (#565) 2025-10-19 15:55:00 +02:00
Corentin Thomasset
ae37d1db36 fix(tasks): add FLY_MACHINE_ID fallback to worker ids (#564) 2025-10-19 12:16:42 +00:00
Corentin Thomasset
a7464f8b89 chore(fly): update configuration for deployment and health checks (#563) 2025-10-18 21:42:09 +00:00
Corentin Thomasset
2dd9ca9835 chore(fly): test fly.io hosting (#561) 2025-10-18 14:17:17 +00:00
Corentin Thomasset
54cc14052c refactor(tracking): replace posthog-js with posthog-js-lite to reduced bundle (#560) 2025-10-17 21:22:56 +00:00
Corentin Thomasset
f930e46dde fix(docker): correct package changelog title to @papra/docker (#551) 2025-10-16 16:15:17 +02:00
Corentin Thomasset
df75e5accb feat(subscriptions): add global coupon support for checkout sessions (#558) 2025-10-16 15:36:42 +02:00
Corentin Thomasset
f66a9f5d1b feat(documents): added deleted and total metrics in the organization stats route (#556) 2025-10-14 17:59:37 +02:00
Corentin Thomasset
c5b337f3bb fix(upload): use organization-specific file size limits (#555) 2025-10-14 03:09:54 +02:00
Corentin Thomasset
bb1ba3e15e chore(release): ensure job runs only for the correct repository (#554) 2025-10-13 21:40:34 +00:00
Corentin Thomasset
ce839c4127 feat(plans): pro plan (#553) 2025-10-13 23:33:55 +02:00
Corentin Thomasset
8aabd28168 refactor(utils): removed lodash-es (#552) 2025-10-13 17:03:25 +02:00
Corentin Thomasset
1a7a14b3ed refactor(query): dropped unnecessary tanstack useQueries (#550) 2025-10-13 02:22:58 +02:00
Corentin Thomasset
17cebde051 fix(intake-emails): make email validation more permissive for webhook addresses (#548) 2025-10-12 18:56:18 +00:00
80 changed files with 3686 additions and 1758 deletions

View File

@@ -11,6 +11,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
if: github.repository == 'papra-hq/papra'
permissions:
contents: write
pull-requests: write

View File

@@ -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',
},
});

View File

@@ -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>

View File

@@ -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:"
}
}

View File

@@ -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')!,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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,

View File

@@ -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';

View File

@@ -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 () => {

View File

@@ -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'>;

View File

@@ -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 });

View File

@@ -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 });
}));
};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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`,
});

View File

@@ -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()} />
)}

View File

@@ -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={[

View File

@@ -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={[

View File

@@ -1,2 +1,3 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';
export const PRO_PLAN_ID = 'pro';

View File

@@ -0,0 +1,6 @@
export type PlanLimits = {
maxDocumentStorageBytes: number | null;
maxIntakeEmailsCount: number | null;
maxOrganizationsMembersCount: number | null;
maxFileSize: number | null;
};

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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 }) {

View File

@@ -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,
};
}

View 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);
});
});
});

View File

@@ -0,0 +1,3 @@
export function castArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}

View 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;
}

View 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');
});
});

View 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;
}

View 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,
},
},
});
});
});
});

View 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;

View 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');
});
});

View 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);
};
}

View File

@@ -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>
);

View File

@@ -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 = () => {

View File

@@ -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`,

View File

@@ -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 }) => {

View File

@@ -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>
);
}

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)),
]),
],

View 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

View 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" ]

View 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" ]

View File

@@ -29,6 +29,7 @@ const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
({ port }) => logger.info({ port }, 'Server started'),
);

View File

@@ -73,9 +73,6 @@ describe('config models', () => {
intakeEmails: {
isEnabled: true,
},
documentsStorage: {
maxUploadSize: 10485760,
},
organizations: {
deletedOrganizationsPurgeDaysDelay: 30,
},

View File

@@ -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',
]),

View File

@@ -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(),

View File

@@ -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,
});
});
});

View File

@@ -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,
};
}

View File

@@ -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,
},
});
},

View File

@@ -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();

View File

@@ -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`, () => {

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -1,2 +1,3 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';
export const PRO_PLAN_ID = 'pro';

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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,
},
};
}

View File

@@ -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,
});
}
}

View File

@@ -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: {

View File

@@ -148,7 +148,7 @@ function setupUpdateWebhookRoute({ app, db }: RouteDefinitionContext) {
});
return context.json({
webhook,
webhook: omit(webhook, ['secret']),
});
},
);

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff