Compare commits

...

37 Commits

Author SHA1 Message Date
Corentin Thomasset
3903eed170 chore(release): update versions (#605)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-02 21:20:30 +01:00
Corentin Thomasset
c70d7e419a chore(release): use provenance for release (#604) 2025-11-02 20:16:24 +00:00
Corentin Thomasset
2240f58f04 chore(release): update versions (#576)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-31 16:39:30 +01:00
Corentin Thomasset
79e9bb1b61 feat(auth): added an email verification confirmation/expiration page (#602) 2025-10-30 10:38:23 +01:00
Corentin Thomasset
6e18162435 fix(queries): clear query client on sign out and invalidate organization queries on error (#600) 2025-10-28 23:56:22 +01:00
Corentin Thomasset
16ae4617df feat(tagging-rules): add condition match mode to tagging rules (#601)
- Introduced a new column `condition_match_mode` in the `tagging_rules` table to specify how conditions should be evaluated (either 'all' or 'any').
- Updated the tagging rules repository, routes, and schemas to handle the new `conditionMatchMode` property.
- Enhanced the tagging rules use cases to apply tags based on the specified condition match mode.
- Added tests to verify the behavior of tagging rules with different condition match modes.
- Created a migration to add the new column and update existing records accordingly.
2025-10-28 14:07:16 +01:00
Corentin Thomasset
1c46071e00 refactor(unocss): migrated preset to wind4 (#599) 2025-10-27 22:09:09 +00:00
Corentin Thomasset
377c11c185 fix(organization): corrected organization redirect (#598)
* fix(organization): corrected organization redirect

* Update .changeset/chatty-monkeys-joke.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-27 12:03:34 +01:00
Corentin Thomasset
28c3c15cef chore(deps): update better-auth (#596) 2025-10-27 01:35:47 +01:00
Corentin Thomasset
0391a3bcd5 chore(deps): updated eslint and config dependencies (#595) 2025-10-27 00:00:28 +00:00
Corentin Thomasset
2c75eec862 test(logs): reduced log spam in test (#594) 2025-10-26 23:50:59 +00:00
Corentin Thomasset
ccf7602f19 chore(deps): use catalog eslint packages in doc app (#593) 2025-10-26 23:29:50 +00:00
Corentin Thomasset
b8a515a313 chore(deps): updated vitest monorepo (#592) 2025-10-26 23:09:49 +00:00
Corentin Thomasset
0aad88471b chore(pnpm): updated pnpm to 10.19.0 (#591) 2025-10-26 21:43:23 +00:00
Corentin Thomasset
efd2ae1c73 chore(deps): removed unused jsdom in client (#590) 2025-10-26 21:48:25 +01:00
Corentin Thomasset
e9a719d06a fix(client): proper feedback messages in auth pages (#589) 2025-10-26 17:04:52 +00:00
Corentin Thomasset
68714267ad fix(subscriptions): stabilized subscriptions webhook states (#588) 2025-10-26 16:47:34 +01:00
Corentin Thomasset
75a13da526 fix(subscriptions): stop preventing org deletion when subscription is active (#587) 2025-10-26 09:27:56 +00:00
Corentin Thomasset
59d5819018 fix(cli): correct import path to prevent CLI crash (#586) 2025-10-25 15:43:21 +00:00
Corentin Thomasset
a857370343 fix(webhooks): update webhook creation to allow without secrets (#585) 2025-10-25 13:08:07 +00:00
Corentin Thomasset
f4740ba59a feat(date): replace timeAgo with RelativeTime component (#584) 2025-10-25 14:26:47 +02:00
Corentin Thomasset
b0abf7f78a feat(i18n): add date and time ago formatting functions (#583) 2025-10-25 11:51:28 +02:00
Corentin Thomasset
182ccbb30b fix(webhooks): corrected webhooks last triggered date (#582) 2025-10-25 02:12:57 +02:00
Jan-Olaf Becker
75340f0ce7 feat(tagging-rules): added a "run now" button for tagging rules (#540)
* feat: add run now button for tagging rules

Allow users to apply existing tagging rules to all documents
in their organization. This helps when rules are created after
documents have already been imported.

Fixes #251

* docs: add tagging rules guide and API endpoint

- Add comprehensive guide for using tagging rules
- Document the new 'Apply to existing documents' feature
- Add API endpoint documentation for applying rules to existing documents

* feat(docs): add Tagging Rules to sidebar navigation

* refactor(ui): normalized button sizes

* refactor(repository): remove unused getOrganizationDocumentsQuery function

* refactor(tagging-rules): mutualized tagging rule application

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-10-24 23:44:12 +02:00
Corentin Thomasset
1228486f28 feat(lecture): add support for extracting text from .docx, .odt, .rtf, .pptx and .odp (#580) 2025-10-24 21:44:11 +02:00
Corentin Thomasset
655a1c5475 feat(auth): enhance user signup logging with email capture (#579) 2025-10-24 16:07:45 +00:00
Corentin Thomasset
d1797eb9be fix(fly.toml): update deployment strategy to canary and adjust process environment variable syntax (#578) 2025-10-24 17:28:54 +02:00
Corentin Thomasset
bd3e321eb7 feat(processes): added worker vs web processes (#577) 2025-10-24 17:06:28 +02:00
Corentin Thomasset
be25de7721 fix(server): add global error handlers for uncaught exceptions and unhandled promise rejections (#575) 2025-10-24 16:05:21 +02:00
Corentin Thomasset
e85403f9a1 fix(fly.toml): set minimum machines running to 1 (#574) 2025-10-24 11:47:51 +00:00
Corentin Thomasset
7de5d0956b feat(upgrade-dialog): add promotional banner for early adopters (#573) 2025-10-24 10:03:23 +00:00
Corentin Thomasset
b1a88230cd fix(subscriptions): added organization deletion restrictions based on active subscriptions (#572) 2025-10-24 01:37:30 +02:00
Corentin Thomasset
55bb29582e fix(docker): update build context paths in package.json scripts (#571) 2025-10-23 21:36:25 +00:00
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
148 changed files with 7409 additions and 3483 deletions

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Added deleted and total document counts and sizes in the `/api/organizations/:organizationId/documents/statistics` route

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Lighten the client bundle by removing lodash dep

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Fix weird navigation freeze when direct navigation to organizations

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Made the validation more permissive for incoming intake email webhook addresses, allowing RFC 5322 compliant email addresses instead of just simple emails.

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Prevent small flash of wrong theme on initial load for slower connections

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Redacted webhook signing secret in api update response

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Reduced the client bundle size by switching to posthog-lite

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Use organization max file size limit for pre-upload validation

View File

@@ -19,14 +19,18 @@ jobs:
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache: "pnpm"
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm i
@@ -42,7 +46,6 @@ jobs:
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker')

View File

@@ -166,10 +166,20 @@ pnpm dev # localhost:4321
- Use **Vitest** for all testing
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
- Use business-oriented test names (avoid `it('should return true')`)
- Integration tests may use Testcontainers (Azurite, LocalStack)
- All new features require test coverage
### Writing Good Test Names
Test names should explain the **why** (business logic, user scenario, or expected behavior), not the **how** (implementation details or return values).
**Key principles:**
- **Describe blocks** should explain the business goal or rule being tested
- **Test names** should explain the scenario, context, and reason for the behavior
- Avoid implementation details like "returns X", "should be Y", "calls Z method"
- Focus on user scenarios and business rules
- Make tests readable as documentation - someone unfamiliar with the code should understand what's being tested and why
## Code Style
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)

View File

@@ -3,7 +3,6 @@
"type": "module",
"version": "0.6.1",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -28,14 +27,15 @@
"tailwind-merge": "^2.6.0",
"unocss-preset-animations": "^1.2.1",
"yaml": "^2.8.0",
"zod": "^3.25.67",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@antfu/eslint-config": "^3.13.0",
"@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "^1.1.120",
"@types/lodash-es": "^4.17.12",
"@unocss/reset": "^0.64.0",
"eslint": "^9.17.0",
"eslint": "catalog:",
"eslint-plugin-astro": "^1.3.1",
"figue": "^3.1.1",
"lodash-es": "^4.17.21",

View File

@@ -0,0 +1,102 @@
---
title: Using Tagging Rules
description: Learn how to automate document organization with tagging rules.
slug: guides/tagging-rules
---
## What are Tagging Rules?
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
## How Tagging Rules Work
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
### Rule Components
Each tagging rule consists of:
1. **Conditions**: Rules that determine which documents should be tagged
- Field: The document property to check (e.g., name, content)
- Operator: How to compare the field (e.g., contains, equals)
- Value: The text to match against
2. **Actions**: The tags to apply when conditions are met
## Applying Rules to Existing Documents
### The "Run Now" Feature
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
This feature is particularly useful when:
- You create a new rule and want to organize your existing documents
- You modify a rule and want to reprocess documents
- You're setting up your organization and want to retroactively organize imported documents
### How to Apply a Rule to Existing Documents
1. Navigate to your organization's Tagging Rules page
2. Find the rule you want to apply
3. Click the **"Apply to existing documents"** button
4. Confirm the action in the dialog
5. The task is queued and will be processed in the background
The system will:
- Queue a background task to process all documents
- Process documents in batches to avoid overloading the system
- Check all existing documents in your organization
- Apply tags where the rule's conditions match
- Show you a success message once the task is queued
:::tip
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
:::
## Best Practices
### Creating Effective Rules
1. **Be specific**: Use precise conditions to avoid over-tagging
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
3. **Use multiple conditions**: Combine conditions for more accurate matching
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
### Example Rules
**Invoice Classification**
- Condition: Document name contains "invoice"
- Action: Apply "Invoice" tag
**Quarterly Reports**
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
- Action: Apply "Report" tag
## Using the API
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
```bash
curl -X POST \
-H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
```
Response (HTTP 202 Accepted):
```json
{
"taskId": "task_abc123"
}
```
Where:
- `taskId`: The ID of the background task processing your request
:::note
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
:::
## Related Resources
- [API Endpoints Documentation](/resources/api-endpoints)
- [CLI Documentation](/resources/cli)

View File

@@ -307,3 +307,13 @@ Remove a tag from a document.
- Required API key permissions: `tags:read` and `documents:update`
- Response: empty (204 status code)
### Apply tagging rule to existing documents
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
- Required API key permissions: `tags:read` and `documents:update`
- Response (JSON, HTTP 202)
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).

View File

@@ -40,6 +40,10 @@ export const sidebar = [
label: 'Document Encryption',
slug: 'guides/document-encryption',
},
{
label: 'Tagging Rules',
slug: 'guides/tagging-rules',
},
],
},
{

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from 'astro';
import type { ConfigDefinition } from 'figue';
import { z } from 'astro:content';
import { mapValues } from 'lodash-es';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { configDefinition } from '../../../papra-server/src/modules/config/config';

View File

@@ -3,7 +3,6 @@
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -29,7 +28,7 @@
},
"dependencies": {
"@branchlet/core": "^1.0.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/chisels": "catalog:",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.1",
@@ -38,12 +37,10 @@
"@solidjs/router": "^0.14.10",
"@tanstack/solid-query": "^5.90.3",
"@tanstack/solid-table": "^8.21.3",
"@unocss/reset": "^0.64.1",
"better-auth": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.2",
"date-fns": "^4.1.0",
"ofetch": "^1.4.1",
"posthog-js-lite": "^4.1.5",
"radix3": "^1.1.2",
@@ -51,7 +48,7 @@
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.6.0",
"ts-pattern": "^5.7.1",
"unocss-preset-animations": "^1.2.1",
"unocss-preset-animations": "^1.3.0",
"unstorage": "^1.16.0",
"valibot": "1.0.0-beta.10"
},
@@ -61,11 +58,10 @@
"@playwright/test": "^1.53.1",
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.20.3",
"typescript": "catalog:",
"unocss": "66.5.3",
"unocss": "^66.5.4",
"vite": "^7.1.9",
"vite-plugin-solid": "^2.11.9",
"vitest": "catalog:"

View File

@@ -17,7 +17,6 @@ import { IdentifyUser } from './modules/tracking/components/identify-user.compon
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
import { Toaster } from './modules/ui/components/sonner';
import { routes } from './routes';
import '@unocss/reset/tailwind.css';
import 'virtual:uno.css';
import './app.css';

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'E-Mail verifizieren',
'auth.email-validation-required.description': 'Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.',
'auth.email-verification.success.title': 'E-Mail verifiziert',
'auth.email-verification.success.description': 'Ihre E-Mail wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen.',
'auth.email-verification.success.login': 'Zur Anmeldung',
'auth.email-verification.error.title': 'Verifizierung fehlgeschlagen',
'auth.email-verification.error.description': 'Der Verifizierungslink ist ungültig oder abgelaufen. Bitte fordern Sie eine neue Verifizierungs-E-Mail an, indem Sie sich anmelden.',
'auth.email-verification.error.back': 'Zurück zur Anmeldung',
'auth.legal-links.description': 'Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.',
'auth.legal-links.terms': 'Nutzungsbedingungen',
'auth.legal-links.privacy': 'Datenschutzrichtlinie',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
'organization.settings.delete.success': 'Organisation gelöscht',
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
'organization.settings.delete.has-active-subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden, bitte kündigen Sie zuerst Ihr Abonnement oben.',
'organization.usage.page.title': 'Nutzung',
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Beispiel: Dokumente mit \'Rechnung\' im Namen taggen',
'tagging-rules.form.description.max-length': 'Die Beschreibung muss weniger als 256 Zeichen lang sein',
'tagging-rules.form.conditions.label': 'Bedingungen',
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.',
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Keine Bedingungen bedeutet, dass die Regel auf alle Dokumente angewendet wird',
'tagging-rules.form.conditions.add-condition': 'Bedingung hinzufügen',
'tagging-rules.form.conditions.connector.when': 'Wenn',
'tagging-rules.form.conditions.connector.and': 'und',
'tagging-rules.form.conditions.connector.or': 'oder',
'tagging-rules.condition-match-mode.all': 'Alle Bedingungen müssen erfüllt sein',
'tagging-rules.condition-match-mode.any': 'Mindestens eine Bedingung muss erfüllt sein',
'tagging-rules.form.conditions.no-conditions.title': 'Keine Bedingungen',
'tagging-rules.form.conditions.no-conditions.description': 'Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel ohne Bedingungen anwenden',
@@ -400,9 +413,16 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.tags.add-tag': 'Tag erstellen',
'tagging-rules.form.submit': 'Regel erstellen',
'tagging-rules.update.title': 'Tagging-Regel aktualisieren',
'tagging-rules.update.error': 'Tagging-Regel konnte nicht aktualisiert werden',
'tagging-rules.update.error': 'Fehler beim Aktualisieren der Tagging-Regel',
'tagging-rules.update.submit': 'Regel aktualisieren',
'tagging-rules.update.cancel': 'Abbrechen',
'tagging-rules.apply.button': 'Auf vorhandene Dokumente anwenden',
'tagging-rules.apply.confirm.title': 'Regel auf vorhandene Dokumente anwenden?',
'tagging-rules.apply.confirm.description': 'Dies überprüft alle vorhandenen Dokumente in Ihrer Organisation und wendet Tags an, wo Bedingungen übereinstimmen. Die Verarbeitung erfolgt im Hintergrund.',
'tagging-rules.apply.confirm.button': 'Regel anwenden',
'tagging-rules.apply.success': 'Regelanwendung im Hintergrund gestartet',
'tagging-rules.apply.error': 'Fehler beim Starten der Regelanwendung',
'tagging-rules.apply.processing': 'Wird gestartet...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
'api-errors.FAILED_TO_CREATE_SESSION': 'Fehler beim Erstellen der Sitzung',
'api-errors.FAILED_TO_UPDATE_USER': 'Fehler beim Aktualisieren des Benutzers',
'api-errors.FAILED_TO_GET_SESSION': 'Fehler beim Abrufen der Sitzung',
'api-errors.INVALID_PASSWORD': 'Ungültiges Passwort',
'api-errors.INVALID_EMAIL': 'Ungültige E-Mail',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Die E-Mail oder das Passwort ist falsch, oder das Konto existiert nicht.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social-Media-Konto bereits verknüpft',
'api-errors.PROVIDER_NOT_FOUND': 'Anbieter nicht gefunden',
'api-errors.INVALID_TOKEN': 'Ungültiger Token',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-Token wird nicht unterstützt',
'api-errors.FAILED_TO_GET_USER_INFO': 'Fehler beim Abrufen der Benutzerinformationen',
'api-errors.USER_EMAIL_NOT_FOUND': 'Benutzer-E-Mail nicht gefunden',
'api-errors.EMAIL_NOT_VERIFIED': 'E-Mail nicht verifiziert',
'api-errors.PASSWORD_TOO_SHORT': 'Passwort zu kurz',
'api-errors.PASSWORD_TOO_LONG': 'Passwort zu lang',
'api-errors.USER_ALREADY_EXISTS': 'Ein Benutzer mit dieser E-Mail existiert bereits',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-Mail kann nicht aktualisiert werden',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Anmeldekonto nicht gefunden',
'api-errors.SESSION_EXPIRED': 'Sitzung abgelaufen',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/Monat',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} jährlich abgerechnet',
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
'subscriptions.upgrade-dialog.promo-banner.title': 'Zeitlich begrenztes Angebot',
'subscriptions.upgrade-dialog.promo-banner.description': 'Erhalten Sie {{ percent }}% Rabatt pro Organisation auf alle Tarife für immer als Early Adopter! Angebot läuft ab in {{ days, >1:{days} Tagen, =1:1 Tag, weniger als 1 Tag }}.',
'subscriptions.plan.free.name': 'Kostenloser Plan',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -68,6 +68,13 @@ export const translations = {
'auth.email-validation-required.title': 'Verify your email',
'auth.email-validation-required.description': 'A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.',
'auth.email-verification.success.title': 'Email verified',
'auth.email-verification.success.description': 'Your email has been successfully verified. You can now log in to your account.',
'auth.email-verification.success.login': 'Go to login',
'auth.email-verification.error.title': 'Verification failed',
'auth.email-verification.error.description': 'The verification link has expired or is invalid. Please request a new verification email by logging in.',
'auth.email-verification.error.back': 'Back to login',
'auth.legal-links.description': 'By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.',
'auth.legal-links.terms': 'Terms of Service',
'auth.legal-links.privacy': 'Privacy Policy',
@@ -155,6 +162,7 @@ export const translations = {
'organization.settings.delete.confirm.cancel-button': 'Cancel',
'organization.settings.delete.success': 'Organization deleted',
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
'organization.settings.delete.has-active-subscription': 'Cannot delete organization with an active subscription, please cancel your subscription above first.',
'organization.usage.page.title': 'Usage',
'organization.usage.page.description': 'View your organization\'s current usage and limits.',
@@ -384,8 +392,13 @@ export const translations = {
'tagging-rules.form.description.placeholder': 'Example: Tag documents with \'invoice\' in the name',
'tagging-rules.form.description.max-length': 'The description must be less than 256 characters',
'tagging-rules.form.conditions.label': 'Conditions',
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.',
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. No conditions means the rule will apply to all documents',
'tagging-rules.form.conditions.add-condition': 'Add condition',
'tagging-rules.form.conditions.connector.when': 'When',
'tagging-rules.form.conditions.connector.and': 'and',
'tagging-rules.form.conditions.connector.or': 'or',
'tagging-rules.condition-match-mode.all': 'All conditions must match',
'tagging-rules.condition-match-mode.any': 'Any condition must match',
'tagging-rules.form.conditions.no-conditions.title': 'No conditions',
'tagging-rules.form.conditions.no-conditions.description': 'You didn\'t add any conditions to this rule. This rule will apply its tags to all documents.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Apply rule without conditions',
@@ -401,6 +414,13 @@ export const translations = {
'tagging-rules.update.error': 'Failed to update tagging rule',
'tagging-rules.update.submit': 'Update rule',
'tagging-rules.update.cancel': 'Cancel',
'tagging-rules.apply.button': 'Apply to existing documents',
'tagging-rules.apply.confirm.title': 'Apply rule to existing documents?',
'tagging-rules.apply.confirm.description': 'This will check all existing documents in your organization and apply tags where conditions match. The processing will happen in the background.',
'tagging-rules.apply.confirm.button': 'Apply rule',
'tagging-rules.apply.success': 'Rule application started in the background',
'tagging-rules.apply.error': 'Failed to start rule application',
'tagging-rules.apply.processing': 'Starting...',
// Intake emails
@@ -587,6 +607,32 @@ export const translations = {
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
'api-errors.organization.has_active_subscription': 'Cannot delete organization with an active subscription. Please cancel your subscription first using the Manage Subscription button above.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'User not found',
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
'api-errors.FAILED_TO_CREATE_SESSION': 'Failed to create session',
'api-errors.FAILED_TO_UPDATE_USER': 'Failed to update user',
'api-errors.FAILED_TO_GET_SESSION': 'Failed to get session',
'api-errors.INVALID_PASSWORD': 'Invalid password',
'api-errors.INVALID_EMAIL': 'Invalid email',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'The email or password is incorrect, or the account does not exist.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social account already linked',
'api-errors.PROVIDER_NOT_FOUND': 'Provider not found',
'api-errors.INVALID_TOKEN': 'Invalid token',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID token not supported',
'api-errors.FAILED_TO_GET_USER_INFO': 'Failed to get user info',
'api-errors.USER_EMAIL_NOT_FOUND': 'User email not found',
'api-errors.EMAIL_NOT_VERIFIED': 'Email not verified',
'api-errors.PASSWORD_TOO_SHORT': 'Password too short',
'api-errors.PASSWORD_TOO_LONG': 'Password too long',
'api-errors.USER_ALREADY_EXISTS': 'A user with this email already exists',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email can not be updated',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Credential account not found',
'api-errors.SESSION_EXPIRED': 'Session expired',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
// Not found
@@ -634,6 +680,8 @@ export const translations = {
'subscriptions.upgrade-dialog.per-month': '/month',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} billed annually',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
'subscriptions.upgrade-dialog.promo-banner.title': 'Limited Time Offer',
'subscriptions.upgrade-dialog.promo-banner.description': 'Get {{ percent }}% off all plans forever per organization as an early adopter! Offer expires in {{ days, >1:{days} days, =1:1 day, less than 1 day }}.',
'subscriptions.plan.free.name': 'Free plan',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Verifica tu correo electrónico',
'auth.email-validation-required.description': 'Se ha enviado un correo de verificación a tu dirección de correo electrónico. Por favor, verifica tu correo haciendo clic en el enlace del correo.',
'auth.email-verification.success.title': 'Correo verificado',
'auth.email-verification.success.description': 'Tu correo ha sido verificado exitosamente. Ahora puedes iniciar sesión en tu cuenta.',
'auth.email-verification.success.login': 'Ir a iniciar sesión',
'auth.email-verification.error.title': 'Verificación fallida',
'auth.email-verification.error.description': 'El enlace de verificación es inválido o ha expirado. Por favor, solicita un nuevo correo de verificación iniciando sesión.',
'auth.email-verification.error.back': 'Volver a iniciar sesión',
'auth.legal-links.description': 'Al continuar, reconoces que entiendes y aceptas los {{ terms }} y la {{ privacy }}.',
'auth.legal-links.terms': 'Términos de servicio',
'auth.legal-links.privacy': 'Política de privacidad',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organización eliminada',
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
'organization.settings.delete.has-active-subscription': 'No se puede eliminar la organización con una suscripción activa, por favor cancela tu suscripción arriba primero.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Ver el uso y los límites actuales de su organización.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nombre del documento',
'tagging-rules.field.content': 'contenido del documento',
'tagging-rules.field.name': 'el nombre del documento',
'tagging-rules.field.content': 'el contenido del documento',
'tagging-rules.operator.equals': 'es igual a',
'tagging-rules.operator.not-equals': 'no es igual a',
'tagging-rules.operator.contains': 'contiene',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Ejemplo: Etiquetar documentos con \'factura\' en el nombre',
'tagging-rules.form.description.max-length': 'La descripción debe tener menos de 256 caracteres',
'tagging-rules.form.conditions.label': 'Condiciones',
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Todas las condiciones deben cumplirse.',
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Sin condiciones significa que la regla se aplicará a todos los documentos',
'tagging-rules.form.conditions.add-condition': 'Añadir condición',
'tagging-rules.form.conditions.connector.when': 'Cuando',
'tagging-rules.form.conditions.connector.and': 'y que',
'tagging-rules.form.conditions.connector.or': 'o que',
'tagging-rules.condition-match-mode.all': 'Todas las condiciones deben coincidir',
'tagging-rules.condition-match-mode.any': 'Cualquier condición debe coincidir',
'tagging-rules.form.conditions.no-conditions.title': 'Sin condiciones',
'tagging-rules.form.conditions.no-conditions.description': 'No añadiste ninguna condición a esta regla. Esta regla aplicará sus etiquetas a todos los documentos.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regla sin condiciones',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Error al actualizar la regla de etiquetado',
'tagging-rules.update.submit': 'Actualizar regla',
'tagging-rules.update.cancel': 'Cancelar',
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
'tagging-rules.apply.confirm.title': '¿Aplicar regla a documentos existentes?',
'tagging-rules.apply.confirm.description': 'Esto verificará todos los documentos existentes en tu organización y aplicará etiquetas donde las condiciones coincidan. El procesamiento se realizará en segundo plano.',
'tagging-rules.apply.confirm.button': 'Aplicar regla',
'tagging-rules.apply.success': 'Aplicación de regla iniciada en segundo plano',
'tagging-rules.apply.error': 'Error al iniciar la aplicación de la regla',
'tagging-rules.apply.processing': 'Iniciando...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
'api-errors.organization.has_active_subscription': 'No se puede eliminar la organización con una suscripción activa. Por favor, cancela tu suscripción primero usando el botón Gestionar Suscripción arriba.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario',
'api-errors.FAILED_TO_CREATE_SESSION': 'Error al crear sesión',
'api-errors.FAILED_TO_UPDATE_USER': 'Error al actualizar usuario',
'api-errors.FAILED_TO_GET_SESSION': 'Error al obtener sesión',
'api-errors.INVALID_PASSWORD': 'Contraseña inválida',
'api-errors.INVALID_EMAIL': 'Email inválido',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'El email o la contraseña es incorrecta, o la cuenta no existe.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Cuenta social ya vinculada',
'api-errors.PROVIDER_NOT_FOUND': 'Proveedor no encontrado',
'api-errors.INVALID_TOKEN': 'Token inválido',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID no soportado',
'api-errors.FAILED_TO_GET_USER_INFO': 'Error al obtener información del usuario',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email del usuario no encontrado',
'api-errors.EMAIL_NOT_VERIFIED': 'Email no verificado',
'api-errors.PASSWORD_TOO_SHORT': 'Contraseña demasiado corta',
'api-errors.PASSWORD_TOO_LONG': 'Contraseña demasiado larga',
'api-errors.USER_ALREADY_EXISTS': 'Ya existe un usuario con este email',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'El email no puede ser actualizado',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Cuenta de credenciales no encontrada',
'api-errors.SESSION_EXPIRED': 'Sesión expirada',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Error al desvincular la última cuenta',
'api-errors.ACCOUNT_NOT_FOUND': 'Cuenta no encontrada',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'El usuario ya tiene contraseña',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/mes',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturado anualmente',
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tiempo limitado',
'subscriptions.upgrade-dialog.promo-banner.description': '¡Obtén {{ percent }}% de descuento por organización en todos los planes para siempre como early adopter! La oferta expira en {{ days, >1:{days} días, =1:1 día, menos de un día }}.',
'subscriptions.plan.free.name': 'Plan gratuito',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Vérifier votre email',
'auth.email-validation-required.description': 'Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l\'email.',
'auth.email-verification.success.title': 'Email vérifié',
'auth.email-verification.success.description': 'Votre email a été vérifié avec succès. Vous pouvez maintenant vous connecter à votre compte.',
'auth.email-verification.success.login': 'Aller à la connexion',
'auth.email-verification.error.title': 'Échec de la vérification',
'auth.email-verification.error.description': 'Le lien de vérification est invalide ou a expiré. Veuillez demander un nouvel email de vérification en vous connectant.',
'auth.email-verification.error.back': 'Retour à la connexion',
'auth.legal-links.description': 'En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.',
'auth.legal-links.terms': 'Conditions d\'utilisation',
'auth.legal-links.privacy': 'Politique de confidentialité',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Annuler',
'organization.settings.delete.success': 'Organisation supprimée',
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
'organization.settings.delete.has-active-subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif, veuillez d\'abord annuler votre abonnement ci-dessus.',
'organization.usage.page.title': 'Utilisation',
'organization.usage.page.description': 'Consultez l\'utilisation actuelle et les limites de votre organisation.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nom du document',
'tagging-rules.field.content': 'contenu du document',
'tagging-rules.field.name': 'le nom du document',
'tagging-rules.field.content': 'le contenu du document',
'tagging-rules.operator.equals': 'égal à',
'tagging-rules.operator.not-equals': 'différent de',
'tagging-rules.operator.contains': 'contient',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Exemple: Catégoriser les documents avec \'facture\' dans le nom',
'tagging-rules.form.description.max-length': 'La description doit contenir moins de 256 caractères',
'tagging-rules.form.conditions.label': 'Conditions',
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Toutes les conditions doivent être remplies pour que la règle s\'applique.',
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Si aucune condition n\'est définie, la règle s\'appliquera à tous les documents.',
'tagging-rules.form.conditions.add-condition': 'Ajouter une condition',
'tagging-rules.form.conditions.connector.when': 'Quand',
'tagging-rules.form.conditions.connector.and': 'et que',
'tagging-rules.form.conditions.connector.or': 'ou que',
'tagging-rules.condition-match-mode.all': 'Toutes les conditions doivent correspondre',
'tagging-rules.condition-match-mode.any': 'Au moins une condition doit correspondre',
'tagging-rules.form.conditions.no-conditions.title': 'Aucune condition',
'tagging-rules.form.conditions.no-conditions.description': 'Vous n\'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Appliquer la règle sans conditions',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Échec de la mise à jour de la règle de catégorisation',
'tagging-rules.update.submit': 'Mettre à jour la règle',
'tagging-rules.update.cancel': 'Annuler',
'tagging-rules.apply.button': 'Appliquer aux documents existants',
'tagging-rules.apply.confirm.title': 'Appliquer la règle aux documents existants ?',
'tagging-rules.apply.confirm.description': 'Cela vérifiera tous les documents existants dans votre organisation et appliquera les tags où les conditions correspondent. Le traitement se fera en arrière-plan.',
'tagging-rules.apply.confirm.button': 'Appliquer la règle',
'tagging-rules.apply.success': 'Application de la règle démarrée en arrière-plan',
'tagging-rules.apply.error': 'Échec du démarrage de l\'application de la règle',
'tagging-rules.apply.processing': 'Démarrage...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
'api-errors.organization.has_active_subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif. Veuillez d\'abord annuler votre abonnement en utilisant le bouton Gérer l\'abonnement ci-dessus.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable',
'api-errors.FAILED_TO_CREATE_USER': 'Échec de la création de l\'utilisateur',
'api-errors.FAILED_TO_CREATE_SESSION': 'Échec de la création de la session',
'api-errors.FAILED_TO_UPDATE_USER': 'Échec de la mise à jour de l\'utilisateur',
'api-errors.FAILED_TO_GET_SESSION': 'Échec de la récupération de la session',
'api-errors.INVALID_PASSWORD': 'Mot de passe invalide',
'api-errors.INVALID_EMAIL': 'Email invalide',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email ou le mot de passe est incorrect, ou le compte n\'existe pas.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Compte social déjà associé',
'api-errors.PROVIDER_NOT_FOUND': 'Fournisseur introuvable',
'api-errors.INVALID_TOKEN': 'Jeton invalide',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Jeton d\'identité non pris en charge',
'api-errors.FAILED_TO_GET_USER_INFO': 'Échec de la récupération des informations utilisateur',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email de l\'utilisateur introuvable',
'api-errors.EMAIL_NOT_VERIFIED': 'Email non vérifié',
'api-errors.PASSWORD_TOO_SHORT': 'Mot de passe trop court',
'api-errors.PASSWORD_TOO_LONG': 'Mot de passe trop long',
'api-errors.USER_ALREADY_EXISTS': 'Un utilisateur avec cet email existe déjà',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email ne peut pas être modifié',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Compte d\'identification introuvable',
'api-errors.SESSION_EXPIRED': 'Session expirée',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Échec de la dissociation du dernier compte',
'api-errors.ACCOUNT_NOT_FOUND': 'Compte introuvable',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utilisateur a déjà un mot de passe',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/mois',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturé annuellement',
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
'subscriptions.upgrade-dialog.promo-banner.title': 'Offre à durée limitée',
'subscriptions.upgrade-dialog.promo-banner.description': 'Bénéficiez de {{ percent }}% de réduction à vie par organisation sur tous les forfaits en tant qu\'early adopter ! L\'offre expire dans {{ days, >1:{days} jours, =1:1 jour, moins d\'un jour }}.',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Verifica la tua email',
'auth.email-validation-required.description': 'Una email di verifica è stata inviata al tuo indirizzo email. Verifica il tuo indirizzo cliccando il link nell\'email.',
'auth.email-verification.success.title': 'Email verificata',
'auth.email-verification.success.description': 'La tua email è stata verificata con successo. Ora puoi accedere al tuo account.',
'auth.email-verification.success.login': 'Vai al login',
'auth.email-verification.error.title': 'Verifica fallita',
'auth.email-verification.error.description': 'Il link di verifica non è valido o è scaduto. Richiedi una nuova email di verifica effettuando l\'accesso.',
'auth.email-verification.error.back': 'Torna al login',
'auth.legal-links.description': 'Continuando, confermi di aver letto e accettato i {{ terms }} e l\'{{ privacy }}.',
'auth.legal-links.terms': 'Termini di servizio',
'auth.legal-links.privacy': 'Informativa sulla privacy',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Annulla',
'organization.settings.delete.success': 'Organizzazione eliminata',
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
'organization.settings.delete.has-active-subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo, si prega di annullare prima l\'abbonamento sopra.',
'organization.usage.page.title': 'Utilizzo',
'organization.usage.page.description': 'Visualizza l\'utilizzo attuale e i limiti della tua organizzazione.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nome documento',
'tagging-rules.field.content': 'contenuto documento',
'tagging-rules.field.name': 'il nome del documento',
'tagging-rules.field.content': 'il contenuto del documento',
'tagging-rules.operator.equals': 'uguale a',
'tagging-rules.operator.not-equals': 'diverso da',
'tagging-rules.operator.contains': 'contiene',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Esempio: Tagga i documenti con \'fattura\' nel nome',
'tagging-rules.form.description.max-length': 'La descrizione deve essere inferiore a 256 caratteri',
'tagging-rules.form.conditions.label': 'Condizioni',
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Tutte le condizioni devono essere soddisfatte.',
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Nessuna condizione significa che la regola si applicherà a tutti i documenti',
'tagging-rules.form.conditions.add-condition': 'Aggiungi condizione',
'tagging-rules.form.conditions.connector.when': 'Quando',
'tagging-rules.form.conditions.connector.and': 'e che',
'tagging-rules.form.conditions.connector.or': 'o che',
'tagging-rules.condition-match-mode.all': 'Tutte le condizioni devono corrispondere',
'tagging-rules.condition-match-mode.any': 'Qualsiasi condizione deve corrispondere',
'tagging-rules.form.conditions.no-conditions.title': 'Nessuna condizione',
'tagging-rules.form.conditions.no-conditions.description': 'Non hai aggiunto nessuna condizione a questa regola. Questa regola applicherà i suoi tag a tutti i documenti.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Applica regola senza condizioni',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Errore nell\'aggiornamento della regola di tagging',
'tagging-rules.update.submit': 'Aggiorna regola',
'tagging-rules.update.cancel': 'Annulla',
'tagging-rules.apply.button': 'Applica ai documenti esistenti',
'tagging-rules.apply.confirm.title': 'Applicare la regola ai documenti esistenti?',
'tagging-rules.apply.confirm.description': 'Questo controllerà tutti i documenti esistenti nella tua organizzazione e applicherà i tag dove le condizioni corrispondono. L\'elaborazione avverrà in background.',
'tagging-rules.apply.confirm.button': 'Applica regola',
'tagging-rules.apply.success': 'Applicazione della regola avviata in background',
'tagging-rules.apply.error': 'Impossibile avviare l\'applicazione della regola',
'tagging-rules.apply.processing': 'Avvio in corso...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
'api-errors.organization.has_active_subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo. Si prega di annullare prima l\'abbonamento utilizzando il pulsante Gestisci abbonamento sopra.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utente non trovato',
'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente',
'api-errors.FAILED_TO_CREATE_SESSION': 'Impossibile creare la sessione',
'api-errors.FAILED_TO_UPDATE_USER': 'Impossibile aggiornare l\'utente',
'api-errors.FAILED_TO_GET_SESSION': 'Impossibile recuperare la sessione',
'api-errors.INVALID_PASSWORD': 'Password non valida',
'api-errors.INVALID_EMAIL': 'Email non valida',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email o la password non è corretta, oppure l\'account non esiste.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Account social già collegato',
'api-errors.PROVIDER_NOT_FOUND': 'Provider non trovato',
'api-errors.INVALID_TOKEN': 'Token non valido',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID non supportato',
'api-errors.FAILED_TO_GET_USER_INFO': 'Impossibile recuperare le informazioni utente',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email utente non trovata',
'api-errors.EMAIL_NOT_VERIFIED': 'Email non verificata',
'api-errors.PASSWORD_TOO_SHORT': 'Password troppo corta',
'api-errors.PASSWORD_TOO_LONG': 'Password troppo lunga',
'api-errors.USER_ALREADY_EXISTS': 'Esiste già un utente con questa email',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email non può essere aggiornata',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Account credenziali non trovato',
'api-errors.SESSION_EXPIRED': 'Sessione scaduta',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Impossibile scollegare l\'ultimo account',
'api-errors.ACCOUNT_NOT_FOUND': 'Account non trovato',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utente ha già una password',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/mese',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} fatturato annualmente',
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
'subscriptions.upgrade-dialog.promo-banner.title': 'Offerta a tempo limitato',
'subscriptions.upgrade-dialog.promo-banner.description': 'Ottieni {{ percent }}% di sconto per organizzazione su tutti i piani per sempre come early adopter! L\'offerta scade tra {{ days, >1:{days} giorni, =1:1 giorno, meno di un giorno }}.',
'subscriptions.plan.free.name': 'Piano gratuito',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Zweryfikuj swój adres e-mail',
'auth.email-validation-required.description': 'Wiadomość weryfikacyjna została wysłana na Twój adres e-mail. Zweryfikuj swój adres e-mail, klikając link w wiadomości.',
'auth.email-verification.success.title': 'E-mail zweryfikowany',
'auth.email-verification.success.description': 'Twój adres e-mail został pomyślnie zweryfikowany. Możesz teraz zalogować się do swojego konta.',
'auth.email-verification.success.login': 'Przejdź do logowania',
'auth.email-verification.error.title': 'Weryfikacja nie powiodła się',
'auth.email-verification.error.description': 'Link weryfikacyjny jest nieprawidłowy lub wygasł. Poproś o nową wiadomość weryfikacyjną, logując się.',
'auth.email-verification.error.back': 'Powrót do logowania',
'auth.legal-links.description': 'Kontynuując, potwierdzasz, że rozumiesz i zgadzasz się na {{ terms }} oraz {{ privacy }}.',
'auth.legal-links.terms': 'Warunki korzystania z usługi',
'auth.legal-links.privacy': 'Polityka prywatności',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Anuluj',
'organization.settings.delete.success': 'Organizacja została usunięta',
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
'organization.settings.delete.has-active-subscription': 'Nie można usunąć organizacji z aktywną subskrypcją, proszę najpierw anulować subskrypcję powyżej.',
'organization.usage.page.title': 'Użycie',
'organization.usage.page.description': 'Zobacz aktualne użycie i limity Twojej organizacji.',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Przykład: Oznacz dokumenty ze słowem \'faktura\' w nazwie',
'tagging-rules.form.description.max-length': 'Opis musi mieć mniej niż 256 znaków',
'tagging-rules.form.conditions.label': 'Warunki',
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Wszystkie warunki muszą być spełnione, aby reguła mogła zostać zastosowana.',
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Brak warunków oznacza, że reguła zostanie zastosowana do wszystkich dokumentów',
'tagging-rules.form.conditions.add-condition': 'Dodaj warunek',
'tagging-rules.form.conditions.connector.when': 'Kiedy',
'tagging-rules.form.conditions.connector.and': 'i',
'tagging-rules.form.conditions.connector.or': 'lub',
'tagging-rules.condition-match-mode.all': 'Wszystkie warunki muszą być spełnione',
'tagging-rules.condition-match-mode.any': 'Dowolny warunek musi być spełniony',
'tagging-rules.form.conditions.no-conditions.title': 'Brak warunków',
'tagging-rules.form.conditions.no-conditions.description': 'Nie dodałeś żadnych warunków do tej reguły. Ta reguła zastosuje swoje tagi do wszystkich dokumentów.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Zastosuj regułę bez warunków',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Nie udało się zaktualizować reguły tagowania',
'tagging-rules.update.submit': 'Zaktualizuj regułę',
'tagging-rules.update.cancel': 'Anuluj',
'tagging-rules.apply.button': 'Zastosuj do istniejących dokumentów',
'tagging-rules.apply.confirm.title': 'Zastosować regułę do istniejących dokumentów?',
'tagging-rules.apply.confirm.description': 'Sprawdzi to wszystkie istniejące dokumenty w organizacji i zastosuje tagi tam, gdzie warunki są spełnione. Przetwarzanie odbędzie się w tle.',
'tagging-rules.apply.confirm.button': 'Zastosuj regułę',
'tagging-rules.apply.success': 'Rozpoczęto stosowanie reguły w tle',
'tagging-rules.apply.error': 'Nie udało się rozpocząć stosowania reguły',
'tagging-rules.apply.processing': 'Uruchamianie...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
'api-errors.organization.has_active_subscription': 'Nie można usunąć organizacji z aktywną subskrypcją. Proszę najpierw anulować subskrypcję za pomocą przycisku Zarządzaj subskrypcją powyżej.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Nie znaleziono użytkownika',
'api-errors.FAILED_TO_CREATE_USER': 'Nie udało się utworzyć użytkownika',
'api-errors.FAILED_TO_CREATE_SESSION': 'Nie udało się utworzyć sesji',
'api-errors.FAILED_TO_UPDATE_USER': 'Nie udało się zaktualizować użytkownika',
'api-errors.FAILED_TO_GET_SESSION': 'Nie udało się pobrać sesji',
'api-errors.INVALID_PASSWORD': 'Nieprawidłowe hasło',
'api-errors.INVALID_EMAIL': 'Nieprawidłowy email',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email lub hasło jest nieprawidłowe, lub konto nie istnieje.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Konto społecznościowe już połączone',
'api-errors.PROVIDER_NOT_FOUND': 'Nie znaleziono dostawcy',
'api-errors.INVALID_TOKEN': 'Nieprawidłowy token',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nie jest obsługiwany',
'api-errors.FAILED_TO_GET_USER_INFO': 'Nie udało się pobrać informacji o użytkowniku',
'api-errors.USER_EMAIL_NOT_FOUND': 'Nie znaleziono emaila użytkownika',
'api-errors.EMAIL_NOT_VERIFIED': 'Email nie został zweryfikowany',
'api-errors.PASSWORD_TOO_SHORT': 'Hasło zbyt krótkie',
'api-errors.PASSWORD_TOO_LONG': 'Hasło zbyt długie',
'api-errors.USER_ALREADY_EXISTS': 'Użytkownik z tym emailem już istnieje',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email nie może być zaktualizowany',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Nie znaleziono konta uwierzytelniającego',
'api-errors.SESSION_EXPIRED': 'Sesja wygasła',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Nie udało się odłączyć ostatniego konta',
'api-errors.ACCOUNT_NOT_FOUND': 'Nie znaleziono konta',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Użytkownik ma już hasło',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} rozliczane rocznie',
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta ograniczona czasowo',
'subscriptions.upgrade-dialog.promo-banner.description': 'Uzyskaj {{ percent }}% zniżki na organizację na wszystkie plany na zawsze jako early adopter! Oferta wygasa za {{ days, >1:{days} dni, =1:1 dzień, mniej niż 1 dzień }}.',
'subscriptions.plan.free.name': 'Plan darmowy',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Verifique seu e-mail',
'auth.email-validation-required.description': 'Um e-mail de verificação foi enviado para seu endereço. Por favor, verifique seu e-mail clicando no link enviado.',
'auth.email-verification.success.title': 'E-mail verificado',
'auth.email-verification.success.description': 'Seu e-mail foi verificado com sucesso. Você já pode fazer login na sua conta.',
'auth.email-verification.success.login': 'Ir para login',
'auth.email-verification.error.title': 'Falha na verificação',
'auth.email-verification.error.description': 'O link de verificação é inválido ou expirou. Por favor, solicite um novo e-mail de verificação ao fazer login.',
'auth.email-verification.error.back': 'Voltar ao login',
'auth.legal-links.description': 'Ao continuar, você reconhece que leu e concorda com os {{ terms }} e a {{ privacy }}.',
'auth.legal-links.terms': 'Termos de Serviço',
'auth.legal-links.privacy': 'Política de Privacidade',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização excluída',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
'organization.settings.delete.has-active-subscription': 'Não é possível excluir a organização com uma assinatura ativa, por favor cancele sua assinatura acima primeiro.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nome do documento',
'tagging-rules.field.content': 'conteúdo do documento',
'tagging-rules.field.name': 'o nome do documento',
'tagging-rules.field.content': 'o conteúdo do documento',
'tagging-rules.operator.equals': 'é igual a',
'tagging-rules.operator.not-equals': 'é diferente de',
'tagging-rules.operator.contains': 'contém',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Exemplo: Marcar documentos com \'fatura\' no nome',
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
'tagging-rules.form.conditions.label': 'Condições',
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Todas as condições devem ser atendidas.',
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Sem condições significa que a regra será aplicada a todos os documentos',
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
'tagging-rules.form.conditions.connector.when': 'Quando',
'tagging-rules.form.conditions.connector.and': 'e que',
'tagging-rules.form.conditions.connector.or': 'ou que',
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
'tagging-rules.form.conditions.no-conditions.title': 'Nenhuma condição',
'tagging-rules.form.conditions.no-conditions.description': 'Você não adicionou nenhuma condição a esta regra. Ela será aplicada a todos os documentos.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Falha ao atualizar a regra de marcação',
'tagging-rules.update.submit': 'Atualizar regra',
'tagging-rules.update.cancel': 'Cancelar',
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
'tagging-rules.apply.confirm.description': 'Isso verificará todos os documentos existentes em sua organização e aplicará tags onde as condições correspondam. O processamento será feito em segundo plano.',
'tagging-rules.apply.confirm.button': 'Aplicar regra',
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
'tagging-rules.apply.processing': 'Iniciando...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
'api-errors.organization.has_active_subscription': 'Não é possível excluir a organização com uma assinatura ativa. Por favor, cancele sua assinatura primeiro usando o botão Gerenciar Assinatura acima.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Usuário não encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar usuário',
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar usuário',
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
'api-errors.INVALID_PASSWORD': 'Senha inválida',
'api-errors.INVALID_EMAIL': 'Email inválido',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a senha está incorreta, ou a conta não existe.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já vinculada',
'api-errors.PROVIDER_NOT_FOUND': 'Provedor não encontrado',
'api-errors.INVALID_TOKEN': 'Token inválido',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do usuário',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do usuário não encontrado',
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
'api-errors.PASSWORD_TOO_SHORT': 'Senha muito curta',
'api-errors.PASSWORD_TOO_LONG': 'Senha muito longa',
'api-errors.USER_ALREADY_EXISTS': 'Já existe um usuário com este email',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desvincular a última conta',
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O usuário já possui uma senha',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
'subscriptions.upgrade-dialog.promo-banner.description': 'Ganhe {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Verifique o seu e-mail',
'auth.email-validation-required.description': 'Foi enviado um e-mail de verificação para o seu endereço de e-mail. Por favor, verifique o seu endereço de e-mail clicando na ligação no e-mail.',
'auth.email-verification.success.title': 'E-mail verificado',
'auth.email-verification.success.description': 'O seu e-mail foi verificado com sucesso. Pode agora iniciar sessão na sua conta.',
'auth.email-verification.success.login': 'Ir para o login',
'auth.email-verification.error.title': 'Falha na verificação',
'auth.email-verification.error.description': 'A ligação de verificação é inválida ou expirou. Por favor, solicite um novo e-mail de verificação ao iniciar sessão.',
'auth.email-verification.error.back': 'Voltar ao login',
'auth.legal-links.description': 'Ao continuar, reconhece que compreende e concorda com os {{ terms }} e a {{ privacy }}.',
'auth.legal-links.terms': 'Termos de Serviço',
'auth.legal-links.privacy': 'Política de Privacidade',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização eliminada',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
'organization.settings.delete.has-active-subscription': 'Não é possível eliminar a organização com uma subscrição ativa, por favor cancele a sua subscrição acima primeiro.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nome do documento',
'tagging-rules.field.content': 'conteúdo do documento',
'tagging-rules.field.name': 'o nome do documento',
'tagging-rules.field.content': 'o conteúdo do documento',
'tagging-rules.operator.equals': 'igual a',
'tagging-rules.operator.not-equals': 'não igual a',
'tagging-rules.operator.contains': 'contém',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Exemplo: Etiquetar documentos com \'fatura\' no nome',
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
'tagging-rules.form.conditions.label': 'Condições',
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Todas as condições devem ser cumpridas para a regra se aplicar.',
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Sem condições significa que a regra se aplicada a todos os documentos',
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
'tagging-rules.form.conditions.connector.when': 'Quando',
'tagging-rules.form.conditions.connector.and': 'e que',
'tagging-rules.form.conditions.connector.or': 'ou que',
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
'tagging-rules.form.conditions.no-conditions.title': 'Sem condições',
'tagging-rules.form.conditions.no-conditions.description': 'Não adicionou nenhuma condição a esta regra. Esta regra aplicará as suas etiquetas a todos os documentos.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Falha ao atualizar regra de etiquetagem',
'tagging-rules.update.submit': 'Atualizar regra',
'tagging-rules.update.cancel': 'Cancelar',
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
'tagging-rules.apply.confirm.description': 'Isto irá verificar todos os documentos existentes na sua organização e aplicar etiquetas onde as condições correspondam. O processamento será feito em segundo plano.',
'tagging-rules.apply.confirm.button': 'Aplicar regra',
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
'tagging-rules.apply.processing': 'A iniciar...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
'api-errors.organization.has_active_subscription': 'Não é possível eliminar a organização com uma subscrição ativa. Por favor, cancele a sua subscrição primeiro usando o botão Gerir Subscrição acima.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador',
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar utilizador',
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
'api-errors.INVALID_PASSWORD': 'Palavra-passe inválida',
'api-errors.INVALID_EMAIL': 'Email inválido',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a palavra-passe está incorreta, ou a conta não existe.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já associada',
'api-errors.PROVIDER_NOT_FOUND': 'Fornecedor não encontrado',
'api-errors.INVALID_TOKEN': 'Token inválido',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do utilizador',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do utilizador não encontrado',
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
'api-errors.PASSWORD_TOO_SHORT': 'Palavra-passe demasiado curta',
'api-errors.PASSWORD_TOO_LONG': 'Palavra-passe demasiado longa',
'api-errors.USER_ALREADY_EXISTS': 'Já existe um utilizador com este email',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desassociar a última conta',
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O utilizador já tem uma palavra-passe',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
'subscriptions.upgrade-dialog.promo-banner.description': 'Obtenha {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
'auth.email-validation-required.title': 'Verifică-ți email-ul',
'auth.email-validation-required.description': 'A fost trimis un e-mail de verificare la adresa ta de e-mail. Te rugăm să îți verifici adresa de e-mail dând click pe linkul din e-mail.',
'auth.email-verification.success.title': 'Email verificat',
'auth.email-verification.success.description': 'Email-ul tău a fost verificat cu succes. Acum te poți autentifica în contul tău.',
'auth.email-verification.success.login': 'Mergi la autentificare',
'auth.email-verification.error.title': 'Verificare eșuată',
'auth.email-verification.error.description': 'Linkul de verificare este invalid sau a expirat. Te rugăm să soliciți un nou e-mail de verificare autentificându-te.',
'auth.email-verification.error.back': 'Înapoi la autentificare',
'auth.legal-links.description': 'Continuând, confirmați că întelegeți și sunteti de acord cu {{ terms }} și {{ privacy }}.',
'auth.legal-links.terms': 'Termenii și condițiile',
'auth.legal-links.privacy': 'Politica de confidențialitate',
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.cancel-button': 'Anulează',
'organization.settings.delete.success': 'Organizație ștearsă cu succes',
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
'organization.settings.delete.has-active-subscription': 'Nu se poate șterge organizația cu un abonament activ, vă rugăm să anulați mai întâi abonamentul de mai sus.',
'organization.usage.page.title': 'Utilizare',
'organization.usage.page.description': 'Vizualizează utilizarea curentă și limitele organizației tale.',
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
// Tagging rules
'tagging-rules.field.name': 'nume document',
'tagging-rules.field.content': 'conținut document',
'tagging-rules.field.name': 'numele documentului',
'tagging-rules.field.content': 'conținutul documentului',
'tagging-rules.operator.equals': 'egal cu',
'tagging-rules.operator.not-equals': 'nu este egal cu',
'tagging-rules.operator.contains': 'conține',
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.form.description.placeholder': 'Exemplu: Etichetează documentele cu \'factură\' în nume',
'tagging-rules.form.description.max-length': 'Descrierea trebuie să aibă mai puțin de 256 de caractere',
'tagging-rules.form.conditions.label': 'Condiții',
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Toate condițiile trebuie îndeplinite pentru ca regula să se aplice.',
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Fără condiții înseamnă că regula se va aplica tuturor documentelor',
'tagging-rules.form.conditions.add-condition': 'Adaugă condiție',
'tagging-rules.form.conditions.connector.when': 'Când',
'tagging-rules.form.conditions.connector.and': 'și că',
'tagging-rules.form.conditions.connector.or': 'sau că',
'tagging-rules.condition-match-mode.all': 'Toate condițiile trebuie îndeplinite',
'tagging-rules.condition-match-mode.any': 'Orice condiție trebuie îndeplinită',
'tagging-rules.form.conditions.no-conditions.title': 'Nicio condiție',
'tagging-rules.form.conditions.no-conditions.description': 'Nu ai adăugat nicio condiție acestei reguli. Această regula va aplica etichetele sale tuturor documentelor.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplică regula fara condiții',
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
'tagging-rules.update.error': 'Nu s-a putut actualiza regula de etichetare',
'tagging-rules.update.submit': 'Actualizează regula',
'tagging-rules.update.cancel': 'Anulează',
'tagging-rules.apply.button': 'Aplicați la documente existente',
'tagging-rules.apply.confirm.title': 'Aplicați regula la documente existente?',
'tagging-rules.apply.confirm.description': 'Aceasta va verifica toate documentele existente din organizația dvs. și va aplica etichetele unde condițiile corespund. Procesarea va avea loc în fundal.',
'tagging-rules.apply.confirm.button': 'Aplicați regula',
'tagging-rules.apply.success': 'Aplicarea regulii a fost pornită în fundal',
'tagging-rules.apply.error': 'Eroare la pornirea aplicării regulii',
'tagging-rules.apply.processing': 'Se pornește...',
// Intake emails
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
'api-errors.organization.has_active_subscription': 'Nu se poate șterge organizația cu un abonament activ. Vă rugăm să anulați mai întâi abonamentul folosind butonul Gestionați abonamentul de mai sus.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit',
'api-errors.FAILED_TO_CREATE_USER': 'Eroare la crearea utilizatorului',
'api-errors.FAILED_TO_CREATE_SESSION': 'Eroare la crearea sesiunii',
'api-errors.FAILED_TO_UPDATE_USER': 'Eroare la actualizarea utilizatorului',
'api-errors.FAILED_TO_GET_SESSION': 'Eroare la obținerea sesiunii',
'api-errors.INVALID_PASSWORD': 'Parolă invalidă',
'api-errors.INVALID_EMAIL': 'Email invalid',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email-ul sau parola este incorectă, sau contul nu există.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Contul social este deja asociat',
'api-errors.PROVIDER_NOT_FOUND': 'Furnizorul nu a fost găsit',
'api-errors.INVALID_TOKEN': 'Token invalid',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nu este suportat',
'api-errors.FAILED_TO_GET_USER_INFO': 'Eroare la obținerea informațiilor utilizatorului',
'api-errors.USER_EMAIL_NOT_FOUND': 'Email-ul utilizatorului nu a fost găsit',
'api-errors.EMAIL_NOT_VERIFIED': 'Email-ul nu este verificat',
'api-errors.PASSWORD_TOO_SHORT': 'Parolă prea scurtă',
'api-errors.PASSWORD_TOO_LONG': 'Parolă prea lungă',
'api-errors.USER_ALREADY_EXISTS': 'Există deja un utilizator cu acest email',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email-ul nu poate fi actualizat',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Contul de autentificare nu a fost găsit',
'api-errors.SESSION_EXPIRED': 'Sesiunea a expirat',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Eroare la disocierea ultimului cont',
'api-errors.ACCOUNT_NOT_FOUND': 'Contul nu a fost găsit',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Utilizatorul are deja o parolă',
// Not found
@@ -636,6 +682,8 @@ export const translations: Partial<TranslationsDictionary> = {
'subscriptions.upgrade-dialog.per-month': '/lună',
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturat anual',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
'subscriptions.upgrade-dialog.promo-banner.title': 'Ofertă pe durată limitată',
'subscriptions.upgrade-dialog.promo-banner.description': 'Obțineți {{ percent }}% reducere pe organizație la toate planurile pentru totdeauna ca early adopter! Oferta expiră în {{ days, >1:{days} zile, =1:1 zi, mai puțin de o zi }}.',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',

View File

@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { A } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -13,7 +12,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { t, formatRelativeTime, formatDate } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = useMutation(() => ({
@@ -57,15 +56,15 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
</div>
<div>
<p class="text-muted-foreground text-xs">
{/* <p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : t('api-keys.list.card.never')}
</p> */}
<p class="text-muted-foreground text-xs" title={formatDate(apiKey.createdAt, { dateStyle: 'short', timeStyle: 'long' })}>
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
{formatRelativeTime(apiKey.createdAt)}
</p>
</div>

View File

@@ -10,3 +10,7 @@ export const ssoProviders = [
icon: 'i-tabler-brand-github',
},
] as const;
export const authPagesPaths = {
emailVerification: '/email-verification',
};

View File

@@ -4,6 +4,7 @@ import type { SsoProviderConfig } from './auth.types';
import { genericOAuthClient } from 'better-auth/client/plugins';
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import { buildTimeConfig } from '../config/config';
import { queryClient } from '../shared/query/query-client';
import { trackingServices } from '../tracking/tracking.services';
import { createDemoAuthClient } from './auth.demo.services';
@@ -28,6 +29,8 @@ export function createAuthClient() {
const result = await client.signOut();
trackingServices.reset();
queryClient.clear();
return result;
},
};

View File

@@ -0,0 +1,56 @@
import type { Component } from 'solid-js';
import { A, useSearchParams } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
export const EmailVerificationPage: Component = () => {
const { t } = useI18n();
const [searchParams] = useSearchParams();
const getHasError = () => Boolean(searchParams.error);
return (
<AuthLayout>
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-xs w-full flex flex-col items-center text-center">
{getHasError()
? (
<>
<div class="i-tabler-alert-circle size-12 text-destructive mb-2" />
<h1 class="text-xl font-bold">
{t('auth.email-verification.error.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
{t('auth.email-verification.error.description')}
</p>
<Button as={A} href="/login" class="gap-2" variant="secondary">
<div class="i-tabler-arrow-left size-4" />
{t('auth.email-verification.error.back')}
</Button>
</>
)
: (
<>
<div class="i-tabler-circle-check size-12 text-primary mb-2" />
<h1 class="text-xl font-bold">
{t('auth.email-verification.success.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
{t('auth.email-verification.success.description')}
</p>
<Button as={A} href="/login" class="gap-2">
{t('auth.email-verification.success.login')}
<div class="i-tabler-arrow-right size-4" />
</Button>
</>
)}
</div>
</div>
</AuthLayout>
);
};

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
@@ -12,6 +13,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { authPagesPaths } from '../auth.constants';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
import { authWithProvider, signIn } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
@@ -26,7 +28,13 @@ export const EmailLoginForm: Component = () => {
const { form, Form, Field } = createForm({
onSubmit: async ({ email, password, rememberMe }) => {
const { error } = await signIn.email({ email, password, rememberMe, callbackURL: config.baseUrl });
const { error } = await signIn.email({
email,
password,
rememberMe,
// This URL is where the user will be redirected after email verification
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
});
if (isEmailVerificationRequiredError({ error })) {
navigate('/email-validation-required');
@@ -35,6 +43,8 @@ export const EmailLoginForm: Component = () => {
if (error) {
throw createI18nApiError({ error });
}
// If all good guard will redirect to dashboard
},
schema: v.object({
email: v.pipe(

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
@@ -11,6 +12,7 @@ import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { authPagesPaths } from '../auth.constants';
import { getEnabledSsoProviderConfigs } from '../auth.models';
import { authWithProvider, signUp } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
@@ -29,7 +31,8 @@ export const EmailRegisterForm: Component = () => {
email,
password,
name,
callbackURL: config.baseUrl,
// This URL is where the user will be redirected after email verification
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
});
if (error) {

View File

@@ -94,7 +94,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
path: '/api/organizations/:organizationId/documents',
method: 'GET',
handler: async ({ params: { organizationId }, query }) => {
const organization = organizationStorage.getItem(organizationId);
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && !document.deletedAt);
@@ -197,7 +197,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
searchQuery: rawSearchQuery = '',
} = query ?? {};
const organization = organizationStorage.getItem(organizationId);
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
@@ -221,7 +221,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
path: '/api/organizations/:organizationId/documents/deleted',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const organization = organizationStorage.getItem(organizationId);
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const deletedDocuments = await findMany(

View File

@@ -1,4 +1,3 @@
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Accessor, Component, Setter } from 'solid-js';
import type { Document } from '../documents.types';
@@ -7,13 +6,12 @@ import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, Show, Switch } from 'solid-js';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { cn } from '@/modules/shared/style/cn';
import { TagLink } from '@/modules/tags/components/tag.component';
import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';
@@ -25,13 +23,13 @@ type Pagination = {
export const createdAtColumn: ColumnDef<Document> = {
header: () => (<span class="hidden sm:block">Created at</span>),
accessorKey: 'createdAt',
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
};
export const deletedAtColumn: ColumnDef<Document> = {
header: () => (<span class="hidden sm:block">Deleted at</span>),
accessorKey: 'deletedAt',
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
};
export const standardActionsColumn: ColumnDef<Document> = {
@@ -92,17 +90,7 @@ export const DocumentsPaginatedList: Component<{
{' '}
-
{' '}
<Tooltip>
<TooltipTrigger as={(tooltipProps: TooltipTriggerProps) => (
<span {...tooltipProps}>
{timeAgo({ date: data.row.original.createdAt })}
</span>
)}
/>
<TooltipContent>
{data.row.original.createdAt.toLocaleString()}
</TooltipContent>
</Tooltip>
<RelativeTime date={data.row.original.createdAt} />
</div>
</div>
</div>

View File

@@ -99,6 +99,98 @@ describe('files models', () => {
expect(daysBeforeDeletion).to.eql(undefined);
});
test('returns 0 when the permanent deletion date is today', () => {
const document = { deletedAt: new Date('2021-01-01') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2021-01-31');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(0);
});
test('returns negative days when the permanent deletion date has passed', () => {
const document = { deletedAt: new Date('2021-01-01') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2021-02-15');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(-15);
});
test('handles deletion that happened on the same day (considers time of day)', () => {
const document = { deletedAt: new Date('2021-01-10T08:00:00') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2021-01-10T14:00:00');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
// Since differenceInDays counts full days, and there's only 6 hours difference,
// the permanent deletion date (30 days from 08:00) is 29 full days from 14:00
expect(daysBeforeDeletion).to.eql(29);
});
test('handles very short retention periods', () => {
const document = { deletedAt: new Date('2021-01-10') };
const deletedDocumentsRetentionDays = 1;
const now = new Date('2021-01-10');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(1);
});
test('handles very long retention periods', () => {
const document = { deletedAt: new Date('2021-01-01') };
const deletedDocumentsRetentionDays = 365;
const now = new Date('2021-01-10');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(356);
});
test('handles zero retention days (immediate deletion)', () => {
const document = { deletedAt: new Date('2021-01-10') };
const deletedDocumentsRetentionDays = 0;
const now = new Date('2021-01-10');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(0);
});
test('handles dates across year boundaries', () => {
const document = { deletedAt: new Date('2020-12-20') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2021-01-05');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(14);
});
test('handles dates across leap year February', () => {
const document = { deletedAt: new Date('2020-02-15') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2020-02-20');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(25);
});
test('handles timestamp precision with hours and minutes', () => {
const document = { deletedAt: new Date('2021-01-01T23:59:59') };
const deletedDocumentsRetentionDays = 30;
const now = new Date('2021-01-02T00:00:01');
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
expect(daysBeforeDeletion).to.eql(29);
});
});
describe('getDocumentNameWithoutExtension', () => {

View File

@@ -1,5 +1,5 @@
import type { DocumentActivityEvent } from './documents.types';
import { addDays, differenceInDays } from 'date-fns';
import { IN_MS } from '../shared/utils/units';
export const iconByFileType = {
'*': 'i-tabler-file',
@@ -44,9 +44,12 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
return undefined;
}
const deletionDate = addDays(document.deletedAt, deletedDocumentsRetentionDays);
// Calculate the permanent deletion date by adding retention days to the deleted date
const deletionDate = new Date(document.deletedAt);
deletionDate.setDate(deletionDate.getDate() + deletedDocumentsRetentionDays);
const daysBeforeDeletion = differenceInDays(deletionDate, now);
// Calculate the difference in milliseconds and convert to days
const daysBeforeDeletion = Math.floor((deletionDate.getTime() - now.getTime()) / IN_MS.DAY);
return daysBeforeDeletion;
}

View File

@@ -4,9 +4,9 @@ import { useParams } from '@solidjs/router';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
@@ -199,7 +199,7 @@ export const DeletedDocumentsPage: Component = () => {
<div class="text-muted-foreground hidden sm:block">
{t('documents.deleted.deleted-at')}
{' '}
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
<RelativeTime class="text-foreground font-bold" date={data.row.original.deletedAt!} />
</div>
),
},

View File

@@ -5,8 +5,8 @@ import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
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 { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { downloadFile } from '@/modules/shared/files/download';
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { TagLink } from '@/modules/tags/components/tag.component';
@@ -83,7 +83,7 @@ const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
</Switch>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
<RelativeTime date={props.activity.createdAt} />
<Show when={props.activity.user}>
{getUser => (
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
@@ -99,7 +99,7 @@ const tabs = ['info', 'content', 'activity'] as const;
type Tab = typeof tabs[number];
export const DocumentPage: Component = () => {
const { t } = useI18n();
const { t, formatRelativeTime } = useI18n();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { deleteDocument } = useDeleteDocument();
@@ -325,12 +325,12 @@ export const DocumentPage: Component = () => {
},
{
label: t('documents.info.created-at'),
value: timeAgo({ date: getDocument().createdAt }),
value: formatRelativeTime(getDocument().createdAt),
icon: 'i-tabler-calendar',
},
{
label: t('documents.info.updated-at'),
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
value: getDocument().updatedAt ? formatRelativeTime(getDocument().updatedAt!) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
icon: 'i-tabler-calendar',
},
]}

View File

@@ -0,0 +1,20 @@
import type { Component, JSX } from 'solid-js';
import type { CoercibleDate } from '@/modules/shared/date/date.types';
import { splitProps } from 'solid-js';
import { coerceDate } from '@/modules/shared/date/coerce-date';
import { useI18n } from '../i18n.provider';
export const RelativeTime: Component<{ date: CoercibleDate } & JSX.IntrinsicElements['time']> = (props) => {
const [local, rest] = splitProps(props, ['date', 'title', 'dateTime']);
const { formatRelativeTime, formatDate } = useI18n();
return (
<time
title={local.title ?? formatDate(local.date, { dateStyle: 'short', timeStyle: 'short' })}
dateTime={local.dateTime ?? coerceDate(local.date).toISOString()}
{...rest}
>
{formatRelativeTime(local.date)}
</time>
);
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { createTranslator, findMatchingLocale } from './i18n.models';
import { createDateFormatter, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
describe('i18n models', () => {
describe('findMatchingLocale', () => {
@@ -125,4 +125,99 @@ describe('i18n models', () => {
expect(t('hello', { name: 'John' })).to.eql('John, John, John and John!');
});
});
describe('createDateFormatter', () => {
test('formats date according to locale, by default in short format', () => {
expect(
createDateFormatter({ getLocale: () => 'en' })(new Date('2025-01-15')),
).to.eql('Jan 15, 2025');
expect(
createDateFormatter({ getLocale: () => 'fr' })(new Date('2025-01-15')),
).to.eql('15 janv. 2025');
expect(
createDateFormatter({ getLocale: () => 'pt-BR' })(new Date('2025-01-15')),
).to.eql('15 de jan. de 2025');
});
});
describe('createRelativeTimeFormatter', () => {
test('formats relative time according to locale', () => {
expect(
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
).to.eql('now');
expect(
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:06Z') }),
).to.eql('6 seconds ago');
expect(
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:02:00Z') }),
).to.eql('il y a 2 minutes');
expect(
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-03T00:00:00Z') }),
).to.eql('anteontem');
});
test('use the best unit for relative time', () => {
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
const timeAgo = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
expect(timeAgo(new Date('2021-01-01T00:00:00Z'))).toBe('now');
expect(timeAgo(new Date('2021-01-01T00:00:06Z'))).toBe('6 seconds ago');
expect(timeAgo(new Date('2021-01-01T00:01:00Z'))).toBe('1 minute ago');
expect(timeAgo(new Date('2021-01-01T00:02:00Z'))).toBe('2 minutes ago');
expect(timeAgo(new Date('2021-01-01T01:00:00Z'))).toBe('1 hour ago');
expect(timeAgo(new Date('2021-01-01T02:00:00Z'))).toBe('2 hours ago');
expect(timeAgo(new Date('2021-01-02T00:00:00Z'))).toBe('yesterday');
expect(timeAgo(new Date('2021-01-03T00:00:00Z'))).toBe('2 days ago');
expect(timeAgo(new Date('2021-02-01T00:00:00Z'))).toBe('last month');
expect(timeAgo(new Date('2021-03-02T00:00:00Z'))).toBe('2 months ago');
expect(timeAgo(new Date('2022-01-12T00:00:00Z'))).toBe('last year');
expect(timeAgo(new Date('2023-01-01T00:00:00Z'))).toBe('2 years ago');
});
test('handles future dates correctly', () => {
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
const timeUntil = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
expect(timeUntil(new Date('2020-12-31T23:59:54Z'))).toBe('in 6 seconds');
expect(timeUntil(new Date('2020-12-31T23:59:00Z'))).toBe('in 1 minute');
expect(timeUntil(new Date('2020-12-31T23:58:00Z'))).toBe('in 2 minutes');
expect(timeUntil(new Date('2020-12-31T23:00:00Z'))).toBe('in 1 hour');
expect(timeUntil(new Date('2020-12-31T22:00:00Z'))).toBe('in 2 hours');
expect(timeUntil(new Date('2020-12-31T00:00:00Z'))).toBe('tomorrow');
expect(timeUntil(new Date('2020-12-30T00:00:00Z'))).toBe('in 2 days');
expect(timeUntil(new Date('2020-12-01T00:00:00Z'))).toBe('next month');
expect(timeUntil(new Date('2020-11-01T00:00:00Z'))).toBe('in 2 months');
expect(timeUntil(new Date('2020-01-01T00:00:00Z'))).toBe('next year');
expect(timeUntil(new Date('2019-01-01T00:00:00Z'))).toBe('in 2 years');
});
test('formats future dates according to locale', () => {
expect(
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
).to.eql('in 2 minutes');
expect(
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
).to.eql('dans 2 minutes');
expect(
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-03T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
).to.eql('depois de amanhã');
});
test('the date can be a parsable string', () => {
expect(
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01T00:00:00Z', { now: new Date('2021-01-01T00:00:00Z') }),
).to.eql('now');
expect(
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01', { now: new Date('2021-01-01T00:02:00Z') }),
).to.eql('2 minutes ago');
});
});
});

View File

@@ -1,6 +1,9 @@
import type { JSX } from 'solid-js';
import type { CoercibleDate } from '../shared/date/date.types';
import type { Locale } from './i18n.provider';
import { createBranchlet } from '@branchlet/core';
import { coerceDate } from '../shared/date/coerce-date';
import { IN_MS } from '../shared/utils/units';
// This tries to get the most preferred language compatible with the supported languages
// It tries to find a supported language by comparing both region and language, if not, then just language
@@ -71,3 +74,46 @@ export function createFragmentTranslator<Dict extends Record<string, string>>({
return translation;
};
}
export function createDateFormatter({ getLocale }: { getLocale: () => string }) {
return (date: CoercibleDate, options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }) => {
return new Intl.DateTimeFormat(getLocale(), options).format(coerceDate(date));
};
}
export function createRelativeTimeFormatter({ getLocale }: { getLocale: () => string }) {
return (rawDate: CoercibleDate, { now = new Date(), numeric = 'auto', style = 'long' }: { now?: Date; numeric?: 'auto' | 'always'; style?: 'long' | 'short' } = {}) => {
const formatter = new Intl.RelativeTimeFormat(getLocale(), { numeric, style });
const date = coerceDate(rawDate);
const msDiff = now.getTime() - date.getTime();
const absDiff = Math.abs(msDiff);
const sign = msDiff >= 0 ? -1 : 1;
if (absDiff < IN_MS.MINUTE) {
return formatter.format(sign * Math.round(absDiff / 1_000), 'second');
}
if (absDiff < IN_MS.HOUR) {
return formatter.format(sign * Math.round(absDiff / IN_MS.MINUTE), 'minute');
}
if (absDiff < IN_MS.DAY) {
return formatter.format(sign * Math.round(absDiff / IN_MS.HOUR), 'hour');
}
if (absDiff < IN_MS.WEEK) {
return formatter.format(sign * Math.round(absDiff / IN_MS.DAY), 'day');
}
if (absDiff < IN_MS.MONTH) {
return formatter.format(sign * Math.round(absDiff / IN_MS.WEEK), 'week');
}
if (absDiff < IN_MS.YEAR) {
return formatter.format(sign * Math.round(absDiff / IN_MS.MONTH), 'month');
}
return formatter.format(sign * Math.round(absDiff / IN_MS.YEAR), 'year');
};
}

View File

@@ -4,7 +4,7 @@ import { makePersisted } from '@solid-primitives/storage';
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
import { translations as defaultTranslations } from '../../locales/en.dictionary';
import { locales } from './i18n.constants';
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
import { createDateFormatter, createFragmentTranslator, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
export type Locale = typeof locales[number]['key'];
@@ -14,6 +14,8 @@ const I18nContext = createContext<{
getLocale: Accessor<Locale>;
setLocale: Setter<Locale>;
locales: typeof locales;
formatDate: ReturnType<typeof createDateFormatter>;
formatRelativeTime: ReturnType<typeof createRelativeTimeFormatter>;
}>();
export function useI18n() {
@@ -58,6 +60,8 @@ export const I18nProvider: ParentComponent = (props) => {
getLocale,
setLocale,
locales,
formatDate: createDateFormatter({ getLocale }),
formatRelativeTime: createRelativeTimeFormatter({ getLocale }),
}}
>
{props.children}

View File

@@ -2,8 +2,8 @@ import type { Component } from 'solid-js';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Show } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
@@ -56,7 +56,7 @@ export const InvitationsPage: Component = () => {
{
header: t('invitations.list.headers.created'),
accessorKey: 'createdAt',
cell: data => <time dateTime={data.getValue()}>{timeAgo({ date: data.getValue() })}</time>,
cell: data => <RelativeTime date={data.getValue()} />,
},
{
header: () => <div class="text-right">{t('invitations.list.headers.actions')}</div>,

View File

@@ -11,7 +11,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { fetchDeletedOrganizations, restoreOrganization } from '../organizations.services';
export const DeletedOrganizationsPage: Component = () => {
const { t } = useI18n();
const { t, formatDate } = useI18n();
const queryClient = useQueryClient();
const { confirm } = useConfirmModal();
const { config } = useConfig();
@@ -49,14 +49,6 @@ export const DeletedOrganizationsPage: Component = () => {
restoreMutation.mutate({ organizationId });
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
};
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<Button variant="ghost" as={A} href="/organizations" class="text-muted-foreground gap-2 ml--4">

View File

@@ -4,10 +4,10 @@ import { A, useNavigate, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, onMount, Show, Switch } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cancelInvitation, resendInvitation } from '@/modules/invitations/invitations.services';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Badge } from '@/modules/ui/components/badge';
import { Button } from '@/modules/ui/components/button';
@@ -148,7 +148,7 @@ const InvitationsList: Component = () => {
{
header: t('organizations.members.table.headers.created'),
accessorKey: 'createdAt',
cell: data => <span title={data.getValue<Date>().toLocaleString()} class="text-muted-foreground">{timeAgo({ date: data.getValue<Date>() })}</span>,
cell: data => <RelativeTime date={data.getValue<Date>()} class="text-muted-foreground" />,
},
{
header: '',

View File

@@ -1,16 +1,17 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Match, Show, Suspense, Switch } from 'solid-js';
import * as v from 'valibot';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { fetchOrganizationSubscription, getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
@@ -23,9 +24,39 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { deleteOrganization } = useDeleteOrganization();
const { confirm } = useConfirmModal();
const { t } = useI18n();
const { getErrorMessage } = useI18nApiErrors();
const navigate = useNavigate();
const { config } = useConfig();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
// Fetch subscription to check if organization has an active subscription
const subscriptionQuery = useQuery(() => ({
queryKey: ['organizations', props.organization.id, 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organization.id }),
enabled: config.isSubscriptionsEnabled,
}));
// Check if subscription blocks deletion (active and not scheduled to cancel)
const getHasBlockingSubscription = () => {
if (!config.isSubscriptionsEnabled) {
return false;
}
const subscription = subscriptionQuery.data?.subscription;
if (!subscription) {
return false;
}
// Allow deletion if subscription is canceled or scheduled to cancel
if (subscription.status === 'canceled' || subscription.cancelAtPeriodEnd) {
return false;
}
// Block deletion for all other active subscription statuses
return true;
};
const handleDelete = async () => {
const confirmed = await confirm({
title: t('organization.settings.delete.confirm.title'),
@@ -40,11 +71,19 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
shouldType: props.organization.name,
});
if (confirmed) {
await deleteOrganization({ organizationId: props.organization.id });
createToast({ type: 'success', message: t('organization.settings.delete.success') });
if (!confirmed) {
return;
}
const [, error] = await safely(deleteOrganization({ organizationId: props.organization.id }));
if (error) {
createToast({ type: 'error', message: getErrorMessage({ error }) });
return;
}
createToast({ type: 'success', message: t('organization.settings.delete.success') });
navigate('/organizations');
};
return (
@@ -57,16 +96,25 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription>
</CardHeader>
<CardFooter class="pt-6 gap-4">
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
<CardFooter class="pt-6 gap-4 flex-col items-start sm:flex-row sm:items-center">
<Button class="flex-shrink-0" onClick={handleDelete} variant="destructive" disabled={!getIsOwner() || getHasBlockingSubscription()}>
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
<Show when={query.isSuccess && !getIsOwner()}>
<span class="text-sm text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Show>
<Switch>
<Match when={query.isSuccess && !getIsOwner()}>
<span class="text-xs text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Match>
<Match when={getHasBlockingSubscription()}>
<span class="text-xs text-muted-foreground">
{t('organization.settings.delete.has-active-subscription')}
</span>
</Match>
</Switch>
</CardFooter>
</Card>
</div>

View File

@@ -0,0 +1,13 @@
import type { CoercibleDate } from './date.types';
export function coerceDate(date: CoercibleDate): Date {
if (date instanceof Date) {
return date;
}
if (typeof date === 'string' || typeof date === 'number') {
return new Date(date);
}
throw new Error(`Invalid date: expected Date, string, or number, but received value "${date}" of type "${typeof date}"`);
}

View File

@@ -0,0 +1 @@
export type CoercibleDate = Date | string | number;

View File

@@ -1,26 +0,0 @@
import { describe, expect, test } from 'vitest';
import { timeAgo } from './time-ago';
describe('time-ago', () => {
describe('timeAgo', () => {
test('formats the relative time', () => {
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:06Z') })).toBe('a few seconds ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:01:00Z') })).toBe('a minute ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:02:00Z') })).toBe('2 minutes ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T01:00:00Z') })).toBe('an hour ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T02:00:00Z') })).toBe('2 hours ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-02T00:00:00Z') })).toBe('a day ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-03T00:00:00Z') })).toBe('2 days ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-02-01T00:00:00Z') })).toBe('a month ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2022-01-01T00:00:00Z') })).toBe('a year ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2023-01-01T00:00:00Z') })).toBe('2 years ago');
});
test('the date can be a parsable string', () => {
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
});
});
});

View File

@@ -1,63 +0,0 @@
export { timeAgo };
function timeAgo({ date: maybeRawDate, now = new Date() }: { date: Date | string; now?: Date }): string {
const date = typeof maybeRawDate === 'string' ? new Date(maybeRawDate) : maybeRawDate;
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffMonths / 12);
if (diffSeconds < 5) {
return 'just now';
}
if (diffSeconds < 10) {
return 'a few seconds ago';
}
if (diffSeconds < 60) {
return `${diffSeconds} seconds ago`;
}
if (diffMinutes === 1) {
return 'a minute ago';
}
if (diffMinutes < 60) {
return `${diffMinutes} minutes ago`;
}
if (diffHours === 1) {
return 'an hour ago';
}
if (diffHours < 24) {
return `${diffHours} hours ago`;
}
if (diffDays === 1) {
return 'a day ago';
}
if (diffDays < 30) {
return `${diffDays} days ago`;
}
if (diffMonths === 1) {
return 'a month ago';
}
if (diffMonths < 12) {
return `${diffMonths} months ago`;
}
if (diffYears === 1) {
return 'a year ago';
}
return `${diffYears} years ago`;
}

View File

@@ -25,6 +25,7 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('lastTriggeredAt' in obj ? { lastTriggeredAt: toDate(obj.lastTriggeredAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: toDate(obj.scheduledPurgeAt) } : {}),
};

View File

@@ -1,7 +1,7 @@
import { isRecord } from './object';
import { isObject } from './object';
export function getErrorStatus(error: unknown): number | undefined {
if (isRecord(error) && 'status' in error && typeof error.status === 'number') {
if (isObject(error) && 'status' in error && typeof error.status === 'number') {
return error.status;
}

View File

@@ -0,0 +1,9 @@
export const IN_MS = {
SECOND: 1_000,
MINUTE: 60_000, // 60 * 1_000
HOUR: 3_600_000, // 60 * 60 * 1_000
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
};

View File

@@ -162,6 +162,17 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
const defaultBillingInterval: BillingInterval = 'annual';
const [getBillingInterval, setBillingInterval] = createSignal<BillingInterval>(defaultBillingInterval);
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 getReductionPercent = () => 100 * (1 - getReductionMultiplier());
const getDaysUntilReductionExpiry = ({ now = new Date() }: { now?: Date } = {}) => {
if (!getIsReductionActive({ now })) {
return 0;
}
const timeDiff = globalReduction.untilDate.getTime() - now.getTime();
return Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
};
const onUpgrade = async (planId: string) => {
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId, billingInterval: getBillingInterval() });
window.location.href = checkoutUrl;
@@ -214,39 +225,62 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
<DialogTrigger as={props.children} />
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="i-tabler-sparkles size-7 text-primary"></div>
<div class="flex flex-col sm:flex-row items-center gap-3">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="i-tabler-sparkles size-7 text-primary"></div>
</div>
<div>
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{t('subscriptions.upgrade-dialog.description')}
</DialogDescription>
</div>
</div>
<div>
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{t('subscriptions.upgrade-dialog.description')}
</DialogDescription>
<div class="flex flex-col items-center flex-1">
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
onClick={() => setBillingInterval('monthly')}
>
{t('subscriptions.billing-interval.monthly')}
</Button>
<Button
size="sm"
variant="ghost"
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')}
</Button>
</div>
</div>
</div>
</DialogHeader>
<div class="mt-2 flex flex-col items-center">
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
onClick={() => setBillingInterval('monthly')}
>
{t('subscriptions.billing-interval.monthly')}
</Button>
<Button
size="sm"
variant="ghost"
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')}
</Button>
{getIsReductionActive() && (
<div class="mt-4 bg-gradient-to-r from-primary/20 to-primary/2 border border-primary/30 rounded-lg p-4 flex-shrink-0">
<div class="flex items-center gap-4">
<div class="p-2.5 bg-primary/20 rounded-lg border border-primary/30 flex-shrink-0">
<div class="i-tabler-gift size-6 text-primary"></div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-foreground">{t('subscriptions.upgrade-dialog.promo-banner.title')}</h4>
<div class="px-2 py-0.5 bg-primary text-primary-foreground text-xs font-bold rounded-md">
{`-${getReductionPercent()}%`}
</div>
</div>
<p class="text-sm text-muted-foreground mb-1">
{t('subscriptions.upgrade-dialog.promo-banner.description', { percent: getReductionPercent(), days: getDaysUntilReductionExpiry() })}
</p>
</div>
</div>
</div>
</div>
)}
<div class="mt-2 grid grid-cols-1 md:grid-cols-3 gap-4">
<PlanCard {...currentPlan} billingInterval={getBillingInterval()} />

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { insert, remove, setValue } from '@modular-forms/solid';
import { getValue, insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/modules/ui/components/separator';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
import { CONDITION_MATCH_MODES, CONDITION_MATCH_MODES_LOCALIZATION_KEYS, TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
export const TaggingRuleForm: Component<{
onSubmit: (args: { taggingRule: TaggingRuleForCreation }) => Promise<void> | void;
@@ -26,7 +26,7 @@ export const TaggingRuleForm: Component<{
const { confirm } = useConfirmModal();
const { form, Form, Field, FieldArray } = createForm({
onSubmit: async ({ name, conditions = [], tagIds, description }) => {
onSubmit: async ({ name, conditions = [], tagIds, description, conditionMatchMode }) => {
if (conditions.length === 0) {
const confirmed = await confirm({
title: t('tagging-rules.form.conditions.no-conditions.title'),
@@ -45,7 +45,7 @@ export const TaggingRuleForm: Component<{
}
}
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
props.onSubmit({ taggingRule: { name, conditions, tagIds, description, conditionMatchMode } });
},
schema: v.object({
name: v.pipe(
@@ -57,6 +57,7 @@ export const TaggingRuleForm: Component<{
v.string(),
v.maxLength(256, t('tagging-rules.form.description.max-length')),
),
conditionMatchMode: v.optional(v.picklist(Object.values(CONDITION_MATCH_MODES))),
conditions: v.optional(
v.array(v.object({
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
@@ -77,6 +78,7 @@ export const TaggingRuleForm: Component<{
tagIds: props.taggingRule?.actions.map(action => action.tagId) ?? [],
name: props.taggingRule?.name,
description: props.taggingRule?.description,
conditionMatchMode: props.taggingRule?.conditionMatchMode ?? CONDITION_MATCH_MODES.ALL,
},
});
@@ -88,6 +90,20 @@ export const TaggingRuleForm: Component<{
return t(TAGGING_RULE_FIELDS_LOCALIZATION_KEYS[field as keyof typeof TAGGING_RULE_FIELDS_LOCALIZATION_KEYS]);
};
const getConditionConnector = (index: number) => {
if (index === 0) {
return t('tagging-rules.form.conditions.connector.when');
}
const conditionMatchMode = getValue(form, 'conditionMatchMode');
if (conditionMatchMode === CONDITION_MATCH_MODES.ALL) {
return t('tagging-rules.form.conditions.connector.and');
}
return t('tagging-rules.form.conditions.connector.or');
};
return (
<Form>
<Field name="name">
@@ -126,13 +142,34 @@ export const TaggingRuleForm: Component<{
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
<Field name="conditionMatchMode">
{field => (
<Select
id="conditionMatchMode"
defaultValue={field.value ?? CONDITION_MATCH_MODES.ALL}
onChange={value => value && setValue(form, 'conditionMatchMode', value)}
options={Object.values(CONDITION_MATCH_MODES)}
itemComponent={props => (
<SelectItem item={props.item}>
{t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[props.item.rawValue as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}
</SelectItem>
)}
>
<SelectTrigger class="w-full mb-4">
<SelectValue<string>>{state => t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[state.selectedOption() as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
)}
</Field>
<FieldArray name="conditions">
{fieldArray => (
<div>
<For each={fieldArray.items}>
{(_, index) => (
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
<div>When</div>
<div>{getConditionConnector(index())}</div>
<Field name={`conditions.${index()}.field`}>
{field => (

View File

@@ -5,14 +5,17 @@ import { useMutation, useQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
import { createToast } from '@/modules/ui/components/sonner';
import { applyTaggingRuleToExistingDocuments, deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const getConditionsLabel = () => {
const count = props.taggingRule.conditions.length;
@@ -37,6 +40,43 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
},
}));
const applyRuleMutation = useMutation(() => ({
mutationFn: async () => {
return applyTaggingRuleToExistingDocuments({
organizationId: props.taggingRule.organizationId,
taggingRuleId: props.taggingRule.id,
});
},
onSuccess: () => {
createToast({
message: t('tagging-rules.apply.success'),
type: 'success',
});
// Note: Documents will be processed in the background
// We'll invalidate this once task status retrieval is implemented
},
onError: () => {
createToast({
message: t('tagging-rules.apply.error'),
type: 'error',
});
},
}));
const handleApplyRule = async () => {
const isConfirmed = await confirm({
title: t('tagging-rules.apply.confirm.title'),
message: t('tagging-rules.apply.confirm.description'),
confirmButton: {
text: t('tagging-rules.apply.confirm.button'),
},
});
if (isConfirmed) {
applyRuleMutation.mutate();
}
};
return (
<div class="flex items-center gap-2 bg-card py-4 px-6 rounded-md border">
<A href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}>
@@ -52,12 +92,24 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleApplyRule}
disabled={applyRuleMutation.isPending}
aria-label={t('tagging-rules.apply.button')}
>
<div class="i-tabler-player-play size-4 mr-1" />
{applyRuleMutation.isPending ? t('tagging-rules.apply.processing') : t('tagging-rules.apply.button')}
</Button>
<Button
as={A}
href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}
variant="outline"
size="icon"
aria-label={t('tagging-rules.list.card.edit')}
class="size-8"
>
<div class="i-tabler-edit size-4" />
</Button>
@@ -68,6 +120,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
onClick={() => deleteTaggingRuleMutation.mutate()}
disabled={deleteTaggingRuleMutation.isPending}
aria-label={t('tagging-rules.list.card.delete')}
class="size-8"
>
<div class="i-tabler-trash size-4" />
</Button>

View File

@@ -27,3 +27,13 @@ export const TAGGING_RULE_FIELDS_LOCALIZATION_KEYS: Record<(typeof TAGGING_RULE_
[TAGGING_RULE_FIELDS.DOCUMENT_NAME]: 'tagging-rules.field.name',
[TAGGING_RULE_FIELDS.DOCUMENT_CONTENT]: 'tagging-rules.field.content',
} as const;
export const CONDITION_MATCH_MODES = {
ALL: 'all',
ANY: 'any',
} as const;
export const CONDITION_MATCH_MODES_LOCALIZATION_KEYS: Record<(typeof CONDITION_MATCH_MODES)[keyof typeof CONDITION_MATCH_MODES], TranslationKeys> = {
[CONDITION_MATCH_MODES.ALL]: 'tagging-rules.condition-match-mode.all',
[CONDITION_MATCH_MODES.ANY]: 'tagging-rules.condition-match-mode.any',
} as const;

View File

@@ -43,3 +43,18 @@ export async function updateTaggingRule({ organizationId, taggingRuleId, tagging
body: taggingRule,
});
}
export async function applyTaggingRuleToExistingDocuments({
organizationId,
taggingRuleId,
}: {
organizationId: string;
taggingRuleId: string;
}) {
const result = await apiClient<{ taskId: string }>({
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}/apply`,
method: 'POST',
});
return result;
}

View File

@@ -1,8 +1,11 @@
import type { TAGGING_RULE_FIELDS, TAGGING_RULE_OPERATORS } from './tagging-rules.constants';
import type { CONDITION_MATCH_MODES, TAGGING_RULE_FIELDS, TAGGING_RULE_OPERATORS } from './tagging-rules.constants';
export type ConditionMatchMode = (typeof CONDITION_MATCH_MODES)[keyof typeof CONDITION_MATCH_MODES];
export type TaggingRuleForCreation = {
name: string;
description: string;
conditionMatchMode?: ConditionMatchMode;
conditions: TaggingRuleCondition[];
tagIds: string[];
};
@@ -17,6 +20,7 @@ export type TaggingRule = {
id: string;
name: string;
description: string;
conditionMatchMode?: ConditionMatchMode;
conditions: TaggingRuleCondition[];
actions: { tagId: string }[];
organizationId: string;

View File

@@ -7,9 +7,9 @@ import { A, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
@@ -355,7 +355,7 @@ export const TagsPage: Component = () => {
</A>
</TableCell>
<TableCell class="text-muted-foreground" title={tag.createdAt.toLocaleString()}>
{timeAgo({ date: tag.createdAt })}
<RelativeTime date={tag.createdAt} />
</TableCell>
<TableCell>
<div class="flex gap-2 justify-end">

View File

@@ -34,7 +34,7 @@ export function DialogContent<T extends ValidComponent = 'div'>(props: Polymorph
/>
<DialogPrimitive.Content
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
local.class,
)}
{...rest}

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

@@ -10,7 +10,7 @@ export function Toaster(props: Parameters<typeof Sonner>[0]) {
class="toaster group"
toastOptions={{
classes: {
toast: 'group toast group-[.toaster]:(bg-background text-foreground border border-border shadow-lg)',
toast: 'group toast group-[.toaster]:(bg-background text-foreground border border-border shadow-lg) px-4 py-3 gap-4',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:(bg-primary text-primary-foreground)',
cancelButton: 'group-[.toast]:(bg-muted text-muted-foreground)',

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

@@ -9,6 +9,7 @@ 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 { queryClient } from '@/modules/shared/query/query-client';
import { getErrorStatus } from '@/modules/shared/utils/errors';
import { UpgradeDialog } from '@/modules/subscriptions/components/upgrade-dialog.component';
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
@@ -145,8 +146,9 @@ 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
class="w-full"
options={[...organizationsQuery.data?.organizations ?? [], { id: 'create' }]}
optionValue="id"
optionTextValue="name"
@@ -174,11 +176,23 @@ const OrganizationLayoutSideNav: Component = () => {
<SelectItem class="cursor-pointer" item={props.item}>{props.item.rawValue.name}</SelectItem>
)}
>
<SelectTrigger>
<SelectValue<Organization | undefined> 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>
@@ -205,6 +219,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
const status = getErrorStatus(error);
if (status && [401, 403].includes(status)) {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
navigate('/');
}
}

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 && (
@@ -183,7 +134,7 @@ export const SidenavLayout: ParentComponent<{
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,6 +1,7 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import { useMutation } from '@tanstack/solid-query';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -17,17 +18,9 @@ export const CreateWebhookPage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
await createWebhook({
name,
url,
secret,
enabled,
events,
organizationId: params.organizationId,
});
const createWebhookMutation = useMutation(() => ({
mutationFn: createWebhook,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
createToast({
@@ -37,6 +30,19 @@ export const CreateWebhookPage: Component = () => {
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
},
}));
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
await createWebhookMutation.mutateAsync({
name,
url,
secret: secret === '' ? undefined : secret,
enabled,
events,
organizationId: params.organizationId,
});
},
schema: v.object({
name: v.pipe(
v.string(),

View File

@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { A, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -13,7 +12,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { deleteWebhook, fetchWebhooks } from '../webhooks.services';
export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
const { t } = useI18n();
const { t, formatRelativeTime } = useI18n();
const { confirm } = useConfirmModal();
const params = useParams();
@@ -61,12 +60,12 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.last-triggered')}
{' '}
{webhook.lastTriggeredAt ? format(webhook.lastTriggeredAt, 'MMM d, yyyy') : t('webhooks.list.card.never')}
{webhook.lastTriggeredAt ? formatRelativeTime(webhook.lastTriggeredAt) : t('webhooks.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.created')}
{' '}
{format(webhook.createdAt, 'MMM d, yyyy')}
{formatRelativeTime(webhook.createdAt)}
</p>
</div>

View File

@@ -4,8 +4,10 @@ import { useQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
import { authPagesPaths } from './modules/auth/auth.constants';
import { createProtectedPage } from './modules/auth/middleware/protected-page.middleware';
import { EmailValidationRequiredPage } from './modules/auth/pages/email-validation-required.page';
import { EmailVerificationPage } from './modules/auth/pages/email-verification.page';
import { LoginPage } from './modules/auth/pages/login.page';
import { RegisterPage } from './modules/auth/pages/register.page';
import { RequestPasswordResetPage } from './modules/auth/pages/request-password-reset.page';
@@ -239,6 +241,10 @@ export const routes: RouteDefinition[] = [
path: '/email-validation-required',
component: createProtectedPage({ authType: 'public-only', component: EmailValidationRequiredPage }),
},
{
path: authPagesPaths.emailVerification,
component: createProtectedPage({ authType: 'public-only', component: EmailVerificationPage }),
},
{
path: '/checkout-success',
component: CheckoutSuccessPage,

View File

@@ -1,8 +1,8 @@
import {
defineConfig,
presetIcons,
presetUno,
presetWebFonts,
presetWind4,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
@@ -12,11 +12,14 @@ import { documentActivityIcon, iconByFileType } from './src/modules/documents/do
export default defineConfig({
presets: [
presetUno({
presetWind4({
dark: {
dark: '[data-kb-theme="dark"]',
light: '[data-kb-theme="light"]',
},
preflights: {
reset: true,
},
prefix: '',
}),
presetAnimations(),

View File

@@ -7,7 +7,7 @@ FROM node:${NODE_VERSION}-slim AS base
LABEL fly_launch_runtime="Node.js"
# Install pnpm
ARG PNPM_VERSION=10.12.3
ARG PNPM_VERSION=10.19.0
RUN npm install -g pnpm@${PNPM_VERSION}
# Node.js app lives here

View File

@@ -9,17 +9,18 @@ dockerfile = "./Dockerfile"
[deploy]
release_command = "pnpm --silent migrate:up:prod"
strategy = "rolling"
strategy = "canary"
[processes]
web = "pnpm --silent start"
web = "env PROCESS_MODE=web pnpm --silent start"
worker = "env PROCESS_MODE=worker pnpm --silent start"
[http_service]
internal_port = 1221
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
min_machines_running = 1
processes = [ 'web' ]
[checks]

View File

@@ -3,7 +3,6 @@
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -24,6 +23,7 @@
"test:int:pull-images": "tsx --env-file-if-exists=.env src/scripts/pull-test-container-images.script.ts | crowlog-pretty",
"typecheck": "tsc --noEmit",
"migrate:up": "tsx --env-file-if-exists=.env src/scripts/migrate-up.script.ts | crowlog-pretty",
"migrate:down": "tsx --env-file-if-exists=.env src/scripts/migrate-down.script.ts | crowlog-pretty",
"migrate:up:prod": "tsx src/scripts/migrate-up.script.ts",
"migrate:push": "drizzle-kit push",
"migrate:create": "sh -c 'drizzle-kit generate --name \"$1\" && tsx --env-file-if-exists=.env src/scripts/create-migration.ts \"$1\" | crowlog-pretty' --",
@@ -44,10 +44,10 @@
"@cadence-mq/core": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.4",
"@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/chisels": "catalog:",
"@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^1.2.1",
"@crowlog/logger": "^1.2.1",
"@crowlog/async-context-plugin": "^2.0.0",
"@crowlog/logger": "^2.0.0",
"@hono/node-server": "^1.14.4",
"@libsql/client": "^0.14.0",
"@owlrelay/api-sdk": "^0.0.2",
@@ -82,7 +82,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@crowlog/pretty": "^1.2.1",
"@crowlog/pretty": "^2.0.1",
"@testcontainers/azurite": "^11.5.1",
"@testcontainers/localstack": "^11.5.1",
"@total-typescript/ts-reset": "^0.6.1",

View File

@@ -3,6 +3,7 @@ import process, { env } from 'node:process';
import { serve } from '@hono/node-server';
import { setupDatabase } from './modules/app/database/database';
import { ensureLocalDatabaseDirectoryExists } from './modules/app/database/database.services';
import { createGracefulShutdownService } from './modules/app/graceful-shutdown/graceful-shutdown.services';
import { createServer } from './modules/app/server';
import { parseConfig } from './modules/config/config';
import { createDocumentStorageService } from './modules/documents/storage/documents.storage.services';
@@ -15,42 +16,81 @@ const logger = createLogger({ namespace: 'app-server' });
const { config } = await parseConfig({ env });
const isWebMode = config.processMode === 'all' || config.processMode === 'web';
const isWorkerMode = config.processMode === 'all' || config.processMode === 'worker';
logger.info({ processMode: config.processMode, isWebMode, isWorkerMode }, 'Starting application');
// Shutdown callback collector
const shutdownService = createGracefulShutdownService({ logger });
const { registerShutdownHandler } = shutdownService;
await ensureLocalDatabaseDirectoryExists({ config });
const { db, client } = setupDatabase(config.database);
const { db } = setupDatabase({ ...config.database, registerShutdownHandler });
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = createTaskServices({ config });
await taskServices.initialize();
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
if (isWebMode) {
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
const server = serve(
{
fetch: app.fetch,
port: config.server.port,
},
({ port }) => logger.info({ port }, 'Server started'),
);
const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
({ port }) => logger.info({ port }, 'Server started'),
);
if (config.ingestionFolder.isEnabled) {
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
taskServices,
config,
db,
documentsStorageService,
registerShutdownHandler({
id: 'web-server-close',
handler: () => {
server.close();
},
});
await startWatchingIngestionFolders();
}
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
if (isWorkerMode) {
if (config.ingestionFolder.isEnabled) {
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
taskServices,
config,
db,
documentsStorageService,
});
taskServices.start();
await startWatchingIngestionFolders();
}
process.on('SIGINT', async () => {
server.close();
client.close();
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
process.exit(0);
taskServices.start();
logger.info('Worker started');
}
// Global error handlers
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
logger.info({ signal }, 'Received shutdown signal, shutting down gracefully...');
await shutdownService.executeShutdownHandlers();
logger.info('Shutdown complete, exiting process');
process.exit(0);
}
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));

View File

@@ -0,0 +1,20 @@
import type { Migration } from '../migrations.types';
import { sql } from 'drizzle-orm';
export const taggingRuleConditionMatchModeMigration = {
name: 'tagging-rule-condition-match-mode',
up: async ({ db }) => {
const tableInfo = await db.run(sql`PRAGMA table_info(tagging_rules)`);
const existingColumns = tableInfo.rows.map(row => row.name);
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
if (!hasColumn('condition_match_mode')) {
await db.run(sql`ALTER TABLE "tagging_rules" ADD "condition_match_mode" text DEFAULT 'all' NOT NULL;`);
}
},
down: async ({ db }) => {
await db.run(sql`ALTER TABLE "tagging_rules" DROP COLUMN "condition_match_mode";`);
},
} satisfies Migration;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1760016118956,
"tag": "0010_soft-delete-organizations",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1761645190314,
"tag": "0011_tagging-rule-condition-match-mode",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,5 @@
import type { Migration } from './migrations.types';
import { createNoopLogger } from '@crowlog/logger';
import { sql } from 'drizzle-orm';
import { describe, expect, test } from 'vitest';
import { setupDatabase } from '../modules/app/database/database';
@@ -26,7 +27,7 @@ describe('migrations registry', () => {
const { db } = setupDatabase({ url: ':memory:' });
// This will throw if any migration is not able to be applied
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
// check foreign keys are enabled
const { rows } = await db.run(sql`pragma foreign_keys;`);
@@ -39,7 +40,7 @@ describe('migrations registry', () => {
for (const migrationCombination of migrationCombinations) {
const { db } = setupDatabase({ url: ':memory:' });
await runMigrations({ db, migrations: migrationCombination });
await runMigrations({ db, migrations: migrationCombination, logger: createNoopLogger() });
}
});
@@ -51,9 +52,9 @@ describe('migrations registry', () => {
const { db } = setupDatabase({ url: ':memory:' });
const previousMigration = migrationCombinations[index - 1] ?? [] as Migration[];
await runMigrations({ db, migrations: previousMigration });
await runMigrations({ db, migrations: previousMigration, logger: createNoopLogger() });
const previousDbState = await serializeSchema({ db });
await runMigrations({ db, migrations: migrationCombination });
await runMigrations({ db, migrations: migrationCombination, logger: createNoopLogger() });
await rollbackLastAppliedMigration({ db });
const currentDbState = await serializeSchema({ db });
@@ -65,7 +66,7 @@ describe('migrations registry', () => {
test('regression test of the database state after running migrations, update the snapshot when the database state changes', async () => {
const { db } = setupDatabase({ url: ':memory:' });
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
expect(await serializeSchema({ db })).toMatchInlineSnapshot(`
"CREATE UNIQUE INDEX "api_keys_key_hash_unique" ON "api_keys" ("key_hash");
@@ -114,7 +115,7 @@ describe('migrations registry', () => {
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE "tagging_rule_actions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "tagging_rule_id" text NOT NULL, "tag_id" text NOT NULL, FOREIGN KEY ("tagging_rule_id") REFERENCES "tagging_rules"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "tagging_rule_conditions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "tagging_rule_id" text NOT NULL, "field" text NOT NULL, "operator" text NOT NULL, "value" text NOT NULL, "is_case_sensitive" integer DEFAULT false NOT NULL, FOREIGN KEY ("tagging_rule_id") REFERENCES "tagging_rules"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "tagging_rules" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "description" text, "enabled" integer DEFAULT true NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "tagging_rules" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "description" text, "enabled" integer DEFAULT true NOT NULL, "condition_match_mode" text DEFAULT 'all' NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "tags" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "color" text NOT NULL, "description" text, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "user_roles" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text NOT NULL, "role" text NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer );
@@ -131,12 +132,12 @@ describe('migrations registry', () => {
test('if for some reasons we drop the migrations table, we can reapply all migrations', async () => {
const { db } = setupDatabase({ url: ':memory:' });
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
const dbState = await serializeSchema({ db });
await db.run(sql`DROP TABLE migrations`);
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
expect(await serializeSchema({ db })).to.eq(dbState);
});

View File

@@ -13,6 +13,7 @@ import { dropLegacyMigrationsMigration } from './list/0009-drop-legacy-migration
import { documentFileEncryptionMigration } from './list/0010-document-file-encryption.migration';
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
export const migrations: Migration[] = [
initialSchemaSetupMigration,
@@ -26,4 +27,5 @@ export const migrations: Migration[] = [
dropLegacyMigrationsMigration,
documentFileEncryptionMigration,
softDeleteOrganizationsMigration,
taggingRuleConditionMatchModeMigration,
];

View File

@@ -1,4 +1,5 @@
import type { Migration } from './migrations.types';
import { createNoopLogger } from '@crowlog/logger';
import { sql } from 'drizzle-orm';
import { describe, expect, test } from 'vitest';
import { setupDatabase } from '../modules/app/database/database';
@@ -50,7 +51,7 @@ describe('migrations usecases', () => {
const migrations = [createTableUserMigration, createTableOrganizationMigration];
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
const migrationsInDb = await db.select().from(migrationsTable);
@@ -61,7 +62,7 @@ describe('migrations usecases', () => {
migrations.push(createTableDocumentMigration);
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
const migrationsInDb2 = await db.select().from(migrationsTable);
@@ -92,7 +93,7 @@ describe('migrations usecases', () => {
const migrations = [createTableUserMigration, createTableDocumentMigration];
await runMigrations({ db, migrations });
await runMigrations({ db, migrations, logger: createNoopLogger() });
const initialMigrations = await db.select().from(migrationsTable);
@@ -131,7 +132,7 @@ describe('migrations usecases', () => {
test('when the last migration in the database does not exist in the migrations list, an error is thrown', async () => {
const { db } = setupDatabase({ url: ':memory:' });
await runMigrations({ db, migrations: [createTableUserMigration] });
await runMigrations({ db, migrations: [createTableUserMigration], logger: createNoopLogger() });
await expect(
rollbackLastAppliedMigration({ db, migrations: [] }),

View File

@@ -54,9 +54,14 @@ export function getAuth({
enabled: true,
},
},
emailVerification: {
sendVerificationEmail: authEmailsServices.sendVerificationEmail,
},
emailVerification: config.auth.isEmailVerificationRequired
? {
sendVerificationEmail: authEmailsServices.sendVerificationEmail,
autoSignInAfterVerification: false,
sendOnSignIn: true,
sendOnSignUp: true,
}
: undefined,
database: drizzleAdapter(
db,
@@ -74,9 +79,9 @@ export function getAuth({
databaseHooks: {
user: {
create: {
after: async (user) => {
logger.info({ userId: user.id }, 'User signed up');
trackingServices.captureUserEvent({ userId: user.id, event: 'User signed up' });
after: async ({ id: userId, email }) => {
logger.info({ userId }, 'User signed up');
trackingServices.captureUserEvent({ userId, event: 'User signed up', properties: { $set: { email } } });
},
},
},

View File

@@ -0,0 +1,208 @@
import { describe, expect, test, vi } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createServer } from '../../server';
import { createAuthEmailsServices } from '../auth.emails.services';
import { getAuth } from '../auth.services';
function createTestEmailServices() {
const args: Array<{ to: string; subject: string; html: string }> = [];
return {
name: 'test',
sendEmail: async (emailArgs: { to: string; subject: string; html: string }) => {
args.push(emailArgs);
},
getSentEmails: () => args,
clear: () => {
args.length = 0;
},
};
}
describe('email verification e2e', () => {
describe('signup with email verification', () => {
describe('when email verification is required, the verification email is sent during signup', () => {
test('an email is sent after signup with verification URL', async () => {
const { db } = await createInMemoryDatabase();
const mockEmailsServices = createTestEmailServices();
const config = overrideConfig({
auth: {
isEmailVerificationRequired: true,
},
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { app } = await createServer({ db, config, auth });
const response = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
name: 'Test User',
}),
});
expect(response.status).to.eql(200);
expect(
mockEmailsServices.getSentEmails().map(({ to, subject }) => ({ to, subject })),
).to.eql([
{
to: 'test@example.com',
subject: 'Verify your email',
},
]);
});
});
describe('when email verification is required, users cannot login without verifying their email', () => {
test('login attempt without verified email returns an error', async () => {
const { db } = await createInMemoryDatabase();
const mockEmailsServices = createTestEmailServices();
const config = overrideConfig({
auth: {
isEmailVerificationRequired: true,
},
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { app } = await createServer({ db, config, auth });
// First, sign up
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
name: 'Test User',
}),
});
// Clear sent emails to check if login triggers another email
mockEmailsServices.clear();
// Try to login without verifying
const loginResponse = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
}),
});
// Better auth returns 403 for unverified email
expect(loginResponse.status).to.eql(403);
expect(
await loginResponse.json(),
).to.eql({
code: 'EMAIL_NOT_VERIFIED',
message: 'Email not verified',
});
// An email should be sent on sign-in attempt with unverified email (per config: sendOnSignIn: true)
expect(mockEmailsServices.getSentEmails()).toHaveLength(1);
expect(mockEmailsServices.getSentEmails()[0]?.subject).to.eql('Verify your email');
});
});
describe('when email verification is disabled, no verification email is sent', () => {
test('signup without email verification requirement does not send email', async () => {
const { db } = await createInMemoryDatabase();
const mockEmailsServices = createTestEmailServices();
const config = overrideConfig({
auth: {
isEmailVerificationRequired: false,
},
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { app } = await createServer({ db, config, auth });
const response = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
name: 'Test User',
}),
});
expect(response.status).to.eql(200);
expect(mockEmailsServices.getSentEmails()).toHaveLength(0);
});
test('users can login immediately after signup when email verification is disabled', async () => {
const { db } = await createInMemoryDatabase();
const mockEmailsServices = createTestEmailServices();
const config = overrideConfig({
auth: {
isEmailVerificationRequired: false,
},
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { app } = await createServer({ db, config, auth });
// Sign up
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
name: 'Test User',
}),
});
// Login immediately without verification
const loginResponse = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'StrongPassword123!',
}),
});
expect(loginResponse.status).to.eql(200);
expect(mockEmailsServices.getSentEmails()).toHaveLength(0);
const loginBody: any = await loginResponse.json();
expect(loginBody).to.have.property('user');
// eslint-disable-next-line ts/no-unsafe-member-access
expect(loginBody?.user?.email).to.eql('test@example.com');
});
});
});
});

View File

@@ -1,5 +1,5 @@
import type { Database } from './database.types';
import { createInMemoryLoggerTransport, createLogger } from '@crowlog/logger';
import { createNoopLogger } from '@crowlog/logger';
import { sql } from 'drizzle-orm';
import { runMigrations } from '../../../migrations/migrations.usecases';
import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables';
@@ -21,7 +21,7 @@ async function createInMemoryDatabase(seedOptions: Omit<Parameters<typeof seedDa
await runMigrations({
db,
// In memory logger to avoid polluting the console with migrations logs
logger: createLogger({ transports: [createInMemoryLoggerTransport()], namespace: 'migrations' }),
logger: createNoopLogger(),
});
await seedDatabase({ db, ...seedOptions });

View File

@@ -1,3 +1,4 @@
import type { ShutdownHandlerRegistration } from '../graceful-shutdown/graceful-shutdown.services';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
@@ -7,15 +8,22 @@ function setupDatabase({
url,
authToken,
encryptionKey,
registerShutdownHandler,
}: {
url: string;
authToken?: string;
encryptionKey?: string;
registerShutdownHandler?: ShutdownHandlerRegistration;
}) {
const client = createClient({ url, authToken, encryptionKey });
const db = drizzle(client);
registerShutdownHandler?.({
id: 'database-client-close',
handler: () => client.close(),
});
return {
db,
client,

View File

@@ -0,0 +1,34 @@
import type { Logger } from '../../shared/logger/logger';
import { createLogger } from '../../shared/logger/logger';
export type ShutdownHandlerConfig = {
id: string;
handler: () => void | Promise<void>;
};
export type ShutdownHandlerRegistration = (handlerConfig: ShutdownHandlerConfig) => void;
export function createGracefulShutdownService({ logger = createLogger({ namespace: 'graceful-shutdown' }) }: { logger?: Logger } = {}) {
const shutdownHandlers: ShutdownHandlerConfig[] = [];
return {
registerShutdownHandler: (handler: ShutdownHandlerConfig) => {
shutdownHandlers.push(handler);
},
async executeShutdownHandlers() {
logger.info('Executing shutdown handlers');
await Promise.allSettled(
shutdownHandlers.map(async ({ handler, id }) => {
try {
await handler();
} catch (error) {
logger.error({ error, id }, 'Error executing shutdown handler');
}
}),
);
logger.info('All shutdown handlers executed');
},
};
}

View File

@@ -28,6 +28,12 @@ export const configDefinition = {
default: 'development',
env: 'NODE_ENV',
},
processMode: {
doc: 'The process mode: "all" runs both web and worker, "web" runs only the API server, "worker" runs only background tasks',
schema: z.enum(['all', 'web', 'worker']),
default: 'all',
env: 'PROCESS_MODE',
},
appBaseUrl: {
doc: 'The base URL of the application. Will override the client baseUrl and server baseUrl when set. Use this one over the client and server baseUrl when the server is serving the client assets (like in docker).',
schema: z.string().url().optional(),
@@ -61,6 +67,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

@@ -34,8 +34,8 @@ export function createDocumentsRepository({ db }: { db: Database }) {
getOrganizationDocumentBySha256Hash,
getAllOrganizationTrashDocuments,
getAllOrganizationDocuments,
getOrganizationDocumentsQuery,
getAllOrganizationDocumentsIterator,
getAllOrganizationUndeletedDocumentsIterator,
updateDocument,
},
{ db },
@@ -391,18 +391,36 @@ async function getAllOrganizationDocuments({ organizationId, db }: { organizatio
};
}
function getOrganizationDocumentsQuery({ organizationId, db }: { organizationId: string; db: Database }) {
return db.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
}).from(documentsTable).where(
eq(documentsTable.organizationId, organizationId),
);
function getAllOrganizationDocumentsIterator({ organizationId, batchSize = 100, db }: { organizationId: string; batchSize?: number; db: Database }) {
const query = db
.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
})
.from(documentsTable)
.where(
eq(documentsTable.organizationId, organizationId),
)
.orderBy(documentsTable.createdAt)
.$dynamic();
return createIterator({ query, batchSize }) as AsyncGenerator<{ id: string; originalStorageKey: string }>;
}
function getAllOrganizationDocumentsIterator({ organizationId, db, batchSize = 100 }: { organizationId: string; db: Database; batchSize?: number }) {
const query = getOrganizationDocumentsQuery({ organizationId, db }).$dynamic();
return createIterator({ query, batchSize }) as AsyncGenerator<{ id: string; originalStorageKey: string }>;
function getAllOrganizationUndeletedDocumentsIterator({ organizationId, batchSize = 100, db }: { organizationId: string; batchSize?: number; db: Database }) {
const query = db
.select()
.from(documentsTable)
.where(
and(
eq(documentsTable.organizationId, organizationId),
eq(documentsTable.isDeleted, false),
),
)
.orderBy(documentsTable.createdAt)
.$dynamic();
return createIterator({ query, batchSize });
}
async function updateDocument({ documentId, organizationId, name, content, db }: { documentId: string; organizationId: string; name?: string; content?: string; db: Database }) {

View File

@@ -65,3 +65,9 @@ export const createOnlyPreviousOwnerCanRestoreError = createErrorFactory({
code: 'organization.only_previous_owner_can_restore',
statusCode: 403,
});
export const createOrganizationHasActiveSubscriptionError = createErrorFactory({
message: 'Cannot delete organization with an active subscription. Please cancel your subscription first.',
code: 'organization.has_active_subscription',
statusCode: 403,
});

View File

@@ -151,10 +151,11 @@ function setupSoftDeleteOrganizationRoute({ app, db, config }: RouteDefinitionCo
const { organizationId } = context.req.valid('param');
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await softDeleteOrganization({ organizationId, deletedBy: userId, organizationsRepository, config });
await softDeleteOrganization({ organizationId, deletedBy: userId, organizationsRepository, subscriptionsRepository, config });
return context.body(null, 204);
},

View File

@@ -11,7 +11,7 @@ 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, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
import { createMaxOrganizationMembersCountReachedError, createOrganizationHasActiveSubscriptionError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
import { createOrganizationsRepository } from './organizations.repository';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
import { checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, purgeExpiredSoftDeletedOrganization, purgeExpiredSoftDeletedOrganizations, removeMemberFromOrganization, softDeleteOrganization } from './organizations.usecases';
@@ -968,12 +968,14 @@ describe('organizations usecases', () => {
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
now: new Date('2025-10-05'),
});
@@ -998,6 +1000,7 @@ describe('organizations usecases', () => {
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await expect(
@@ -1005,6 +1008,7 @@ describe('organizations usecases', () => {
organizationId: 'organization-1',
deletedBy: 'admin-user',
organizationsRepository,
subscriptionsRepository,
config,
}),
).rejects.toThrow(createUserNotOrganizationOwnerError());
@@ -1030,12 +1034,14 @@ describe('organizations usecases', () => {
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
});
@@ -1052,6 +1058,7 @@ describe('organizations usecases', () => {
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await expect(
@@ -1059,6 +1066,7 @@ describe('organizations usecases', () => {
organizationId: 'non-existent-org',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
}),
).rejects.toThrow(createOrganizationNotFoundError());
@@ -1078,12 +1086,14 @@ describe('organizations usecases', () => {
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
now: new Date('2025-10-05'),
});
@@ -1098,6 +1108,128 @@ describe('organizations usecases', () => {
expect(org1?.deletedAt).to.eql(new Date('2025-10-05'));
expect(org2?.deletedAt).to.eql(null);
});
test('cannot delete organization with an active subscription', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Test Org', customerId: 'cus_123' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER },
],
organizationSubscriptions: [
{
id: 'sub_123',
organizationId: 'organization-1',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await expect(
softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
}),
).rejects.toThrow(createOrganizationHasActiveSubscriptionError());
// Organization should not be deleted
const [organization] = await db.select().from(organizationsTable);
expect(organization?.deletedAt).to.eql(null);
});
test('can delete organization with subscription scheduled to cancel at period end', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Test Org', customerId: 'cus_123' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER },
],
organizationSubscriptions: [
{
id: 'sub_123',
organizationId: 'organization-1',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: true, // User already canceled, allow org deletion
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
now: new Date('2025-10-05'),
});
// Organization should be deleted
const [organization] = await db.select().from(organizationsTable);
expect(organization?.deletedAt).to.eql(new Date('2025-10-05'));
expect(organization?.deletedBy).to.eql('usr_1');
});
test('can delete organization after subscription is canceled', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Test Org', customerId: 'cus_123' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER },
],
organizationSubscriptions: [
{
id: 'sub_123',
organizationId: 'organization-1',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'canceled',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig();
await softDeleteOrganization({
organizationId: 'organization-1',
deletedBy: 'usr_1',
organizationsRepository,
subscriptionsRepository,
config,
now: new Date('2025-10-05'),
});
const [organization] = await db.select().from(organizationsTable);
expect(organization?.deletedAt).to.eql(new Date('2025-10-05'));
expect(organization?.deletedBy).to.eql('usr_1');
});
});
});

View File

@@ -17,10 +17,12 @@ import { getOrganizationPlan } from '../plans/plans.usecases';
import { sanitize } from '../shared/html/html';
import { createLogger } from '../shared/logger/logger';
import { isDefined } from '../shared/utils';
import { doesSubscriptionBlockDeletion } from '../subscriptions/subscriptions.models';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import {
createMaxOrganizationMembersCountReachedError,
createOnlyPreviousOwnerCanRestoreError,
createOrganizationHasActiveSubscriptionError,
createOrganizationInvitationAlreadyExistsError,
createOrganizationNotDeletedError,
createOrganizationNotFoundError,
@@ -456,17 +458,26 @@ export async function softDeleteOrganization({
organizationId,
deletedBy,
organizationsRepository,
subscriptionsRepository,
config,
now = new Date(),
}: {
organizationId: string;
deletedBy: string;
organizationsRepository: OrganizationsRepository;
subscriptionsRepository: SubscriptionsRepository;
config: Config;
now?: Date;
}) {
await ensureUserIsOwnerOfOrganization({ userId: deletedBy, organizationId, organizationsRepository });
// Check if organization has a subscription that blocks deletion
const { subscription } = await subscriptionsRepository.getActiveOrganizationSubscription({ organizationId });
if (doesSubscriptionBlockDeletion(subscription)) {
throw createOrganizationHasActiveSubscriptionError();
}
await organizationsRepository.deleteAllMembersFromOrganization({ organizationId });
await organizationsRepository.deleteAllOrganizationInvitations({ organizationId });
await organizationsRepository.softDeleteOrganization({

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

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { coerceStripeTimestampToDate, isSignatureHeaderFormatValid } from './subscriptions.models';
import { coerceStripeTimestampToDate, doesSubscriptionBlockDeletion, isSignatureHeaderFormatValid } from './subscriptions.models';
describe('subscriptions models', () => {
describe('coerceStripeTimestampToDate', () => {
@@ -19,4 +19,181 @@ describe('subscriptions models', () => {
expect(isSignatureHeaderFormatValid('v1_1234567890')).toBe(true);
});
});
describe('doesSubscriptionBlockDeletion', () => {
describe('organization deletion is blocked to prevent orphaned active subscriptions', () => {
test('active subscription with ongoing billing blocks deletion', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(true);
});
test('fully active subscription not scheduled for cancellation blocks deletion', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false, // Fully active, not scheduled to cancel
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(true);
});
test('subscription with payment issues still has access and blocks deletion', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'past_due',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(true);
});
test('subscription in trial period blocks deletion to maintain trial access', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'trialing',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(true);
});
test('subscription with failed payment still maintains access and blocks deletion', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'unpaid',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(true);
});
});
describe('organization deletion is allowed when subscription is terminated or user has canceled', () => {
test('fully canceled subscription allows deletion since billing has ended', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'canceled',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(false);
});
test('organization without subscription can be deleted freely', () => {
expect(doesSubscriptionBlockDeletion(null)).toBe(false);
});
test('organization with no subscription record can be deleted', () => {
expect(doesSubscriptionBlockDeletion(undefined)).toBe(false);
});
test('user who canceled subscription via customer portal can immediately delete organization', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: true, // User canceled via customer portal, will end at period end
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(false);
});
test('incomplete subscription allows deletion since payment was never completed', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'incomplete',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(false);
});
test('incomplete_expired subscription allows deletion since payment failed to complete', () => {
const subscription = {
id: 'sub_123',
organizationId: 'org_123',
customerId: 'cus_123',
planId: 'plan_pro',
status: 'incomplete_expired',
seatsCount: 5,
currentPeriodStart: new Date('2025-10-01'),
currentPeriodEnd: new Date('2025-11-01'),
cancelAtPeriodEnd: false,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
};
expect(doesSubscriptionBlockDeletion(subscription)).toBe(false);
});
});
});
});

View File

@@ -1,3 +1,4 @@
import type { Subscription } from './subscriptions.types';
import { isNil, isNonEmptyString } from '../shared/utils';
export function coerceStripeTimestampToDate(timestamp: number) {
@@ -11,3 +12,48 @@ export function isSignatureHeaderFormatValid(signature: string | undefined): sig
return isNonEmptyString(signature);
}
/**
* Determines if a subscription should prevent organization deletion.
*
* Organization deletion is allowed when:
* - No subscription exists (null/undefined)
* - Subscription status is 'canceled' (fully terminated)
* - Subscription status is 'incomplete' or 'incomplete_expired' (payment never completed)
* - Subscription is scheduled to cancel at period end (cancelAtPeriodEnd is true)
* - User has already expressed intent to cancel
* - Organization will lose access at period end anyway
*
* Organization deletion is blocked for active subscriptions with:
* - 'active' status AND cancelAtPeriodEnd is false
* - 'past_due' status (payment issues, but still has access)
* - 'trialing' status (in trial period)
* - 'unpaid' status (payment failed but subscription remains)
*
* @param subscription - The subscription to check, or null/undefined if no subscription exists
* @returns true if the subscription blocks deletion, false otherwise
*/
export function doesSubscriptionBlockDeletion(subscription: Subscription | null | undefined): boolean {
if (!subscription) {
return false;
}
// Fully canceled subscriptions don't block deletion
if (subscription.status === 'canceled') {
return false;
}
// Incomplete subscriptions don't block deletion (payment never completed)
if (subscription.status === 'incomplete' || subscription.status === 'incomplete_expired') {
return false;
}
// Subscriptions scheduled to cancel at period end don't block deletion
// User has already expressed intent to cancel, so let them delete the org
if (subscription.cancelAtPeriodEnd) {
return false;
}
// All other subscription statuses block deletion
return true;
}

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, inArray } from 'drizzle-orm';
import { omitUndefined } from '../shared/utils';
import { organizationSubscriptionsTable } from './subscriptions.tables';
@@ -10,9 +10,11 @@ export type SubscriptionsRepository = ReturnType<typeof createSubscriptionsRepos
export function createSubscriptionsRepository({ db }: { db: Database }) {
return injectArguments(
{
getOrganizationSubscription,
createSubscription,
getActiveOrganizationSubscription,
getAllOrganizationSubscriptions,
getSubscriptionById,
updateSubscription,
upsertSubscription,
},
{
db,
@@ -20,21 +22,44 @@ export function createSubscriptionsRepository({ db }: { db: Database }) {
);
}
async function getOrganizationSubscription({ organizationId, db }: { organizationId: string; db: Database }) {
async function getActiveOrganizationSubscription({ organizationId, db }: { organizationId: string; db: Database }) {
// Allowlist approach: explicitly include only statuses that grant access
// - active: paid and active subscription
// - trialing: in trial period (has access)
// - past_due: payment failed but still has access during grace period
const [subscription] = await db
.select()
.from(organizationSubscriptionsTable)
.where(
and(
eq(organizationSubscriptionsTable.organizationId, organizationId),
inArray(organizationSubscriptionsTable.status, ['active', 'trialing', 'past_due']),
),
);
return { subscription };
}
async function getAllOrganizationSubscriptions({ organizationId, db }: { organizationId: string; db: Database }) {
const subscriptions = await db
.select()
.from(organizationSubscriptionsTable)
.where(
eq(organizationSubscriptionsTable.organizationId, organizationId),
);
return { subscription };
return { subscriptions };
}
async function createSubscription({ db, ...subscription }: { db: Database } & DbInsertableSubscription) {
const [createdSubscription] = await db.insert(organizationSubscriptionsTable).values(subscription).returning();
async function getSubscriptionById({ subscriptionId, db }: { subscriptionId: string; db: Database }) {
const [subscription] = await db
.select()
.from(organizationSubscriptionsTable)
.where(
eq(organizationSubscriptionsTable.id, subscriptionId),
);
return { createdSubscription };
return { subscription };
}
async function updateSubscription({ subscriptionId, db, ...subscription }: { subscriptionId: string; db: Database } & Omit<Partial<DbInsertableSubscription>, 'id'>) {
@@ -48,3 +73,17 @@ async function updateSubscription({ subscriptionId, db, ...subscription }: { sub
return { updatedSubscription };
}
// cspell:ignore upserted Insertable
async function upsertSubscription({ db, ...subscription }: { db: Database } & DbInsertableSubscription) {
const [upsertedSubscription] = await db
.insert(organizationSubscriptionsTable)
.values(subscription)
.onConflictDoUpdate({
target: organizationSubscriptionsTable.id,
set: omitUndefined(subscription),
})
.returning();
return { subscription: upsertedSubscription };
}

View File

@@ -58,6 +58,7 @@ function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: Rou
event,
plansRepository,
subscriptionsRepository,
subscriptionsServices,
});
return context.body(null, 204);
@@ -112,6 +113,26 @@ function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsService
const { customerId } = await getOrCreateOrganizationCustomerId({ organizationId, subscriptionsServices, organizationsRepository });
// Step 1: Expire any active checkout sessions before creating a new one
// This allows subscriptions to be canceled (can't cancel while checkout is active)
await subscriptionsServices.expireActiveCheckoutSessions({ customerId });
// Step 2: Cancel any incomplete subscriptions from previous failed attempts
// This prevents accumulating orphaned incomplete subscriptions
const { subscriptions: allSubscriptions } = await subscriptionsRepository.getAllOrganizationSubscriptions({ organizationId });
const incompleteSubscriptions = allSubscriptions.filter(sub => sub.status === 'incomplete' || sub.status === 'incomplete_expired');
// Now that checkout sessions are expired, we can cancel the subscriptions
await Promise.allSettled(
incompleteSubscriptions.map(async (sub) => {
try {
await subscriptionsServices.cancelSubscription({ subscriptionId: sub.id });
} catch (error) {
logger.warn({ subscriptionId: sub.id, error }, 'Failed to cancel incomplete subscription');
}
}),
);
const { checkoutUrl } = await subscriptionsServices.createCheckoutUrl({
customerId,
priceId,
@@ -172,21 +193,23 @@ function setupGetOrganizationSubscriptionRoute({ app, db, config }: RouteDefinit
organizationsRepository,
});
const { subscription } = await subscriptionsRepository.getOrganizationSubscription({
const { subscription } = await subscriptionsRepository.getActiveOrganizationSubscription({
organizationId,
});
const { organizationPlan } = await plansRepository.getOrganizationPlanById({ planId: subscription?.planId ?? FREE_PLAN_ID });
return context.json({
subscription: pick(subscription, [
'status',
'currentPeriodEnd',
'currentPeriodStart',
'cancelAtPeriodEnd',
'planId',
'seatsCount',
]),
subscription: subscription
? pick(subscription, [
'status',
'currentPeriodEnd',
'currentPeriodStart',
'cancelAtPeriodEnd',
'planId',
'seatsCount',
])
: null,
plan: organizationPlan,
});
},

View File

@@ -5,7 +5,7 @@ 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';
import { isNil, isNonEmptyString } from '../shared/utils';
export type SubscriptionsServices = ReturnType<typeof createSubscriptionsServices>;
@@ -20,6 +20,9 @@ export function createSubscriptionsServices({ config }: { config: Config }) {
getCustomerPortalUrl,
getCheckoutSession,
getCoupon,
cancelSubscription,
expireActiveCheckoutSessions,
getSubscription,
},
{ stripeClient, config },
);
@@ -156,3 +159,55 @@ async function getCoupon({ stripeClient, couponId, logger = createLogger({ names
},
};
}
async function cancelSubscription({ stripeClient, subscriptionId }: { stripeClient: Stripe; subscriptionId: string }) {
await stripeClient.subscriptions.cancel(subscriptionId);
return { success: true };
}
async function expireActiveCheckoutSessions({ stripeClient, customerId, logger = createLogger({ namespace: 'subscriptions:services:expireActiveCheckoutSessions' }) }: { stripeClient: Stripe; customerId: string; logger?: Logger }) {
// List all open checkout sessions for this customer
// Note: Checkout sessions auto-expire after 24h, so 100 limit is typically sufficient
// For edge cases with 100+ sessions, we paginate through all results
let hasMore = true;
let startingAfter: string | undefined;
const sessionsIds = new Set<string>();
while (hasMore) {
const sessions = await stripeClient.checkout.sessions.list({
customer: customerId,
status: 'open',
limit: 100,
...(isNonEmptyString(startingAfter) ? { starting_after: startingAfter } : {}),
});
sessions.data.forEach(({ id }) => sessionsIds.add(id));
hasMore = sessions.has_more;
const lastSession = sessions.data[sessions.data.length - 1];
if (lastSession) {
startingAfter = lastSession.id;
}
}
// Expire each open session
await Promise.allSettled(
Array.from(sessionsIds).map(async (sessionId) => {
try {
await stripeClient.checkout.sessions.expire(sessionId);
logger.info({ customerId, sessionId }, 'Expired checkout session');
} catch (error) {
logger.warn({ customerId, sessionId, error }, 'Failed to expire checkout session');
}
}),
);
logger.info({ customerId, totalSessions: sessionsIds.size }, 'Expired active checkout sessions');
}
async function getSubscription({ stripeClient, subscriptionId }: { stripeClient: Stripe; subscriptionId: string }) {
const subscription = await stripeClient.subscriptions.retrieve(subscriptionId);
return { subscription };
}

View File

@@ -1,8 +1,11 @@
import type Stripe from 'stripe';
import type { PlansRepository } from '../plans/plans.repository';
import type { Logger } from '../shared/logger/logger';
import type { SubscriptionsRepository } from './subscriptions.repository';
import type { SubscriptionsServices } from './subscriptions.services';
import { get } from 'lodash-es';
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { coerceStripeTimestampToDate } from './subscriptions.models';
@@ -10,28 +13,68 @@ export async function handleStripeWebhookEvent({
event,
plansRepository,
subscriptionsRepository,
subscriptionsServices,
logger = createLogger({ namespace: 'subscriptions' }),
}: {
event: Stripe.Event;
subscriptionsRepository: SubscriptionsRepository;
plansRepository: PlansRepository;
subscriptionsServices: SubscriptionsServices;
logger?: Logger;
}) {
if (event.type === 'customer.subscription.created') {
const subscriptionItem = get(event, 'data.object.items.data[0]');
const customerId = get(event, 'data.object.customer') as string;
if (event.type === 'customer.subscription.created' || event.type === 'customer.subscription.updated') {
const subscriptionId = event.data.object.id;
const organizationId = get(event, 'data.object.metadata.organizationId') as string | undefined;
const currentPeriodEnd = coerceStripeTimestampToDate(get(event, 'data.object.current_period_end'));
const currentPeriodStart = coerceStripeTimestampToDate(get(event, 'data.object.current_period_start'));
const cancelAtPeriodEnd = get(event, 'data.object.cancel_at_period_end');
const status = get(event, 'data.object.status');
if (isNil(organizationId)) {
throw createOrganizationNotFoundError();
}
// Fetch current state from Stripe (source of truth) to handle out-of-order webhooks
// This ensures we always have the latest data regardless of webhook arrival order
let stripeSubscription: Stripe.Subscription;
try {
const { subscription } = await subscriptionsServices.getSubscription({ subscriptionId });
stripeSubscription = subscription;
logger.info({
subscriptionId,
status: subscription.status,
eventType: event.type,
}, 'Fetched current subscription state from Stripe');
} catch (error) {
// Fallback to webhook data if Stripe API fails
logger.warn({
subscriptionId,
error,
eventType: event.type,
}, 'Failed to fetch subscription from Stripe, using webhook data as fallback');
stripeSubscription = event.data.object;
}
// Extract data from current Stripe state
const subscriptionItem = stripeSubscription.items.data[0];
if (!subscriptionItem) {
throw new Error(`Subscription ${subscriptionId} has no items`);
}
const customerId = typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id;
const currentPeriodEnd = coerceStripeTimestampToDate(stripeSubscription.current_period_end);
const currentPeriodStart = coerceStripeTimestampToDate(stripeSubscription.current_period_start);
const cancelAtPeriodEnd = stripeSubscription.cancel_at_period_end;
const status = stripeSubscription.status;
// Look up the plan - this might fail if price ID is misconfigured
const { organizationPlan } = await plansRepository.getOrganizationPlanByPriceId({ priceId: subscriptionItem.price.id });
await subscriptionsRepository.createSubscription({
id: subscriptionItem.id,
// Upsert subscription with current state from Stripe
await subscriptionsRepository.upsertSubscription({
id: subscriptionId,
organizationId,
planId: organizationPlan.id,
seatsCount: organizationPlan.limits.maxOrganizationsMembersCount,
@@ -42,31 +85,29 @@ export async function handleStripeWebhookEvent({
cancelAtPeriodEnd,
});
logger.info({
subscriptionId,
customerId,
status,
planId: organizationPlan.id,
eventType: event.type,
}, 'Subscription synced from Stripe current state');
return;
}
if (event.type === 'customer.subscription.updated') {
const subscriptionItem = get(event, 'data.object.items.data[0]');
const organizationId = get(event, 'data.object.metadata.organizationId') as string | undefined;
const currentPeriodEnd = coerceStripeTimestampToDate(get(event, 'data.object.current_period_end'));
const currentPeriodStart = coerceStripeTimestampToDate(get(event, 'data.object.current_period_start'));
const cancelAtPeriodEnd = get(event, 'data.object.cancel_at_period_end');
const status = get(event, 'data.object.status');
if (event.type === 'customer.subscription.deleted') {
const subscriptionId = event.data.object.id;
if (isNil(organizationId)) {
throw createOrganizationNotFoundError();
if (isNil(subscriptionId)) {
return;
}
const { organizationPlan } = await plansRepository.getOrganizationPlanByPriceId({ priceId: subscriptionItem.price.id });
await subscriptionsRepository.updateSubscription({
subscriptionId: subscriptionItem.id,
seatsCount: organizationPlan.limits.maxOrganizationsMembersCount,
status,
currentPeriodEnd,
currentPeriodStart,
cancelAtPeriodEnd,
planId: organizationPlan.id,
subscriptionId,
status: 'canceled',
});
logger.info({ subscriptionId }, 'Subscription canceled');
}
}

View File

@@ -17,3 +17,8 @@ export const TAGGING_RULE_FIELDS = {
DOCUMENT_NAME: 'name',
DOCUMENT_CONTENT: 'content',
} as const satisfies Record<string, keyof DbSelectableDocument>;
export const CONDITION_MATCH_MODES = {
ALL: 'all',
ANY: 'any',
} as const;

Some files were not shown because too many files have changed in this diff Show More