mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-20 12:19:46 -06:00
Compare commits
34 Commits
@papra/doc
...
kysely-mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca83ee3868 | ||
|
|
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 |
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}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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';
|
||||
@@ -218,6 +219,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
||||
const status = getErrorStatus(error);
|
||||
|
||||
if (status && [401, 403].includes(status)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +44,13 @@
|
||||
"@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",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@owlrelay/api-sdk": "^0.0.2",
|
||||
"@owlrelay/webhook": "^0.0.3",
|
||||
"@papra/lecture": "workspace:*",
|
||||
@@ -65,6 +66,7 @@
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"figue": "^3.1.1",
|
||||
"hono": "^4.8.2",
|
||||
"kysely": "^0.28.8",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mime-types": "^3.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
@@ -78,11 +80,12 @@
|
||||
"sanitize-html": "^2.17.0",
|
||||
"stripe": "^17.7.0",
|
||||
"tsx": "^4.20.3",
|
||||
"valibot": "1.0.0-beta.10",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"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,43 +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,
|
||||
hostname: config.server.hostname,
|
||||
},
|
||||
({ 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'));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { setupDatabase } from '../../modules/app/database/database';
|
||||
import { initialSchemaSetupMigration } from './0001-initial-schema-setup.migration';
|
||||
@@ -9,7 +9,7 @@ describe('0001-initial-schema-setup migration', () => {
|
||||
const { db } = setupDatabase({ url: ':memory:' });
|
||||
await initialSchemaSetupMigration.up({ db });
|
||||
|
||||
const { rows: existingTables } = await db.run(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`);
|
||||
const { rows: existingTables } = await db.executeQuery<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`.compile(db));
|
||||
|
||||
expect(existingTables.map(({ name }) => name)).to.eql([
|
||||
'documents',
|
||||
@@ -43,7 +43,7 @@ describe('0001-initial-schema-setup migration', () => {
|
||||
|
||||
await initialSchemaSetupMigration.down({ db });
|
||||
|
||||
const { rows: existingTablesAfterDown } = await db.run(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`);
|
||||
const { rows: existingTablesAfterDown } = await db.executeQuery<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`.compile(db));
|
||||
|
||||
expect(existingTablesAfterDown.map(({ name }) => name)).to.eql([]);
|
||||
});
|
||||
|
||||
@@ -1,220 +1,326 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const initialSchemaSetupMigration = {
|
||||
name: 'initial-schema-setup',
|
||||
description: 'Creation of the base tables for the application',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "documents" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"is_deleted" integer DEFAULT false NOT NULL,
|
||||
"deleted_at" integer,
|
||||
"organization_id" text NOT NULL,
|
||||
"created_by" text,
|
||||
"deleted_by" text,
|
||||
"original_name" text NOT NULL,
|
||||
"original_size" integer DEFAULT 0 NOT NULL,
|
||||
"original_storage_key" text NOT NULL,
|
||||
"original_sha256_hash" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null,
|
||||
FOREIGN KEY ("deleted_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null
|
||||
);
|
||||
`),
|
||||
// Create users table first (no dependencies)
|
||||
await db.schema
|
||||
.createTable('users')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('email', 'text', col => col.notNull())
|
||||
.addColumn('email_verified', 'integer', col => col.notNull().defaultTo(0))
|
||||
.addColumn('name', 'text')
|
||||
.addColumn('image', 'text')
|
||||
.addColumn('max_organization_count', 'integer')
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "documents_organization_id_is_deleted_created_at_index" ON "documents" ("organization_id","is_deleted","created_at");`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "documents_organization_id_is_deleted_index" ON "documents" ("organization_id","is_deleted");`),
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "documents_organization_id_original_sha256_hash_unique" ON "documents" ("organization_id","original_sha256_hash");`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "documents_original_sha256_hash_index" ON "documents" ("original_sha256_hash");`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "documents_organization_id_size_index" ON "documents" ("organization_id","original_size");`),
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "organization_invitations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"role" text,
|
||||
"status" text NOT NULL,
|
||||
"expires_at" integer NOT NULL,
|
||||
"inviter_id" text NOT NULL,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
await db.schema
|
||||
.createIndex('users_email_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.column('email')
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "organization_members" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('users_email_index')
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.column('email')
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "organization_members_user_organization_unique" ON "organization_members" ("organization_id","user_id");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "organizations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"customer_id" text
|
||||
);`),
|
||||
// Create organizations table (no dependencies)
|
||||
await db.schema
|
||||
.createTable('organizations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('customer_id', 'text')
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "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 organization_members table (depends on users and organizations)
|
||||
await db.schema
|
||||
.createTable('organization_members')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('user_id', 'text', col => col.notNull().references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('role', 'text', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "user_roles_role_index" ON "user_roles" ("role");`),
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "user_roles_user_id_role_unique_index" ON "user_roles" ("user_id","role");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "documents_tags" (
|
||||
"document_id" text NOT NULL,
|
||||
"tag_id" text NOT NULL,
|
||||
PRIMARY KEY("document_id", "tag_id"),
|
||||
FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('organization_members_user_organization_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('organization_members')
|
||||
.columns(['organization_id', 'user_id'])
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "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 organization_invitations table (depends on users and organizations)
|
||||
await db.schema
|
||||
.createTable('organization_invitations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('email', 'text', col => col.notNull())
|
||||
.addColumn('role', 'text')
|
||||
.addColumn('status', 'text', col => col.notNull())
|
||||
.addColumn('expires_at', 'integer', col => col.notNull())
|
||||
.addColumn('inviter_id', 'text', col => col.notNull().references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "tags_organization_id_name_unique" ON "tags" ("organization_id","name");`),
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "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
|
||||
);
|
||||
`),
|
||||
// Create documents table (depends on users and organizations)
|
||||
await db.schema
|
||||
.createTable('documents')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('is_deleted', 'integer', col => col.notNull().defaultTo(0))
|
||||
.addColumn('deleted_at', 'integer')
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('created_by', 'text', col => col.references('users.id').onDelete('set null').onUpdate('cascade'))
|
||||
.addColumn('deleted_by', 'text', col => col.references('users.id').onDelete('set null').onUpdate('cascade'))
|
||||
.addColumn('original_name', 'text', col => col.notNull())
|
||||
.addColumn('original_size', 'integer', col => col.notNull().defaultTo(0))
|
||||
.addColumn('original_storage_key', 'text', col => col.notNull())
|
||||
.addColumn('original_sha256_hash', 'text', col => col.notNull())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('mime_type', 'text', col => col.notNull())
|
||||
.addColumn('content', 'text', col => col.notNull().defaultTo(''))
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" ("email");`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "users_email_index" ON "users" ("email");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "auth_accounts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"user_id" text,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"access_token_expires_at" integer,
|
||||
"refresh_token_expires_at" integer,
|
||||
"scope" text,
|
||||
"id_token" text,
|
||||
"password" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('documents_organization_id_is_deleted_created_at_index')
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.columns(['organization_id', 'is_deleted', 'created_at'])
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "auth_sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"user_id" text,
|
||||
"expires_at" integer NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"active_organization_id" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("active_organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE set null
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('documents_organization_id_is_deleted_index')
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.columns(['organization_id', 'is_deleted'])
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "auth_sessions_token_index" ON "auth_sessions" ("token");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "auth_verifications" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" integer NOT NULL
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('documents_organization_id_original_sha256_hash_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.columns(['organization_id', 'original_sha256_hash'])
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "auth_verifications_identifier_index" ON "auth_verifications" ("identifier");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "intake_emails" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"email_address" text NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"allowed_origins" text DEFAULT '[]' NOT NULL,
|
||||
"is_enabled" integer DEFAULT true NOT NULL,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);`),
|
||||
await db.schema
|
||||
.createIndex('documents_original_sha256_hash_index')
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.column('original_sha256_hash')
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "intake_emails_email_address_unique" ON "intake_emails" ("email_address");`),
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS "organization_subscriptions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"plan_id" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"seats_count" integer NOT NULL,
|
||||
"current_period_end" integer NOT NULL,
|
||||
"current_period_start" integer NOT NULL,
|
||||
"cancel_at_period_end" integer DEFAULT false NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);`),
|
||||
]);
|
||||
await db.schema
|
||||
.createIndex('documents_organization_id_size_index')
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.columns(['organization_id', 'original_size'])
|
||||
.execute();
|
||||
|
||||
// Create tags table (depends on organizations)
|
||||
await db.schema
|
||||
.createTable('tags')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('color', 'text', col => col.notNull())
|
||||
.addColumn('description', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('tags_organization_id_name_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('tags')
|
||||
.columns(['organization_id', 'name'])
|
||||
.execute();
|
||||
|
||||
// Create documents_tags junction table (depends on documents and tags)
|
||||
await db.schema
|
||||
.createTable('documents_tags')
|
||||
.ifNotExists()
|
||||
.addColumn('document_id', 'text', col => col.notNull().references('documents.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('tag_id', 'text', col => col.notNull().references('tags.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addPrimaryKeyConstraint('documents_tags_pkey', ['document_id', 'tag_id'])
|
||||
.execute();
|
||||
|
||||
// Create user_roles table (depends on users)
|
||||
await db.schema
|
||||
.createTable('user_roles')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('user_id', 'text', col => col.notNull().references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('role', 'text', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('user_roles_role_index')
|
||||
.ifNotExists()
|
||||
.on('user_roles')
|
||||
.column('role')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('user_roles_user_id_role_unique_index')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('user_roles')
|
||||
.columns(['user_id', 'role'])
|
||||
.execute();
|
||||
|
||||
// Create auth_accounts table (depends on users)
|
||||
await db.schema
|
||||
.createTable('auth_accounts')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('user_id', 'text', col => col.references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('account_id', 'text', col => col.notNull())
|
||||
.addColumn('provider_id', 'text', col => col.notNull())
|
||||
.addColumn('access_token', 'text')
|
||||
.addColumn('refresh_token', 'text')
|
||||
.addColumn('access_token_expires_at', 'integer')
|
||||
.addColumn('refresh_token_expires_at', 'integer')
|
||||
.addColumn('scope', 'text')
|
||||
.addColumn('id_token', 'text')
|
||||
.addColumn('password', 'text')
|
||||
.execute();
|
||||
|
||||
// Create auth_sessions table (depends on users and organizations)
|
||||
await db.schema
|
||||
.createTable('auth_sessions')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('token', 'text', col => col.notNull())
|
||||
.addColumn('user_id', 'text', col => col.references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('expires_at', 'integer', col => col.notNull())
|
||||
.addColumn('ip_address', 'text')
|
||||
.addColumn('user_agent', 'text')
|
||||
.addColumn('active_organization_id', 'text', col => col.references('organizations.id').onDelete('set null').onUpdate('cascade'))
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('auth_sessions_token_index')
|
||||
.ifNotExists()
|
||||
.on('auth_sessions')
|
||||
.column('token')
|
||||
.execute();
|
||||
|
||||
// Create auth_verifications table
|
||||
await db.schema
|
||||
.createTable('auth_verifications')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('identifier', 'text', col => col.notNull())
|
||||
.addColumn('value', 'text', col => col.notNull())
|
||||
.addColumn('expires_at', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('auth_verifications_identifier_index')
|
||||
.ifNotExists()
|
||||
.on('auth_verifications')
|
||||
.column('identifier')
|
||||
.execute();
|
||||
|
||||
// Create intake_emails table (depends on organizations)
|
||||
await db.schema
|
||||
.createTable('intake_emails')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('email_address', 'text', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('allowed_origins', 'text', col => col.notNull().defaultTo('[]'))
|
||||
.addColumn('is_enabled', 'integer', col => col.notNull().defaultTo(1))
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('intake_emails_email_address_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('intake_emails')
|
||||
.column('email_address')
|
||||
.execute();
|
||||
|
||||
// Create organization_subscriptions table (depends on organizations)
|
||||
await db.schema
|
||||
.createTable('organization_subscriptions')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('customer_id', 'text', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('plan_id', 'text', col => col.notNull())
|
||||
.addColumn('status', 'text', col => col.notNull())
|
||||
.addColumn('seats_count', 'integer', col => col.notNull())
|
||||
.addColumn('current_period_end', 'integer', col => col.notNull())
|
||||
.addColumn('current_period_start', 'integer', col => col.notNull())
|
||||
.addColumn('cancel_at_period_end', 'integer', col => col.notNull().defaultTo(0))
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
// Tables
|
||||
db.run(sql`DROP TABLE IF EXISTS "organization_subscriptions";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "intake_emails";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "auth_verifications";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "auth_sessions";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "auth_accounts";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "tags";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "documents_tags";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "user_roles";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "organizations";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "organization_members";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "organization_invitations";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "documents";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "users";`),
|
||||
// Drop tables in reverse order of creation (respecting foreign key constraints)
|
||||
await db.schema.dropTable('organization_subscriptions').ifExists().execute();
|
||||
await db.schema.dropTable('intake_emails').ifExists().execute();
|
||||
await db.schema.dropTable('auth_verifications').ifExists().execute();
|
||||
await db.schema.dropTable('auth_sessions').ifExists().execute();
|
||||
await db.schema.dropTable('auth_accounts').ifExists().execute();
|
||||
await db.schema.dropTable('user_roles').ifExists().execute();
|
||||
await db.schema.dropTable('documents_tags').ifExists().execute();
|
||||
await db.schema.dropTable('tags').ifExists().execute();
|
||||
await db.schema.dropTable('documents').ifExists().execute();
|
||||
await db.schema.dropTable('organization_invitations').ifExists().execute();
|
||||
await db.schema.dropTable('organization_members').ifExists().execute();
|
||||
await db.schema.dropTable('organizations').ifExists().execute();
|
||||
await db.schema.dropTable('users').ifExists().execute();
|
||||
|
||||
// // Indexes
|
||||
db.run(sql`DROP INDEX IF EXISTS "documents_organization_id_is_deleted_created_at_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "documents_organization_id_is_deleted_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "documents_organization_id_original_sha256_hash_unique";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "documents_original_sha256_hash_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "documents_organization_id_size_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "user_roles_role_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "user_roles_user_id_role_unique_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "tags_organization_id_name_unique";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "users_email_unique";`),
|
||||
]);
|
||||
await db.schema.dropIndex('users_email_unique').ifExists().execute();
|
||||
await db.schema.dropIndex('users_email_index').ifExists().execute();
|
||||
await db.schema.dropIndex('organization_members_user_organization_unique').ifExists().execute();
|
||||
await db.schema.dropIndex('documents_organization_id_is_deleted_created_at_index').ifExists().execute();
|
||||
await db.schema.dropIndex('documents_organization_id_is_deleted_index').ifExists().execute();
|
||||
await db.schema.dropIndex('documents_organization_id_original_sha256_hash_unique').ifExists().execute();
|
||||
await db.schema.dropIndex('documents_original_sha256_hash_index').ifExists().execute();
|
||||
await db.schema.dropIndex('documents_organization_id_size_index').ifExists().execute();
|
||||
await db.schema.dropIndex('tags_organization_id_name_unique').ifExists().execute();
|
||||
await db.schema.dropIndex('user_roles_role_index').ifExists().execute();
|
||||
await db.schema.dropIndex('user_roles_user_id_role_unique_index').ifExists().execute();
|
||||
await db.schema.dropIndex('auth_sessions_token_index').ifExists().execute();
|
||||
await db.schema.dropIndex('auth_verifications_identifier_index').ifExists().execute();
|
||||
await db.schema.dropIndex('intake_emails_email_address_unique').ifExists().execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
export const documentsFtsMigration = {
|
||||
name: 'documents-fts',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(id UNINDEXED, name, original_name, content, prefix='2 3 4')`),
|
||||
db.run(sql`INSERT INTO documents_fts(id, name, original_name, content) SELECT id, name, original_name, content FROM documents`),
|
||||
db.run(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_insert AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content);
|
||||
END
|
||||
`),
|
||||
db.run(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_update AFTER UPDATE ON documents BEGIN
|
||||
UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id;
|
||||
END
|
||||
`),
|
||||
db.run(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_delete AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE id = old.id;
|
||||
END
|
||||
`),
|
||||
]);
|
||||
// FTS5 virtual tables and triggers require raw SQL (SQLite-specific)
|
||||
await db.executeQuery(sql`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(id UNINDEXED, name, original_name, content, prefix='2 3 4')`.compile(db));
|
||||
await db.executeQuery(sql`INSERT INTO documents_fts(id, name, original_name, content) SELECT id, name, original_name, content FROM documents`.compile(db));
|
||||
|
||||
await db.executeQuery(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_insert AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content);
|
||||
END
|
||||
`.compile(db));
|
||||
|
||||
await db.executeQuery(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_update AFTER UPDATE ON documents BEGIN
|
||||
UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id;
|
||||
END
|
||||
`.compile(db));
|
||||
|
||||
await db.executeQuery(sql`
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_delete AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE id = old.id;
|
||||
END
|
||||
`.compile(db));
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_insert`),
|
||||
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_update`),
|
||||
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_delete`),
|
||||
db.run(sql`DROP TABLE IF EXISTS documents_fts`),
|
||||
]);
|
||||
await db.executeQuery(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_insert`.compile(db));
|
||||
await db.executeQuery(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_update`.compile(db));
|
||||
await db.executeQuery(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_delete`.compile(db));
|
||||
await db.executeQuery(sql`DROP TABLE IF EXISTS documents_fts`.compile(db));
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,57 +1,51 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const taggingRulesMigration = {
|
||||
name: 'tagging-rules',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "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 tagging_rules table first (depends on organizations)
|
||||
await db.schema
|
||||
.createTable('tagging_rules')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('organization_id', 'text', col => col.notNull().references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('description', 'text')
|
||||
.addColumn('enabled', 'integer', col => col.notNull().defaultTo(1))
|
||||
.execute();
|
||||
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "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 tagging_rule_conditions table (depends on tagging_rules)
|
||||
await db.schema
|
||||
.createTable('tagging_rule_conditions')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('tagging_rule_id', 'text', col => col.notNull().references('tagging_rules.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('field', 'text', col => col.notNull())
|
||||
.addColumn('operator', 'text', col => col.notNull())
|
||||
.addColumn('value', 'text', col => col.notNull())
|
||||
.addColumn('is_case_sensitive', 'integer', col => col.notNull().defaultTo(0))
|
||||
.execute();
|
||||
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "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 tagging_rule_actions table (depends on tagging_rules and tags)
|
||||
await db.schema
|
||||
.createTable('tagging_rule_actions')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('tagging_rule_id', 'text', col => col.notNull().references('tagging_rules.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('tag_id', 'text', col => col.notNull().references('tags.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TABLE IF EXISTS "tagging_rule_actions"`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "tagging_rule_conditions"`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "tagging_rules"`),
|
||||
]);
|
||||
await db.schema.dropTable('tagging_rule_actions').ifExists().execute();
|
||||
await db.schema.dropTable('tagging_rule_conditions').ifExists().execute();
|
||||
await db.schema.dropTable('tagging_rules').ifExists().execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const apiKeysMigration = {
|
||||
name: 'api-keys',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "api_key_organizations" (
|
||||
"api_key_id" text NOT NULL,
|
||||
"organization_member_id" text NOT NULL,
|
||||
FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("organization_member_id") REFERENCES "organization_members"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "api_keys" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"prefix" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"last_used_at" integer,
|
||||
"expires_at" integer,
|
||||
"permissions" text DEFAULT '[]' NOT NULL,
|
||||
"all_organizations" integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "api_keys_key_hash_unique" ON "api_keys" ("key_hash")`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS "key_hash_index" ON "api_keys" ("key_hash")`),
|
||||
]);
|
||||
// Create api_keys table first (depends on users)
|
||||
await db.schema
|
||||
.createTable('api_keys')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('key_hash', 'text', col => col.notNull())
|
||||
.addColumn('prefix', 'text', col => col.notNull())
|
||||
.addColumn('user_id', 'text', col => col.notNull().references('users.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('last_used_at', 'integer')
|
||||
.addColumn('expires_at', 'integer')
|
||||
.addColumn('permissions', 'text', col => col.notNull().defaultTo('[]'))
|
||||
.addColumn('all_organizations', 'integer', col => col.notNull().defaultTo(0))
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('api_keys_key_hash_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('api_keys')
|
||||
.column('key_hash')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('key_hash_index')
|
||||
.ifNotExists()
|
||||
.on('api_keys')
|
||||
.column('key_hash')
|
||||
.execute();
|
||||
|
||||
// Create api_key_organizations junction table (depends on api_keys and organization_members)
|
||||
await db.schema
|
||||
.createTable('api_key_organizations')
|
||||
.ifNotExists()
|
||||
.addColumn('api_key_id', 'text', col => col.notNull().references('api_keys.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('organization_member_id', 'text', col => col.notNull().references('organization_members.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TABLE IF EXISTS "api_key_organizations"`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "api_keys"`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "api_keys_key_hash_unique"`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "key_hash_index"`),
|
||||
]);
|
||||
await db.schema.dropTable('api_key_organizations').ifExists().execute();
|
||||
await db.schema.dropTable('api_keys').ifExists().execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const organizationsWebhooksMigration = {
|
||||
name: 'organizations-webhooks',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "webhook_deliveries" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"webhook_id" text NOT NULL,
|
||||
"event_name" text NOT NULL,
|
||||
"request_payload" text NOT NULL,
|
||||
"response_payload" text NOT NULL,
|
||||
"response_status" integer NOT NULL,
|
||||
FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "webhook_events" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"webhook_id" text NOT NULL,
|
||||
"event_name" text NOT NULL,
|
||||
FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
// Create webhooks table first
|
||||
await db.schema
|
||||
.createTable('webhooks')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('url', 'text', col => col.notNull())
|
||||
.addColumn('secret', 'text')
|
||||
.addColumn('enabled', 'integer', col => col.notNull().defaultTo(1))
|
||||
.addColumn('created_by', 'text', col => col.references('users.id').onDelete('set null').onUpdate('cascade'))
|
||||
.addColumn('organization_id', 'text', col => col.references('organizations.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.execute();
|
||||
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "webhook_events_webhook_id_event_name_unique" ON "webhook_events" ("webhook_id","event_name")`),
|
||||
// Create webhook_events table (depends on webhooks)
|
||||
await db.schema
|
||||
.createTable('webhook_events')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('webhook_id', 'text', col => col.notNull().references('webhooks.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('event_name', 'text', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "webhooks" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"secret" text,
|
||||
"enabled" integer DEFAULT true NOT NULL,
|
||||
"created_by" text,
|
||||
"organization_id" text,
|
||||
FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null,
|
||||
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
`),
|
||||
await db.schema
|
||||
.createIndex('webhook_events_webhook_id_event_name_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('webhook_events')
|
||||
.columns(['webhook_id', 'event_name'])
|
||||
.execute();
|
||||
|
||||
]);
|
||||
// Create webhook_deliveries table (depends on webhooks)
|
||||
await db.schema
|
||||
.createTable('webhook_deliveries')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('updated_at', 'integer', col => col.notNull())
|
||||
.addColumn('webhook_id', 'text', col => col.notNull().references('webhooks.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('event_name', 'text', col => col.notNull())
|
||||
.addColumn('request_payload', 'text', col => col.notNull())
|
||||
.addColumn('response_payload', 'text', col => col.notNull())
|
||||
.addColumn('response_status', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TABLE IF EXISTS "webhook_deliveries"`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "webhook_events"`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "webhook_events_webhook_id_event_name_unique"`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "webhooks"`),
|
||||
]);
|
||||
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
|
||||
await db.schema.dropTable('webhook_events').ifExists().execute();
|
||||
await db.schema.dropTable('webhooks').ifExists().execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
export const organizationsInvitationsImprovementMigration = {
|
||||
name: 'organizations-invitations-improvement',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "role" TO "role" text NOT NULL`),
|
||||
db.run(sql`CREATE UNIQUE INDEX IF NOT EXISTS "organization_invitations_organization_email_unique" ON "organization_invitations" ("organization_id","email")`),
|
||||
db.run(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "status" TO "status" text NOT NULL DEFAULT 'pending'`),
|
||||
]);
|
||||
await db.executeQuery(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "role" TO "role" text not null`.compile(db));
|
||||
await db.executeQuery(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "status" TO "status" text not null DEFAULT 'pending'`.compile(db));
|
||||
|
||||
await db.schema
|
||||
.createIndex('organization_invitations_organization_email_unique')
|
||||
.unique()
|
||||
.ifNotExists()
|
||||
.on('organization_invitations')
|
||||
.columns(['organization_id', 'email'])
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "role" TO "role" text`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "organization_invitations_organization_email_unique"`),
|
||||
db.run(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "status" TO "status" text NOT NULL`),
|
||||
]);
|
||||
await db.executeQuery(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "role" TO "role" text`.compile(db));
|
||||
await db.executeQuery(sql`ALTER TABLE "organization_invitations" ALTER COLUMN "status" TO "status" text not null`.compile(db));
|
||||
|
||||
await db.schema
|
||||
.dropIndex('organization_invitations_organization_email_unique')
|
||||
.ifExists()
|
||||
.execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Migration} from '../migrations.types';
|
||||
|
||||
export const documentActivityLogMigration = {
|
||||
name: 'document-activity-log',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS "document_activity_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"event" text NOT NULL,
|
||||
"event_data" text,
|
||||
"user_id" text,
|
||||
"tag_id" text,
|
||||
FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE no action,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE no action
|
||||
);
|
||||
`),
|
||||
]);
|
||||
await db.schema
|
||||
.createTable('document_activity_log')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('document_id', 'text', col => col.notNull().references('documents.id').onDelete('cascade').onUpdate('cascade'))
|
||||
.addColumn('event', 'text', col => col.notNull())
|
||||
.addColumn('event_data', 'text')
|
||||
.addColumn('user_id', 'text', col => col.references('users.id').onDelete('no action').onUpdate('cascade'))
|
||||
.addColumn('tag_id', 'text', col => col.references('tags.id').onDelete('no action').onUpdate('cascade'))
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TABLE IF EXISTS "document_activity_log"`),
|
||||
]);
|
||||
await db.schema.dropTable('document_activity_log').ifExists().execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,56 +1,75 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
export const documentActivityLogOnDeleteSetNullMigration = {
|
||||
name: 'document-activity-log-on-delete-set-null',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`PRAGMA foreign_keys=OFF`),
|
||||
db.run(sql`
|
||||
CREATE TABLE "__new_document_activity_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"event" text NOT NULL,
|
||||
"event_data" text,
|
||||
"user_id" text,
|
||||
"tag_id" text,
|
||||
FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE set null
|
||||
);
|
||||
`),
|
||||
db.run(sql`
|
||||
INSERT INTO "__new_document_activity_log"("id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id") SELECT "id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id" FROM "document_activity_log";
|
||||
`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "document_activity_log"`),
|
||||
db.run(sql`ALTER TABLE "__new_document_activity_log" RENAME TO "document_activity_log"`),
|
||||
db.run(sql`PRAGMA foreign_keys=ON`),
|
||||
]);
|
||||
// SQLite doesn't support modifying foreign keys, need to recreate table
|
||||
await db.executeQuery(sql`PRAGMA foreign_keys=OFF`.compile(db));
|
||||
|
||||
await db.schema
|
||||
.createTable('__new_document_activity_log')
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('document_id', 'text', col => col.notNull().references('documents.id').onUpdate('cascade').onDelete('cascade'))
|
||||
.addColumn('event', 'text', col => col.notNull())
|
||||
.addColumn('event_data', 'text')
|
||||
.addColumn('user_id', 'text', col => col.references('users.id').onUpdate('cascade').onDelete('set null'))
|
||||
.addColumn('tag_id', 'text', col => col.references('tags.id').onUpdate('cascade').onDelete('set null'))
|
||||
.execute();
|
||||
|
||||
await db.executeQuery(sql`
|
||||
INSERT INTO "__new_document_activity_log"("id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id")
|
||||
SELECT "id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id" FROM "document_activity_log"
|
||||
`.compile(db));
|
||||
|
||||
await db
|
||||
.schema
|
||||
.dropTable('document_activity_log')
|
||||
.ifExists()
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.schema
|
||||
.alterTable('__new_document_activity_log')
|
||||
.renameTo('document_activity_log')
|
||||
.execute();
|
||||
|
||||
await db.executeQuery(sql`PRAGMA foreign_keys=ON`.compile(db));
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`PRAGMA foreign_keys=OFF`),
|
||||
db.run(sql`
|
||||
CREATE TABLE "__restore_document_activity_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" integer NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"event" text NOT NULL,
|
||||
"event_data" text,
|
||||
"user_id" text,
|
||||
"tag_id" text,
|
||||
FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE no action,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE no action
|
||||
);
|
||||
`),
|
||||
db.run(sql`INSERT INTO "__restore_document_activity_log"("id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id") SELECT "id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id" FROM "document_activity_log";`),
|
||||
db.run(sql`DROP TABLE IF EXISTS "document_activity_log"`),
|
||||
db.run(sql`ALTER TABLE "__restore_document_activity_log" RENAME TO "document_activity_log"`),
|
||||
db.run(sql`PRAGMA foreign_keys=ON`),
|
||||
]);
|
||||
await db.executeQuery(sql`PRAGMA foreign_keys=OFF`.compile(db));
|
||||
|
||||
await db.schema
|
||||
.createTable('__restore_document_activity_log')
|
||||
.addColumn('id', 'text', col => col.primaryKey().notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.addColumn('document_id', 'text', col => col.notNull().references('documents.id').onUpdate('cascade').onDelete('cascade'))
|
||||
.addColumn('event', 'text', col => col.notNull())
|
||||
.addColumn('event_data', 'text')
|
||||
.addColumn('user_id', 'text', col => col.references('users.id').onUpdate('cascade').onDelete('no action'))
|
||||
.addColumn('tag_id', 'text', col => col.references('tags.id').onUpdate('cascade').onDelete('no action'))
|
||||
.execute();
|
||||
|
||||
await db.executeQuery(sql`
|
||||
INSERT INTO "__restore_document_activity_log"("id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id")
|
||||
SELECT "id", "created_at", "document_id", "event", "event_data", "user_id", "tag_id" FROM "document_activity_log"
|
||||
`.compile(db));
|
||||
|
||||
await db
|
||||
.schema
|
||||
.dropTable('document_activity_log')
|
||||
.ifExists()
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.schema
|
||||
.alterTable('__restore_document_activity_log')
|
||||
.renameTo('document_activity_log')
|
||||
.execute();
|
||||
|
||||
await db.executeQuery(sql`PRAGMA foreign_keys=ON`.compile(db));
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const dropLegacyMigrationsMigration = {
|
||||
name: 'drop-legacy-migrations',
|
||||
description: 'Drop the legacy migrations table as it is not used anymore',
|
||||
|
||||
up: async ({ db }) => {
|
||||
await db.run(sql`DROP TABLE IF EXISTS "__drizzle_migrations"`);
|
||||
await db.schema.dropTable('__drizzle_migrations').ifExists().execute();
|
||||
},
|
||||
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,32 +1,49 @@
|
||||
import type { BatchItem } from 'drizzle-orm/batch';
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
export const documentFileEncryptionMigration = {
|
||||
name: 'document-file-encryption',
|
||||
|
||||
up: async ({ db }) => {
|
||||
// Check if columns already exist to handle reapplying migrations
|
||||
const tableInfo = await db.run(sql`PRAGMA table_info(documents)`);
|
||||
const tableInfo = await db.executeQuery<{ name: string }>(sql`PRAGMA table_info(documents)`.compile(db));
|
||||
const existingColumns = tableInfo.rows.map(row => row.name);
|
||||
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
|
||||
|
||||
const statements = [
|
||||
...(!hasColumn('file_encryption_key_wrapped') ? [sql`ALTER TABLE documents ADD COLUMN file_encryption_key_wrapped TEXT`] : []),
|
||||
...(!hasColumn('file_encryption_kek_version') ? [sql`ALTER TABLE documents ADD COLUMN file_encryption_kek_version TEXT`] : []),
|
||||
...(!hasColumn('file_encryption_algorithm') ? [sql`ALTER TABLE documents ADD COLUMN file_encryption_algorithm TEXT`] : []),
|
||||
sql`CREATE INDEX IF NOT EXISTS documents_file_encryption_kek_version_index ON documents (file_encryption_kek_version)`,
|
||||
];
|
||||
if (!hasColumn('file_encryption_key_wrapped')) {
|
||||
await db.schema
|
||||
.alterTable('documents')
|
||||
.addColumn('file_encryption_key_wrapped', 'text')
|
||||
.execute();
|
||||
}
|
||||
|
||||
await db.batch(statements.map(statement => db.run(statement) as BatchItem<'sqlite'>) as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]);
|
||||
if (!hasColumn('file_encryption_kek_version')) {
|
||||
await db.schema
|
||||
.alterTable('documents')
|
||||
.addColumn('file_encryption_kek_version', 'text')
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (!hasColumn('file_encryption_algorithm')) {
|
||||
await db.schema
|
||||
.alterTable('documents')
|
||||
.addColumn('file_encryption_algorithm', 'text')
|
||||
.execute();
|
||||
}
|
||||
|
||||
await db.schema
|
||||
.createIndex('documents_file_encryption_kek_version_index')
|
||||
.ifNotExists()
|
||||
.on('documents')
|
||||
.column('file_encryption_kek_version')
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP INDEX IF EXISTS documents_file_encryption_kek_version_index`),
|
||||
db.run(sql`ALTER TABLE documents DROP COLUMN file_encryption_key_wrapped`),
|
||||
db.run(sql`ALTER TABLE documents DROP COLUMN file_encryption_kek_version`),
|
||||
db.run(sql`ALTER TABLE documents DROP COLUMN file_encryption_algorithm`),
|
||||
]);
|
||||
await db.schema.dropIndex('documents_file_encryption_kek_version_index').ifExists().execute();
|
||||
|
||||
await db.schema.alterTable('documents').dropColumn('file_encryption_key_wrapped').execute();
|
||||
await db.schema.alterTable('documents').dropColumn('file_encryption_kek_version').execute();
|
||||
await db.schema.alterTable('documents').dropColumn('file_encryption_algorithm').execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
import type { BatchItem } from 'drizzle-orm/batch';
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
export const softDeleteOrganizationsMigration = {
|
||||
name: 'soft-delete-organizations',
|
||||
|
||||
up: async ({ db }) => {
|
||||
const tableInfo = await db.run(sql`PRAGMA table_info(organizations)`);
|
||||
const tableInfo = await db.executeQuery<{ name: string }>(sql`PRAGMA table_info(organizations)`.compile(db));
|
||||
const existingColumns = tableInfo.rows.map(row => row.name);
|
||||
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
|
||||
|
||||
const statements = [
|
||||
...(hasColumn('deleted_by') ? [] : [(sql`ALTER TABLE "organizations" ADD "deleted_by" text REFERENCES users(id);`)]),
|
||||
...(hasColumn('deleted_at') ? [] : [(sql`ALTER TABLE "organizations" ADD "deleted_at" integer;`)]),
|
||||
...(hasColumn('scheduled_purge_at') ? [] : [(sql`ALTER TABLE "organizations" ADD "scheduled_purge_at" integer;`)]),
|
||||
if (!hasColumn('deleted_by')) {
|
||||
await db.schema
|
||||
.alterTable('organizations')
|
||||
.addColumn('deleted_by', 'text', col => col.references('users.id').onDelete('set null').onUpdate('cascade'))
|
||||
.execute();
|
||||
}
|
||||
|
||||
sql`CREATE INDEX IF NOT EXISTS "organizations_deleted_at_purge_at_index" ON "organizations" ("deleted_at","scheduled_purge_at");`,
|
||||
sql`CREATE INDEX IF NOT EXISTS "organizations_deleted_by_deleted_at_index" ON "organizations" ("deleted_by","deleted_at");`,
|
||||
];
|
||||
if (!hasColumn('deleted_at')) {
|
||||
await db.schema
|
||||
.alterTable('organizations')
|
||||
.addColumn('deleted_at', 'integer')
|
||||
.execute();
|
||||
}
|
||||
|
||||
await db.batch(statements.map(statement => db.run(statement) as BatchItem<'sqlite'>) as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]);
|
||||
if (!hasColumn('scheduled_purge_at')) {
|
||||
await db.schema
|
||||
.alterTable('organizations')
|
||||
.addColumn('scheduled_purge_at', 'integer')
|
||||
.execute();
|
||||
}
|
||||
|
||||
await db.schema
|
||||
.createIndex('organizations_deleted_at_purge_at_index')
|
||||
.ifNotExists()
|
||||
.on('organizations')
|
||||
.columns(['deleted_at', 'scheduled_purge_at'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('organizations_deleted_by_deleted_at_index')
|
||||
.ifNotExists()
|
||||
.on('organizations')
|
||||
.columns(['deleted_by', 'deleted_at'])
|
||||
.execute();
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP INDEX IF EXISTS "organizations_deleted_at_purge_at_index";`),
|
||||
db.run(sql`DROP INDEX IF EXISTS "organizations_deleted_by_deleted_at_index";`),
|
||||
await db.schema.dropIndex('organizations_deleted_at_purge_at_index').ifExists().execute();
|
||||
await db.schema.dropIndex('organizations_deleted_by_deleted_at_index').ifExists().execute();
|
||||
|
||||
db.run(sql`ALTER TABLE "organizations" DROP COLUMN "deleted_by";`),
|
||||
db.run(sql`ALTER TABLE "organizations" DROP COLUMN "deleted_at";`),
|
||||
db.run(sql`ALTER TABLE "organizations" DROP COLUMN "scheduled_purge_at";`),
|
||||
|
||||
]);
|
||||
await db.schema.alterTable('organizations').dropColumn('deleted_by').execute();
|
||||
await db.schema.alterTable('organizations').dropColumn('deleted_at').execute();
|
||||
await db.schema.alterTable('organizations').dropColumn('scheduled_purge_at').execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'kysely';
|
||||
import { CONDITION_MATCH_MODES } from '../../modules/tagging-rules/tagging-rules.constants';
|
||||
|
||||
export const taggingRuleConditionMatchModeMigration = {
|
||||
name: 'tagging-rule-condition-match-mode',
|
||||
|
||||
up: async ({ db }) => {
|
||||
const tableInfo = await db.executeQuery<{ name: string }>(sql`PRAGMA table_info(tagging_rules)`.compile(db));
|
||||
const existingColumns = tableInfo.rows.map(row => row.name);
|
||||
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
|
||||
|
||||
if (!hasColumn('condition_match_mode')) {
|
||||
await db.schema
|
||||
.alterTable('tagging_rules')
|
||||
.addColumn('condition_match_mode', 'text', col => col.defaultTo(CONDITION_MATCH_MODES.ALL).notNull())
|
||||
.execute();
|
||||
}
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.schema.alterTable('tagging_rules').dropColumn('condition_match_mode').execute();
|
||||
},
|
||||
} satisfies Migration;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1743508385578,
|
||||
"tag": "0000_initial_schema_setup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1743508401881,
|
||||
"tag": "0001_documents_fts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1743938048080,
|
||||
"tag": "0002_tagging_rules",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1745131802627,
|
||||
"tag": "0003_api-keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1746297779495,
|
||||
"tag": "0004_organizations-webhooks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1747575029264,
|
||||
"tag": "0005_organizations-invitations-improvement",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1748554484124,
|
||||
"tag": "0006_document-activity-log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1754086182584,
|
||||
"tag": "0007_document-activity-log-on-delete-set-null",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1756332437565,
|
||||
"tag": "0008_document-file-encryption",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1756332955747,
|
||||
"tag": "0009_document-file-encryption",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1760016118956,
|
||||
"tag": "0010_soft-delete-organizations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const migrationsTable = sqliteTable(
|
||||
'migrations',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
runAt: integer('run_at', { mode: 'timestamp_ms' }).notNull().$default(() => new Date()),
|
||||
},
|
||||
t => [
|
||||
index('name_index').on(t.name),
|
||||
index('run_at_index').on(t.runAt),
|
||||
],
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Migration } from './migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { createNoopLogger } from '@crowlog/logger';
|
||||
import { sql } from 'kysely';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { setupDatabase } from '../modules/app/database/database';
|
||||
import { serializeSchema } from '../modules/app/database/database.test-utils';
|
||||
@@ -26,10 +27,10 @@ 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;`);
|
||||
const { rows } = await db.executeQuery<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`.compile(db));
|
||||
expect(rows).to.eql([{ foreign_keys: 1 }]);
|
||||
});
|
||||
|
||||
@@ -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,62 +66,62 @@ 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");
|
||||
CREATE INDEX "auth_sessions_token_index" ON "auth_sessions" ("token");
|
||||
CREATE INDEX "auth_verifications_identifier_index" ON "auth_verifications" ("identifier");
|
||||
CREATE INDEX documents_file_encryption_kek_version_index ON documents (file_encryption_kek_version);
|
||||
CREATE INDEX "documents_organization_id_is_deleted_created_at_index" ON "documents" ("organization_id","is_deleted","created_at");
|
||||
CREATE INDEX "documents_organization_id_is_deleted_index" ON "documents" ("organization_id","is_deleted");
|
||||
CREATE UNIQUE INDEX "documents_organization_id_original_sha256_hash_unique" ON "documents" ("organization_id","original_sha256_hash");
|
||||
CREATE INDEX "documents_organization_id_size_index" ON "documents" ("organization_id","original_size");
|
||||
CREATE INDEX "documents_original_sha256_hash_index" ON "documents" ("original_sha256_hash");
|
||||
CREATE UNIQUE INDEX "intake_emails_email_address_unique" ON "intake_emails" ("email_address");
|
||||
CREATE INDEX "key_hash_index" ON "api_keys" ("key_hash");
|
||||
CREATE INDEX migrations_name_index ON migrations (name);
|
||||
CREATE INDEX migrations_run_at_index ON migrations (run_at);
|
||||
CREATE UNIQUE INDEX "organization_invitations_organization_email_unique" ON "organization_invitations" ("organization_id","email");
|
||||
CREATE UNIQUE INDEX "organization_members_user_organization_unique" ON "organization_members" ("organization_id","user_id");
|
||||
CREATE INDEX "organizations_deleted_at_purge_at_index" ON "organizations" ("deleted_at","scheduled_purge_at");
|
||||
CREATE INDEX "organizations_deleted_by_deleted_at_index" ON "organizations" ("deleted_by","deleted_at");
|
||||
CREATE UNIQUE INDEX "tags_organization_id_name_unique" ON "tags" ("organization_id","name");
|
||||
CREATE INDEX "user_roles_role_index" ON "user_roles" ("role");
|
||||
CREATE UNIQUE INDEX "user_roles_user_id_role_unique_index" ON "user_roles" ("user_id","role");
|
||||
CREATE INDEX "users_email_index" ON "users" ("email");
|
||||
CREATE UNIQUE INDEX "users_email_unique" ON "users" ("email");
|
||||
CREATE UNIQUE INDEX "webhook_events_webhook_id_event_name_unique" ON "webhook_events" ("webhook_id","event_name");
|
||||
CREATE TABLE "api_key_organizations" ( "api_key_id" text NOT NULL, "organization_member_id" text NOT NULL, FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("organization_member_id") REFERENCES "organization_members"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "api_keys" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "key_hash" text NOT NULL, "prefix" text NOT NULL, "user_id" text NOT NULL, "last_used_at" integer, "expires_at" integer, "permissions" text DEFAULT '[]' NOT NULL, "all_organizations" integer DEFAULT false NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "auth_accounts" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text, "account_id" text NOT NULL, "provider_id" text NOT NULL, "access_token" text, "refresh_token" text, "access_token_expires_at" integer, "refresh_token_expires_at" integer, "scope" text, "id_token" text, "password" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "auth_sessions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "token" text NOT NULL, "user_id" text, "expires_at" integer NOT NULL, "ip_address" text, "user_agent" text, "active_organization_id" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("active_organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE set null );
|
||||
CREATE TABLE "auth_verifications" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, "expires_at" integer NOT NULL );
|
||||
CREATE TABLE "document_activity_log" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "document_id" text NOT NULL, "event" text NOT NULL, "event_data" text, "user_id" text, "tag_id" text, FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE set null );
|
||||
CREATE TABLE "documents" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "is_deleted" integer DEFAULT false NOT NULL, "deleted_at" integer, "organization_id" text NOT NULL, "created_by" text, "deleted_by" text, "original_name" text NOT NULL, "original_size" integer DEFAULT 0 NOT NULL, "original_storage_key" text NOT NULL, "original_sha256_hash" text NOT NULL, "name" text NOT NULL, "mime_type" text NOT NULL, "content" text DEFAULT '' NOT NULL, file_encryption_key_wrapped TEXT, file_encryption_kek_version TEXT, file_encryption_algorithm TEXT, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("deleted_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null );
|
||||
"CREATE UNIQUE INDEX "api_keys_key_hash_unique" on "api_keys" ("key_hash");
|
||||
CREATE INDEX "auth_sessions_token_index" on "auth_sessions" ("token");
|
||||
CREATE INDEX "auth_verifications_identifier_index" on "auth_verifications" ("identifier");
|
||||
CREATE INDEX "documents_file_encryption_kek_version_index" on "documents" ("file_encryption_kek_version");
|
||||
CREATE INDEX "documents_organization_id_is_deleted_created_at_index" on "documents" ("organization_id", "is_deleted", "created_at");
|
||||
CREATE INDEX "documents_organization_id_is_deleted_index" on "documents" ("organization_id", "is_deleted");
|
||||
CREATE UNIQUE INDEX "documents_organization_id_original_sha256_hash_unique" on "documents" ("organization_id", "original_sha256_hash");
|
||||
CREATE INDEX "documents_organization_id_size_index" on "documents" ("organization_id", "original_size");
|
||||
CREATE INDEX "documents_original_sha256_hash_index" on "documents" ("original_sha256_hash");
|
||||
CREATE UNIQUE INDEX "intake_emails_email_address_unique" on "intake_emails" ("email_address");
|
||||
CREATE INDEX "key_hash_index" on "api_keys" ("key_hash");
|
||||
CREATE INDEX "migrations_name_index" on "migrations" ("name");
|
||||
CREATE INDEX "migrations_run_at_index" on "migrations" ("run_at");
|
||||
CREATE UNIQUE INDEX "organization_invitations_organization_email_unique" on "organization_invitations" ("organization_id", "email");
|
||||
CREATE UNIQUE INDEX "organization_members_user_organization_unique" on "organization_members" ("organization_id", "user_id");
|
||||
CREATE INDEX "organizations_deleted_at_purge_at_index" on "organizations" ("deleted_at", "scheduled_purge_at");
|
||||
CREATE INDEX "organizations_deleted_by_deleted_at_index" on "organizations" ("deleted_by", "deleted_at");
|
||||
CREATE UNIQUE INDEX "tags_organization_id_name_unique" on "tags" ("organization_id", "name");
|
||||
CREATE INDEX "user_roles_role_index" on "user_roles" ("role");
|
||||
CREATE UNIQUE INDEX "user_roles_user_id_role_unique_index" on "user_roles" ("user_id", "role");
|
||||
CREATE INDEX "users_email_index" on "users" ("email");
|
||||
CREATE UNIQUE INDEX "users_email_unique" on "users" ("email");
|
||||
CREATE UNIQUE INDEX "webhook_events_webhook_id_event_name_unique" on "webhook_events" ("webhook_id", "event_name");
|
||||
CREATE TABLE "api_key_organizations" ("api_key_id" text not null references "api_keys" ("id") on delete cascade on update cascade, "organization_member_id" text not null references "organization_members" ("id") on delete cascade on update cascade);
|
||||
CREATE TABLE "api_keys" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "name" text not null, "key_hash" text not null, "prefix" text not null, "user_id" text not null references "users" ("id") on delete cascade on update cascade, "last_used_at" integer, "expires_at" integer, "permissions" text default '[]' not null, "all_organizations" integer default 0 not null);
|
||||
CREATE TABLE "auth_accounts" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "user_id" text references "users" ("id") on delete cascade on update cascade, "account_id" text not null, "provider_id" text not null, "access_token" text, "refresh_token" text, "access_token_expires_at" integer, "refresh_token_expires_at" integer, "scope" text, "id_token" text, "password" text);
|
||||
CREATE TABLE "auth_sessions" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "token" text not null, "user_id" text references "users" ("id") on delete cascade on update cascade, "expires_at" integer not null, "ip_address" text, "user_agent" text, "active_organization_id" text references "organizations" ("id") on delete set null on update cascade);
|
||||
CREATE TABLE "auth_verifications" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "identifier" text not null, "value" text not null, "expires_at" integer not null);
|
||||
CREATE TABLE "document_activity_log" ("id" text not null primary key, "created_at" integer not null, "document_id" text not null references "documents" ("id") on delete cascade on update cascade, "event" text not null, "event_data" text, "user_id" text references "users" ("id") on delete set null on update cascade, "tag_id" text references "tags" ("id") on delete set null on update cascade);
|
||||
CREATE TABLE "documents" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "is_deleted" integer default 0 not null, "deleted_at" integer, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "created_by" text references "users" ("id") on delete set null on update cascade, "deleted_by" text references "users" ("id") on delete set null on update cascade, "original_name" text not null, "original_size" integer default 0 not null, "original_storage_key" text not null, "original_sha256_hash" text not null, "name" text not null, "mime_type" text not null, "content" text default '' not null, "file_encryption_key_wrapped" text, "file_encryption_kek_version" text, "file_encryption_algorithm" text);
|
||||
CREATE VIRTUAL TABLE documents_fts USING fts5(id UNINDEXED, name, original_name, content, prefix='2 3 4');
|
||||
CREATE TABLE 'documents_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
CREATE TABLE 'documents_fts_content'(id INTEGER PRIMARY KEY, c0, c1, c2, c3);
|
||||
CREATE TABLE 'documents_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
||||
CREATE TABLE 'documents_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE 'documents_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE "documents_tags" ( "document_id" text NOT NULL, "tag_id" text NOT NULL, PRIMARY KEY("document_id", "tag_id"), FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "intake_emails" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email_address" text NOT NULL, "organization_id" text NOT NULL, "allowed_origins" text DEFAULT '[]' NOT NULL, "is_enabled" integer DEFAULT true NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, run_at INTEGER NOT NULL);
|
||||
CREATE TABLE "organization_invitations" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "email" text NOT NULL, "role" text NOT NULL, "status" text NOT NULL DEFAULT 'pending', "expires_at" integer NOT NULL, "inviter_id" text NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "organization_members" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "user_id" text NOT NULL, "role" text NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "organization_subscriptions" ( "id" text PRIMARY KEY NOT NULL, "customer_id" text NOT NULL, "organization_id" text NOT NULL, "plan_id" text NOT NULL, "status" text NOT NULL, "seats_count" integer NOT NULL, "current_period_end" integer NOT NULL, "current_period_start" integer NOT NULL, "cancel_at_period_end" integer DEFAULT false NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "organizations" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "customer_id" text , "deleted_by" text REFERENCES users(id), "deleted_at" integer, "scheduled_purge_at" integer);
|
||||
CREATE TABLE "documents_tags" ("document_id" text not null references "documents" ("id") on delete cascade on update cascade, "tag_id" text not null references "tags" ("id") on delete cascade on update cascade, constraint "documents_tags_pkey" primary key ("document_id", "tag_id"));
|
||||
CREATE TABLE "intake_emails" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "email_address" text not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "allowed_origins" text default '[]' not null, "is_enabled" integer default 1 not null);
|
||||
CREATE TABLE "migrations" ("id" integer primary key autoincrement, "name" text not null, "run_at" integer not null);
|
||||
CREATE TABLE "organization_invitations" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "email" text not null, "role" text not null, "status" text not null DEFAULT 'pending', "expires_at" integer not null, "inviter_id" text not null references "users" ("id") on delete cascade on update cascade);
|
||||
CREATE TABLE "organization_members" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "user_id" text not null references "users" ("id") on delete cascade on update cascade, "role" text not null);
|
||||
CREATE TABLE "organization_subscriptions" ("id" text not null primary key, "customer_id" text not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "plan_id" text not null, "status" text not null, "seats_count" integer not null, "current_period_end" integer not null, "current_period_start" integer not null, "cancel_at_period_end" integer default 0 not null, "created_at" integer not null, "updated_at" integer not null);
|
||||
CREATE TABLE "organizations" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "name" text not null, "customer_id" text, "deleted_by" text references "users" ("id") on delete set null on update cascade, "deleted_at" integer, "scheduled_purge_at" integer);
|
||||
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 "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 );
|
||||
CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
||||
CREATE TABLE "tagging_rule_actions" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "tagging_rule_id" text not null references "tagging_rules" ("id") on delete cascade on update cascade, "tag_id" text not null references "tags" ("id") on delete cascade on update cascade);
|
||||
CREATE TABLE "tagging_rule_conditions" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "tagging_rule_id" text not null references "tagging_rules" ("id") on delete cascade on update cascade, "field" text not null, "operator" text not null, "value" text not null, "is_case_sensitive" integer default 0 not null);
|
||||
CREATE TABLE "tagging_rules" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "name" text not null, "description" text, "enabled" integer default 1 not null, "condition_match_mode" text default 'all' not null);
|
||||
CREATE TABLE "tags" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "organization_id" text not null references "organizations" ("id") on delete cascade on update cascade, "name" text not null, "color" text not null, "description" text);
|
||||
CREATE TABLE "user_roles" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "user_id" text not null references "users" ("id") on delete cascade on update cascade, "role" text not null);
|
||||
CREATE TABLE "users" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "email" text not null, "email_verified" integer default 0 not null, "name" text, "image" text, "max_organization_count" integer);
|
||||
CREATE TABLE "webhook_deliveries" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "webhook_id" text not null references "webhooks" ("id") on delete cascade on update cascade, "event_name" text not null, "request_payload" text not null, "response_payload" text not null, "response_status" integer not null);
|
||||
CREATE TABLE "webhook_events" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "webhook_id" text not null references "webhooks" ("id") on delete cascade on update cascade, "event_name" text not null);
|
||||
CREATE TABLE "webhooks" ("id" text not null primary key, "created_at" integer not null, "updated_at" integer not null, "name" text not null, "url" text not null, "secret" text, "enabled" integer default 1 not null, "created_by" text references "users" ("id") on delete set null on update cascade, "organization_id" text references "organizations" ("id") on delete cascade on update cascade);
|
||||
CREATE TRIGGER trigger_documents_fts_delete AFTER DELETE ON documents BEGIN DELETE FROM documents_fts WHERE id = old.id; END;
|
||||
CREATE TRIGGER trigger_documents_fts_insert AFTER INSERT ON documents BEGIN INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content); END;
|
||||
CREATE TRIGGER trigger_documents_fts_update AFTER UPDATE ON documents BEGIN UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id; END;"
|
||||
@@ -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 db.executeQuery(sql`DROP TABLE migrations`.compile(db));
|
||||
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,29 +1,54 @@
|
||||
import type { Database } from '../modules/app/database/database.types';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { migrationsTable } from './migration.tables';
|
||||
import type { DatabaseClient } from '../modules/app/database/database.types';
|
||||
|
||||
export async function setupMigrationTableIfNotExists({ db }: { db: Database }) {
|
||||
await db.batch([
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, run_at INTEGER NOT NULL)`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS migrations_name_index ON migrations (name)`),
|
||||
db.run(sql`CREATE INDEX IF NOT EXISTS migrations_run_at_index ON migrations (run_at)`),
|
||||
]);
|
||||
export async function setupMigrationTableIfNotExists({ db }: { db: DatabaseClient }) {
|
||||
await db.schema
|
||||
.createTable('migrations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('run_at', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('migrations_name_index')
|
||||
.ifNotExists()
|
||||
.on('migrations')
|
||||
.columns(['name'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('migrations_run_at_index')
|
||||
.ifNotExists()
|
||||
.on('migrations')
|
||||
.columns(['run_at'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getMigrations({ db }: { db: Database }) {
|
||||
const migrations = await db.select().from(migrationsTable).orderBy(asc(migrationsTable.runAt));
|
||||
export async function getMigrations({ db }: { db: DatabaseClient }) {
|
||||
const dbMigrations = await db.selectFrom('migrations').selectAll().orderBy('run_at', 'asc').execute();
|
||||
|
||||
return { migrations };
|
||||
return {
|
||||
migrations: dbMigrations.map(migration => ({
|
||||
...migration,
|
||||
runAt: new Date(migration.run_at),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveMigration({ db, migrationName, now = new Date() }: { db: Database; migrationName: string; now?: Date }) {
|
||||
await db.insert(migrationsTable).values({ name: migrationName, runAt: now });
|
||||
export async function saveMigration({ db, migrationName, now = new Date() }: { db: DatabaseClient; migrationName: string; now?: Date }) {
|
||||
await db
|
||||
.insertInto('migrations')
|
||||
.values({
|
||||
name: migrationName,
|
||||
run_at: now.getTime(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteMigration({ db, migrationName }: { db: Database; migrationName: string }) {
|
||||
await db.delete(migrationsTable).where(eq(migrationsTable.name, migrationName));
|
||||
export async function deleteMigration({ db, migrationName }: { db: DatabaseClient; migrationName: string }) {
|
||||
await db.deleteFrom('migrations').where('name', '=', migrationName).execute();
|
||||
}
|
||||
|
||||
export async function deleteAllMigrations({ db }: { db: Database }) {
|
||||
await db.delete(migrationsTable);
|
||||
export async function deleteAllMigrations({ db }: { db: DatabaseClient }) {
|
||||
await db.deleteFrom('migrations').execute();
|
||||
}
|
||||
|
||||
18
apps/papra-server/src/migrations/migrations.tables.ts
Normal file
18
apps/papra-server/src/migrations/migrations.tables.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';
|
||||
|
||||
export type MigrationTable = {
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
run_at: ColumnType<number, number | undefined, never>;
|
||||
};
|
||||
|
||||
export type DbSelectableMigration = Selectable<MigrationTable>;
|
||||
export type DbInsertableMigration = Insertable<MigrationTable>;
|
||||
export type DbUpdatableMigration = Updateable<MigrationTable>;
|
||||
|
||||
export type InsertableMigration = Omit<DbInsertableMigration, 'id' | 'run_at'>;
|
||||
export type Migration = {
|
||||
id: number;
|
||||
name: string;
|
||||
runAt: Date;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Database } from '../modules/app/database/database.types';
|
||||
import type { DatabaseClient } from '../modules/app/database/database.types';
|
||||
|
||||
export type MigrationArguments = {
|
||||
db: Database;
|
||||
db: DatabaseClient;
|
||||
};
|
||||
|
||||
export type Migration = {
|
||||
|
||||
@@ -1,48 +1,78 @@
|
||||
import type { DatabaseClient } from '../modules/app/database/database.types';
|
||||
import type { Migration } from './migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { createNoopLogger } from '@crowlog/logger';
|
||||
import { sql } from 'kysely';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { setupDatabase } from '../modules/app/database/database';
|
||||
import { migrationsTable } from './migration.tables';
|
||||
import { rollbackLastAppliedMigration, runMigrations } from './migrations.usecases';
|
||||
|
||||
const createTableUserMigration: Migration = {
|
||||
name: 'create-table-user',
|
||||
up: async ({ db }) => {
|
||||
await db.run(sql`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`);
|
||||
await db.schema
|
||||
.createTable('users')
|
||||
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
down: async ({ db }) => {
|
||||
await db.run(sql`DROP TABLE users`);
|
||||
await db.schema
|
||||
.dropTable('users')
|
||||
.execute();
|
||||
},
|
||||
};
|
||||
|
||||
const createTableOrganizationMigration: Migration = {
|
||||
name: 'create-table-organization',
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`CREATE TABLE organizations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`),
|
||||
db.run(sql`CREATE TABLE organization_members (id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT NOT NULL, created_at INTEGER NOT NULL)`),
|
||||
]);
|
||||
await db.schema
|
||||
.createTable('organizations')
|
||||
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('organization_members')
|
||||
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
||||
.addColumn('organization_id', 'integer', col => col.notNull())
|
||||
.addColumn('user_id', 'integer', col => col.notNull())
|
||||
.addColumn('role', 'text', col => col.notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
down: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`DROP TABLE organizations`),
|
||||
db.run(sql`DROP TABLE organization_members`),
|
||||
]);
|
||||
await db.schema
|
||||
.dropTable('organization_members')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.dropTable('organizations')
|
||||
.execute();
|
||||
},
|
||||
};
|
||||
|
||||
const createTableDocumentMigration: Migration = {
|
||||
name: 'create-table-document',
|
||||
up: async ({ db }) => {
|
||||
await db.batch([
|
||||
db.run(sql`CREATE TABLE documents (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at INTEGER NOT NULL)`),
|
||||
]);
|
||||
await db.schema
|
||||
.createTable('documents')
|
||||
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
|
||||
.addColumn('name', 'text', col => col.notNull())
|
||||
.addColumn('created_at', 'integer', col => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
down: async ({ db }) => {
|
||||
await db.run(sql`DROP TABLE documents`);
|
||||
await db.schema
|
||||
.dropTable('documents')
|
||||
.execute();
|
||||
},
|
||||
};
|
||||
|
||||
async function getTablesNames({ db }: { db: DatabaseClient }) {
|
||||
const { rows: tables } = await db.executeQuery<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`.compile(db));
|
||||
return tables.map(({ name }) => name);
|
||||
}
|
||||
|
||||
describe('migrations usecases', () => {
|
||||
describe('runMigrations', () => {
|
||||
test('should run all migrations that are not already applied', async () => {
|
||||
@@ -50,9 +80,9 @@ describe('migrations usecases', () => {
|
||||
|
||||
const migrations = [createTableUserMigration, createTableOrganizationMigration];
|
||||
|
||||
await runMigrations({ db, migrations });
|
||||
await runMigrations({ db, migrations, logger: createNoopLogger() });
|
||||
|
||||
const migrationsInDb = await db.select().from(migrationsTable);
|
||||
const migrationsInDb = await db.selectFrom('migrations').selectAll().execute();
|
||||
|
||||
expect(migrationsInDb.map(({ id, name }) => ({ id, name }))).to.eql([
|
||||
{ id: 1, name: 'create-table-user' },
|
||||
@@ -61,9 +91,9 @@ describe('migrations usecases', () => {
|
||||
|
||||
migrations.push(createTableDocumentMigration);
|
||||
|
||||
await runMigrations({ db, migrations });
|
||||
await runMigrations({ db, migrations, logger: createNoopLogger() });
|
||||
|
||||
const migrationsInDb2 = await db.select().from(migrationsTable);
|
||||
const migrationsInDb2 = await db.selectFrom('migrations').selectAll().execute();
|
||||
|
||||
expect(migrationsInDb2.map(({ id, name }) => ({ id, name }))).to.eql([
|
||||
{ id: 1, name: 'create-table-user' },
|
||||
@@ -71,10 +101,8 @@ describe('migrations usecases', () => {
|
||||
{ id: 3, name: 'create-table-document' },
|
||||
]);
|
||||
|
||||
const { rows: tables } = await db.run(sql`SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'`);
|
||||
|
||||
// Ensure all tables and indexes are created
|
||||
expect(tables.map(t => t.name)).to.eql([
|
||||
expect(await getTablesNames({ db })).to.eql([
|
||||
'migrations',
|
||||
'migrations_name_index',
|
||||
'migrations_run_at_index',
|
||||
@@ -92,9 +120,9 @@ describe('migrations usecases', () => {
|
||||
|
||||
const migrations = [createTableUserMigration, createTableDocumentMigration];
|
||||
|
||||
await runMigrations({ db, migrations });
|
||||
await runMigrations({ db, migrations, logger: createNoopLogger() });
|
||||
|
||||
const initialMigrations = await db.select().from(migrationsTable);
|
||||
const initialMigrations = await db.selectFrom('migrations').selectAll().execute();
|
||||
|
||||
expect(initialMigrations.map(({ id, name }) => ({ id, name }))).to.eql([
|
||||
{ id: 1, name: 'create-table-user' },
|
||||
@@ -102,20 +130,22 @@ describe('migrations usecases', () => {
|
||||
]);
|
||||
|
||||
// Ensure the tables exists, no error is thrown
|
||||
await db.run(sql`SELECT * FROM users`);
|
||||
await db.run(sql`SELECT * FROM documents`);
|
||||
await db.selectFrom('users').selectAll().execute();
|
||||
await db.selectFrom('documents').selectAll().execute();
|
||||
|
||||
await rollbackLastAppliedMigration({ db, migrations });
|
||||
|
||||
const migrationsInDb = await db.select().from(migrationsTable);
|
||||
const migrationsInDb = await db.selectFrom('migrations').selectAll().execute();
|
||||
|
||||
expect(migrationsInDb.map(({ id, name }) => ({ id, name }))).to.eql([
|
||||
{ id: 1, name: 'create-table-user' },
|
||||
]);
|
||||
|
||||
// Ensure the table document is dropped
|
||||
await db.run(sql`SELECT * FROM users`);
|
||||
await expect(db.run(sql`SELECT * FROM documents`)).rejects.toThrow();
|
||||
await db.selectFrom('users').selectAll().execute();
|
||||
await expect(
|
||||
db.selectFrom('documents').selectAll().execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('when their is no migration to rollback, nothing is done', async () => {
|
||||
@@ -123,7 +153,7 @@ describe('migrations usecases', () => {
|
||||
|
||||
await rollbackLastAppliedMigration({ db });
|
||||
|
||||
const migrationsInDb = await db.select().from(migrationsTable);
|
||||
const migrationsInDb = await db.selectFrom('migrations').selectAll().execute();
|
||||
|
||||
expect(migrationsInDb).to.eql([]);
|
||||
});
|
||||
@@ -131,7 +161,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: [] }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Database } from '../modules/app/database/database.types';
|
||||
import type { DatabaseClient } from '../modules/app/database/database.types';
|
||||
import type { Logger } from '../modules/shared/logger/logger';
|
||||
import type { Migration } from './migrations.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
@@ -6,7 +6,7 @@ import { createLogger } from '../modules/shared/logger/logger';
|
||||
import { migrations as migrationsList } from './migrations.registry';
|
||||
import { deleteMigration, getMigrations, saveMigration, setupMigrationTableIfNotExists } from './migrations.repository';
|
||||
|
||||
export async function runMigrations({ db, migrations = migrationsList, logger = createLogger({ namespace: 'migrations' }) }: { db: Database; migrations?: Migration[]; logger?: Logger }) {
|
||||
export async function runMigrations({ db, migrations = migrationsList, logger = createLogger({ namespace: 'migrations' }) }: { db: DatabaseClient; migrations?: Migration[]; logger?: Logger }) {
|
||||
await setupMigrationTableIfNotExists({ db });
|
||||
|
||||
if (migrations.length === 0) {
|
||||
@@ -45,14 +45,14 @@ export async function runMigrations({ db, migrations = migrationsList, logger =
|
||||
logger.info('All migrations run successfully');
|
||||
}
|
||||
|
||||
async function upMigration({ db, migration }: { db: Database; migration: Migration }) {
|
||||
async function upMigration({ db, migration }: { db: DatabaseClient; migration: Migration }) {
|
||||
const { name, up } = migration;
|
||||
|
||||
await up({ db });
|
||||
await saveMigration({ db, migrationName: name });
|
||||
}
|
||||
|
||||
export async function rollbackLastAppliedMigration({ db, migrations = migrationsList, logger = createLogger({ namespace: 'migrations' }) }: { db: Database; migrations?: Migration[]; logger?: Logger }) {
|
||||
export async function rollbackLastAppliedMigration({ db, migrations = migrationsList, logger = createLogger({ namespace: 'migrations' }) }: { db: DatabaseClient; migrations?: Migration[]; logger?: Logger }) {
|
||||
await setupMigrationTableIfNotExists({ db });
|
||||
|
||||
const { migrations: existingMigrations } = await getMigrations({ db });
|
||||
@@ -75,7 +75,7 @@ export async function rollbackLastAppliedMigration({ db, migrations = migrations
|
||||
logger.info({ migrationName: lastMigration.name }, 'Migration rolled back successfully');
|
||||
}
|
||||
|
||||
async function downMigration({ db, migration }: { db: Database; migration: Migration }) {
|
||||
async function downMigration({ db, migration }: { db: DatabaseClient; migration: Migration }) {
|
||||
const { name, down } = migration;
|
||||
|
||||
await down?.({ db });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { DatabaseClient } from '../app/database/database.types';
|
||||
import type { Context } from '../app/server.types';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||
@@ -10,7 +10,7 @@ import { getApiKey } from './api-keys.usecases';
|
||||
|
||||
// The role of this middleware is to extract the api key from the authorization header if present
|
||||
// and set it on the context, no auth enforcement is done here
|
||||
export function createApiKeyMiddleware({ db }: { db: Database }) {
|
||||
export function createApiKeyMiddleware({ db }: { db: DatabaseClient }) {
|
||||
const apiKeyRepository = createApiKeysRepository({ db });
|
||||
|
||||
return createMiddleware(async (context: Context, next) => {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyOrganization,
|
||||
DbInsertableApiKey,
|
||||
DbInsertableApiKeyOrganization,
|
||||
DbSelectableApiKey,
|
||||
DbSelectableApiKeyOrganization,
|
||||
InsertableApiKey,
|
||||
InsertableApiKeyOrganization,
|
||||
} from './api-keys.new.tables';
|
||||
import { sha256 } from '../shared/crypto/hash';
|
||||
import { generateId } from '../shared/random/ids';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { API_KEY_PREFIX, API_KEY_TOKEN_REGEX } from './api-keys.constants';
|
||||
import { API_KEY_ID_PREFIX, API_KEY_PREFIX, API_KEY_TOKEN_REGEX } from './api-keys.constants';
|
||||
import { apiPermissionsSchema } from './api-keys.schemas';
|
||||
|
||||
const generateApiKeyId = () => generateId({ prefix: API_KEY_ID_PREFIX });
|
||||
|
||||
export function getApiKeyUiPrefix({ token }: { token: string }) {
|
||||
return {
|
||||
@@ -22,3 +36,72 @@ export function looksLikeAnApiKey(token?: string | null | undefined): token is s
|
||||
|
||||
return API_KEY_TOKEN_REGEX.test(token);
|
||||
}
|
||||
|
||||
// DB <-> Business model transformers
|
||||
|
||||
export function dbToApiKey(dbApiKey: Omit<DbSelectableApiKey, 'key_hash'>): Omit<ApiKey, 'keyHash'>;
|
||||
export function dbToApiKey(dbApiKey: DbSelectableApiKey): ApiKey;
|
||||
export function dbToApiKey(dbApiKey?: DbSelectableApiKey | Omit<DbSelectableApiKey, 'key_hash'>): ApiKey | undefined | Omit<ApiKey, 'keyHash'> {
|
||||
if (!dbApiKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbApiKey.id,
|
||||
name: dbApiKey.name,
|
||||
...('key_hash' in dbApiKey ? { keyHash: dbApiKey.key_hash } : {}),
|
||||
prefix: dbApiKey.prefix,
|
||||
userId: dbApiKey.user_id,
|
||||
permissions: apiPermissionsSchema.parse(dbApiKey.permissions),
|
||||
allOrganizations: dbApiKey.all_organizations === 1,
|
||||
createdAt: new Date(dbApiKey.created_at),
|
||||
updatedAt: new Date(dbApiKey.updated_at),
|
||||
lastUsedAt: isNil(dbApiKey.last_used_at) ? null : new Date(dbApiKey.last_used_at),
|
||||
expiresAt: isNil(dbApiKey.expires_at) ? null : new Date(dbApiKey.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function apiKeyToDb(
|
||||
apiKey: InsertableApiKey,
|
||||
{
|
||||
now = new Date(),
|
||||
generateId = generateApiKeyId,
|
||||
}: {
|
||||
now?: Date;
|
||||
generateId?: () => string;
|
||||
} = {},
|
||||
): DbInsertableApiKey {
|
||||
return {
|
||||
id: apiKey.id ?? generateId(),
|
||||
name: apiKey.name,
|
||||
key_hash: apiKey.keyHash,
|
||||
prefix: apiKey.prefix,
|
||||
user_id: apiKey.userId,
|
||||
permissions: JSON.stringify(apiKey.permissions ?? []),
|
||||
all_organizations: apiKey.allOrganizations === true ? 1 : 0,
|
||||
created_at: apiKey.createdAt?.getTime() ?? now.getTime(),
|
||||
updated_at: apiKey.updatedAt?.getTime() ?? now.getTime(),
|
||||
last_used_at: apiKey.lastUsedAt?.getTime(),
|
||||
expires_at: apiKey.expiresAt?.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
// API Key Organizations junction table transformers
|
||||
|
||||
export function dbToApiKeyOrganization(dbApiKeyOrg?: DbSelectableApiKeyOrganization): ApiKeyOrganization | undefined {
|
||||
if (!dbApiKeyOrg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKeyId: dbApiKeyOrg.api_key_id,
|
||||
organizationMemberId: dbApiKeyOrg.organization_member_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function apiKeyOrganizationToDb(apiKeyOrg: InsertableApiKeyOrganization): DbInsertableApiKeyOrganization {
|
||||
return {
|
||||
api_key_id: apiKeyOrg.apiKeyId,
|
||||
organization_member_id: apiKeyOrg.organizationMemberId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Expand } from '@corentinth/chisels';
|
||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import type { BusinessInsertable, CamelCaseKeys, TableWithIdAndTimestamps } from '../app/database/database.columns.types';
|
||||
import type { ApiKeyPermissions } from './api-keys.types';
|
||||
|
||||
// --- API Keys
|
||||
|
||||
export type ApiKeysTable = TableWithIdAndTimestamps<{
|
||||
name: string;
|
||||
key_hash: string;
|
||||
prefix: string;
|
||||
user_id: string;
|
||||
last_used_at: number | null;
|
||||
expires_at: number | null;
|
||||
permissions: string;
|
||||
all_organizations: number;
|
||||
}>;
|
||||
|
||||
export type DbSelectableApiKey = Selectable<ApiKeysTable>;
|
||||
export type DbInsertableApiKey = Insertable<ApiKeysTable>;
|
||||
export type DbUpdateableApiKey = Updateable<ApiKeysTable>;
|
||||
|
||||
export type InsertableApiKey = BusinessInsertable<DbInsertableApiKey, {
|
||||
permissions?: ApiKeyPermissions[];
|
||||
allOrganizations?: boolean;
|
||||
lastUsedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
}>;
|
||||
|
||||
export type ApiKey = Expand<CamelCaseKeys<Omit<DbSelectableApiKey, 'created_at' | 'updated_at' | 'permissions' | 'all_organizations' | 'last_used_at' | 'expires_at'> & {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions: ApiKeyPermissions[];
|
||||
allOrganizations: boolean;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}>>;
|
||||
|
||||
// --- API Key Organizations (Junction Table)
|
||||
|
||||
export type ApiKeyOrganizationsTable = {
|
||||
api_key_id: string;
|
||||
organization_member_id: string;
|
||||
};
|
||||
|
||||
export type DbSelectableApiKeyOrganization = Selectable<ApiKeyOrganizationsTable>;
|
||||
export type DbInsertableApiKeyOrganization = Insertable<ApiKeyOrganizationsTable>;
|
||||
export type DbUpdateableApiKeyOrganization = Updateable<ApiKeyOrganizationsTable>;
|
||||
|
||||
export type InsertableApiKeyOrganization = Expand<CamelCaseKeys<DbInsertableApiKeyOrganization>>;
|
||||
export type ApiKeyOrganization = Expand<CamelCaseKeys<DbSelectableApiKeyOrganization>>;
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { DatabaseClient } from '../app/database/database.types';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import type { ApiKeyPermissions } from './api-keys.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { and, eq, getTableColumns, inArray } from 'drizzle-orm';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { organizationMembersTable, organizationsTable } from '../organizations/organizations.table';
|
||||
import { pick } from 'lodash-es';
|
||||
import { dbToOrganizationMember } from '../organizations/organizations.models';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { apiKeyOrganizationsTable, apiKeysTable } from './api-keys.tables';
|
||||
import { apiKeyOrganizationToDb, apiKeyToDb, dbToApiKey } from './api-keys.models';
|
||||
|
||||
export type ApiKeysRepository = ReturnType<typeof createApiKeysRepository>;
|
||||
|
||||
export function createApiKeysRepository({ db, logger = createLogger({ namespace: 'api-keys.repository' }) }: { db: Database; logger?: Logger }) {
|
||||
export function createApiKeysRepository({ db, logger = createLogger({ namespace: 'api-keys.repository' }) }: { db: DatabaseClient; logger?: Logger }) {
|
||||
return injectArguments(
|
||||
{
|
||||
saveApiKey,
|
||||
@@ -35,7 +34,7 @@ async function saveApiKey({
|
||||
organizationIds,
|
||||
expiresAt,
|
||||
}: {
|
||||
db: Database;
|
||||
db: DatabaseClient;
|
||||
logger: Logger;
|
||||
name: string;
|
||||
keyHash: string;
|
||||
@@ -46,9 +45,9 @@ async function saveApiKey({
|
||||
expiresAt?: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
const [apiKey] = await db
|
||||
.insert(apiKeysTable)
|
||||
.values({
|
||||
const dbApiKey = await db
|
||||
.insertInto('api_keys')
|
||||
.values(apiKeyToDb({
|
||||
name,
|
||||
keyHash,
|
||||
prefix,
|
||||
@@ -56,10 +55,11 @@ async function saveApiKey({
|
||||
allOrganizations,
|
||||
userId,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
}))
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!apiKey) {
|
||||
if (!dbApiKey) {
|
||||
// Very unlikely to happen as the insertion should throw an issue, it's for type safety
|
||||
throw createError({
|
||||
message: 'Error while creating api key',
|
||||
@@ -69,18 +69,19 @@ async function saveApiKey({
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = dbToApiKey(dbApiKey);
|
||||
|
||||
if (organizationIds && organizationIds.length > 0) {
|
||||
const apiKeyId = apiKey.id;
|
||||
|
||||
const organizationMembers = await db
|
||||
.select()
|
||||
.from(organizationMembersTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(organizationMembersTable.organizationId, organizationIds),
|
||||
eq(organizationMembersTable.userId, userId),
|
||||
),
|
||||
);
|
||||
const dbOrganizationMembers = await db
|
||||
.selectFrom('organization_members')
|
||||
.where('organization_id', 'in', organizationIds)
|
||||
.where('user_id', '=', userId)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
const organizationMembers = dbOrganizationMembers.map(dbOm => (dbToOrganizationMember(dbOm)));
|
||||
|
||||
if (!organizationIds.every(id => organizationMembers.some(om => om.organizationId === id))) {
|
||||
logger.warn({
|
||||
@@ -91,41 +92,44 @@ async function saveApiKey({
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(apiKeyOrganizationsTable)
|
||||
.insertInto('api_key_organizations')
|
||||
.values(
|
||||
organizationMembers.map(({ id: organizationMemberId }) => ({ apiKeyId, organizationMemberId })),
|
||||
);
|
||||
organizationMembers.map(({ id: organizationMemberId }) => apiKeyOrganizationToDb({ apiKeyId, organizationMemberId })),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
async function getUserApiKeys({ userId, db }: { userId: string; db: Database }) {
|
||||
const apiKeys = await db
|
||||
.select({
|
||||
...omit(getTableColumns(apiKeysTable), 'keyHash'),
|
||||
})
|
||||
.from(apiKeysTable)
|
||||
.where(
|
||||
eq(apiKeysTable.userId, userId),
|
||||
);
|
||||
async function getUserApiKeys({ userId, db }: { userId: string; db: DatabaseClient }) {
|
||||
const dbApiKeys = await db
|
||||
.selectFrom('api_keys')
|
||||
.where('user_id', '=', userId)
|
||||
.select(['api_keys.id', 'api_keys.user_id', 'api_keys.name', 'api_keys.prefix', 'api_keys.last_used_at', 'api_keys.expires_at', 'api_keys.permissions', 'api_keys.all_organizations', 'api_keys.created_at', 'api_keys.updated_at'])
|
||||
.execute()
|
||||
.then(rows => rows.map(row => dbToApiKey(row)));
|
||||
|
||||
const relatedOrganizations = await db
|
||||
.select({
|
||||
...getTableColumns(organizationsTable),
|
||||
apiKeyId: apiKeyOrganizationsTable.apiKeyId,
|
||||
})
|
||||
.from(apiKeyOrganizationsTable)
|
||||
.leftJoin(organizationMembersTable, eq(apiKeyOrganizationsTable.organizationMemberId, organizationMembersTable.id))
|
||||
.leftJoin(organizationsTable, eq(organizationMembersTable.organizationId, organizationsTable.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(apiKeyOrganizationsTable.apiKeyId, apiKeys.map(apiKey => apiKey.id)),
|
||||
eq(organizationMembersTable.userId, userId),
|
||||
),
|
||||
);
|
||||
.selectFrom('api_key_organizations')
|
||||
.innerJoin('organization_members', 'api_key_organizations.organization_member_id', 'organization_members.id')
|
||||
.innerJoin('organizations', 'organization_members.organization_id', 'organizations.id')
|
||||
.where('api_key_organizations.api_key_id', 'in', dbApiKeys.map(apiKey => apiKey.id))
|
||||
.where('organization_members.user_id', '=', userId)
|
||||
.select([
|
||||
'organizations.id',
|
||||
'organizations.name',
|
||||
'organizations.customer_id',
|
||||
'organizations.deleted_at',
|
||||
'organizations.deleted_by',
|
||||
'organizations.scheduled_purge_at',
|
||||
'organizations.created_at',
|
||||
'organizations.updated_at',
|
||||
'api_key_organizations.api_key_id as apiKeyId',
|
||||
])
|
||||
.execute();
|
||||
|
||||
const apiKeysWithOrganizations = apiKeys.map(apiKey => ({
|
||||
const apiKeysWithOrganizations = dbApiKeys.map(apiKey => ({
|
||||
...apiKey,
|
||||
organizations: relatedOrganizations
|
||||
.filter(organization => organization.apiKeyId === apiKey.id)
|
||||
@@ -137,24 +141,22 @@ async function getUserApiKeys({ userId, db }: { userId: string; db: Database })
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteUserApiKey({ apiKeyId, userId, db }: { apiKeyId: string; userId: string; db: Database }) {
|
||||
async function deleteUserApiKey({ apiKeyId, userId, db }: { apiKeyId: string; userId: string; db: DatabaseClient }) {
|
||||
await db
|
||||
.delete(apiKeysTable)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeysTable.id, apiKeyId),
|
||||
eq(apiKeysTable.userId, userId),
|
||||
),
|
||||
);
|
||||
.deleteFrom('api_keys')
|
||||
.where('id', '=', apiKeyId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function getApiKeyByHash({ keyHash, db }: { keyHash: string; db: Database }) {
|
||||
const [apiKey] = await db
|
||||
.select()
|
||||
.from(apiKeysTable)
|
||||
.where(
|
||||
eq(apiKeysTable.keyHash, keyHash),
|
||||
);
|
||||
async function getApiKeyByHash({ keyHash, db }: { keyHash: string; db: DatabaseClient }) {
|
||||
const dbApiKey = await db
|
||||
.selectFrom('api_keys')
|
||||
.where('key_hash', '=', keyHash)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
const apiKey = dbApiKey ? dbToApiKey(dbApiKey) : undefined;
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ApiKeyPermissions } from './api-keys.types';
|
||||
import { z } from 'zod';
|
||||
import { API_KEY_ID_REGEX } from './api-keys.constants';
|
||||
import { API_KEY_ID_REGEX, API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
||||
|
||||
export const apiKeyIdSchema = z.string().regex(API_KEY_ID_REGEX);
|
||||
|
||||
export const apiPermissionsSchema = z.array(z.enum(API_KEY_PERMISSIONS_VALUES as [ApiKeyPermissions, ...ApiKeyPermissions[]]));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user