mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 12:15:22 -06:00
Compare commits
37 Commits
top-bar-ex
...
@papra/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3903eed170 | ||
|
|
c70d7e419a | ||
|
|
2240f58f04 | ||
|
|
79e9bb1b61 | ||
|
|
6e18162435 | ||
|
|
16ae4617df | ||
|
|
1c46071e00 | ||
|
|
377c11c185 | ||
|
|
28c3c15cef | ||
|
|
0391a3bcd5 | ||
|
|
2c75eec862 | ||
|
|
ccf7602f19 | ||
|
|
b8a515a313 | ||
|
|
0aad88471b | ||
|
|
efd2ae1c73 | ||
|
|
e9a719d06a | ||
|
|
68714267ad | ||
|
|
75a13da526 | ||
|
|
59d5819018 | ||
|
|
a857370343 | ||
|
|
f4740ba59a | ||
|
|
b0abf7f78a | ||
|
|
182ccbb30b | ||
|
|
75340f0ce7 | ||
|
|
1228486f28 | ||
|
|
655a1c5475 | ||
|
|
d1797eb9be | ||
|
|
bd3e321eb7 | ||
|
|
be25de7721 | ||
|
|
e85403f9a1 | ||
|
|
7de5d0956b | ||
|
|
b1a88230cd | ||
|
|
55bb29582e | ||
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added deleted and total document counts and sizes in the `/api/organizations/:organizationId/documents/statistics` route
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Lighten the client bundle by removing lodash dep
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Fix weird navigation freeze when direct navigation to organizations
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Prevent small flash of wrong theme on initial load for slower connections
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Redacted webhook signing secret in api update response
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Reduced the client bundle size by switching to posthog-lite
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Use organization max file size limit for pre-upload validation
|
||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -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')
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal 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)
|
||||
@@ -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).
|
||||
|
||||
@@ -40,6 +40,10 @@ export const sidebar = [
|
||||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 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': '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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@ export const ssoProviders = [
|
||||
icon: 'i-tabler-brand-github',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const authPagesPaths = {
|
||||
emailVerification: '/email-verification',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
apps/papra-client/src/modules/shared/date/coerce-date.ts
Normal file
13
apps/papra-client/src/modules/shared/date/coerce-date.ts
Normal 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}"`);
|
||||
}
|
||||
1
apps/papra-client/src/modules/shared/date/date.types.ts
Normal file
1
apps/papra-client/src/modules/shared/date/date.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CoercibleDate = Date | string | number;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
9
apps/papra-client/src/modules/shared/utils/units.ts
Normal file
9
apps/papra-client/src/modules/shared/utils/units.ts
Normal 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
|
||||
};
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SettingsLayout: ParentComponent = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
|
||||
<SideNav
|
||||
mainMenu={getMainMenuItems()}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { Component, ComponentProps, JSX, ParentComponent } from 'solid-js';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { Show, Suspense } from 'solid-js';
|
||||
@@ -16,7 +15,6 @@ import { useThemeStore } from '@/modules/theme/theme.store';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
@@ -43,57 +41,10 @@ export const SideNav: Component<{
|
||||
footer?: Component;
|
||||
preFooter?: Component;
|
||||
}> = (props) => {
|
||||
const getShortSideNavItems = () => [
|
||||
{
|
||||
label: 'All organizations',
|
||||
to: '/organizations',
|
||||
icon: 'i-tabler-building-community',
|
||||
},
|
||||
{
|
||||
label: 'GitHub repository',
|
||||
href: 'https://github.com/papra-hq/papra',
|
||||
icon: 'i-tabler-brand-github',
|
||||
},
|
||||
{
|
||||
label: 'Bluesky',
|
||||
href: 'https://bsky.app/profile/papra.app',
|
||||
icon: 'i-tabler-brand-bluesky',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="flex h-full">
|
||||
<div class="w-65px border-r bg-card pt-4 pb-6 flex flex-col">
|
||||
<Button variant="link" size="icon" as={A} href="/" class="text-lg font-bold hover:no-underline flex items-center text-primary mb-4 mx-auto">
|
||||
<div class="i-tabler-file-text size-10 transform rotate-12deg hover:rotate-25deg transition"></div>
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
{getShortSideNavItems().map(menuItem => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
as={(tooltipProps: TooltipTriggerProps) => (
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-lg font-bold hover:no-underline flex items-center text-foreground dark:text-muted-foreground hover:text-primary"
|
||||
{...tooltipProps}
|
||||
aria-label={menuItem.label}
|
||||
{...(menuItem.href
|
||||
? { as: 'a', href: menuItem.href, target: '_blank', rel: 'noopener noreferrer' }
|
||||
: { as: A, href: menuItem.to })}
|
||||
>
|
||||
<div class={cn(menuItem.icon, 'size-5')} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipContent>{menuItem.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
|
||||
<div class="h-full flex flex-col pb-6 flex-1">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{props.header && <props.header />}
|
||||
|
||||
{props.mainMenu && (
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
2058
apps/papra-server/src/migrations/meta/0011_snapshot.json
Normal file
2058
apps/papra-server/src/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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 } } });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user