mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 03:52:20 -06:00
Compare commits
30 Commits
@papra/app
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bfdb8aa66 | ||
|
|
2e2bb6fbbd | ||
|
|
d09b9ed70d | ||
|
|
e1571d2b87 | ||
|
|
c9a66e4aa8 | ||
|
|
9fa2df4235 | ||
|
|
c84a921988 | ||
|
|
9b5f3993c3 | ||
|
|
b28772317c | ||
|
|
a3f9f05c66 | ||
|
|
0616635cd6 | ||
|
|
9e7a3ba70b | ||
|
|
04990b986e | ||
|
|
097b6bf2b7 | ||
|
|
cb3ce6b1d8 | ||
|
|
405ba645f6 | ||
|
|
ab6fd6ad10 | ||
|
|
782f70ff66 | ||
|
|
1abbf18e94 | ||
|
|
6bcb2a71e9 | ||
|
|
936bc2bd0a | ||
|
|
2efe7321cd | ||
|
|
947bdf8385 | ||
|
|
b5bf0cca4b | ||
|
|
208a561668 | ||
|
|
40cb1d71d5 | ||
|
|
3da13f7591 | ||
|
|
2a444aad31 | ||
|
|
47d8bbd356 | ||
|
|
ed4d7e4a00 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ cache
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
|
||||
@@ -105,6 +105,73 @@ We recommend running the app locally for development. Follow these steps:
|
||||
|
||||
6. Open your browser and navigate to `http://localhost:3000`.
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### ESLint Extension
|
||||
|
||||
We recommend installing the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VS Code to get real-time linting feedback and automatic code fixing.
|
||||
The linting configuration is based on [@antfu/eslint-config](https://github.com/antfu/eslint-config), you can find specific IDE configurations in their repository.
|
||||
|
||||
<details>
|
||||
<summary>Recommended VS Code Settings</summary>
|
||||
|
||||
Create or update your `.vscode/settings.json` file with the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Testing
|
||||
|
||||
We use **Vitest** for testing. Each package comes with its own testing commands.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^2.2.2",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { isArray, isEmpty, isNil } from 'lodash-es';
|
||||
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
@@ -46,16 +46,21 @@ const rows = configDetails
|
||||
};
|
||||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
|
||||
### ${env}
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
|
||||
const envs = castArray(env);
|
||||
const [firstEnv, ...restEnvs] = envs;
|
||||
|
||||
return `
|
||||
### ${firstEnv}
|
||||
${documentation}
|
||||
|
||||
- Path: \`${path.join('.')}\`
|
||||
- Environment variable: \`${env}\`
|
||||
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''}
|
||||
- Default value: \`${defaultValue}\`
|
||||
|
||||
|
||||
`.trim()).join('\n\n---\n\n');
|
||||
`.trim();
|
||||
}).join('\n\n---\n\n');
|
||||
|
||||
function wrapText(text: string, maxLength = 75) {
|
||||
const words = text.split(' ');
|
||||
@@ -80,10 +85,12 @@ function wrapText(text: string, maxLength = 75) {
|
||||
|
||||
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const envs = castArray(env);
|
||||
const [firstEnv] = envs;
|
||||
|
||||
return [
|
||||
...wrapText(documentation),
|
||||
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
|
||||
|
||||
3. **Configure your Papra instance**
|
||||
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
|
||||
|
||||
```bash
|
||||
# Enable intake emails
|
||||
|
||||
@@ -18,8 +18,107 @@ The public API uses a bearer token for authentication. You can get a token by lo
|
||||
</details>
|
||||
|
||||
|
||||
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Using cURL:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations
|
||||
```
|
||||
|
||||
**Using JavaScript (fetch):**
|
||||
```javascript
|
||||
const response = await fetch('https://api.papra.app/api/organizations', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### API Key Permissions
|
||||
|
||||
When creating an API key, you can select from the following permissions:
|
||||
|
||||
**Organizations:**
|
||||
- `organizations:create` - Create new organizations
|
||||
- `organizations:read` - Read organization information and list organizations of the user
|
||||
- `organizations:update` - Update organization details
|
||||
- `organizations:delete` - Delete organizations
|
||||
|
||||
**Documents:**
|
||||
- `documents:create` - Upload and create new documents
|
||||
- `documents:read` - Read and download documents
|
||||
- `documents:update` - Update document metadata and content
|
||||
- `documents:delete` - Delete documents
|
||||
|
||||
**Tags:**
|
||||
- `tags:create` - Create new tags
|
||||
- `tags:read` - Read tag information and list tags
|
||||
- `tags:update` - Update tag details
|
||||
- `tags:delete` - Delete tags
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List organizations
|
||||
|
||||
**GET** `/api/organizations`
|
||||
|
||||
List all organizations accessible to the authenticated user.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organizations`: The list of organizations.
|
||||
|
||||
### Create an organization
|
||||
|
||||
**POST** `/api/organizations`
|
||||
|
||||
Create a new organization.
|
||||
|
||||
- Required API key permissions: `organizations:create`
|
||||
- Body (JSON)
|
||||
- `name`: The organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The created organization.
|
||||
|
||||
### Get an organization
|
||||
|
||||
**GET** `/api/organizations/:organizationId`
|
||||
|
||||
Get an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organization`: The organization.
|
||||
|
||||
### Update an organization
|
||||
|
||||
**PUT** `/api/organizations/:organizationId`
|
||||
|
||||
Update an organization's name.
|
||||
|
||||
- Required API key permissions: `organizations:update`
|
||||
- Body (JSON)
|
||||
- `name`: The new organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The updated organization.
|
||||
|
||||
### Delete an organization
|
||||
|
||||
**DELETE** `/api/organizations/:organizationId`
|
||||
|
||||
Delete an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:delete`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Create a document
|
||||
|
||||
**POST** `/api/organizations/:organizationId/documents`
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.9.6
|
||||
|
||||
## 0.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#501](https://github.com/papra-hq/papra/pull/501) [`b5bf0cc`](https://github.com/papra-hq/papra/commit/b5bf0cca4b571495329cb553da06e0d334ee8968) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix an issue preventing to disable the max upload size
|
||||
|
||||
- [#498](https://github.com/papra-hq/papra/pull/498) [`3da13f7`](https://github.com/papra-hq/papra/commit/3da13f759155df5d7c532160a7ea582385db63b6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed the "open in new tab" button for security improvement (xss prevention)
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.6",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
@@ -21,12 +21,10 @@
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "pnpm check-i18n-types-outdated && vitest run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check-i18n-types-outdated": "pnpm script:generate-i18n-types && git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo \"Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes.\" && exit 1)",
|
||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
|
||||
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Organisation löschen',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Mitglieder',
|
||||
'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Alle auswählen',
|
||||
'api-keys.permissions.deselect-all': 'Alle abwählen',
|
||||
'api-keys.permissions.organizations.title': 'Organisationen',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Organisationen erstellen',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Organisationen lesen',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Organisationen aktualisieren',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Organisationen löschen',
|
||||
'api-keys.permissions.documents.title': 'Dokumente',
|
||||
'api-keys.permissions.documents.documents:create': 'Dokumente erstellen',
|
||||
'api-keys.permissions.documents.documents:read': 'Dokumente lesen',
|
||||
@@ -541,7 +549,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
|
||||
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
|
||||
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',
|
||||
|
||||
@@ -141,6 +141,7 @@ export const translations = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Delete organization',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Members',
|
||||
'organizations.members.description': 'Manage your organization members',
|
||||
@@ -415,6 +416,13 @@ export const translations = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Select all',
|
||||
'api-keys.permissions.deselect-all': 'Deselect all',
|
||||
'api-keys.permissions.organizations.title': 'Organizations',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Create organizations',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Read organizations',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Update organizations',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Delete organizations',
|
||||
'api-keys.permissions.documents.title': 'Documents',
|
||||
'api-keys.permissions.documents.documents:create': 'Create documents',
|
||||
'api-keys.permissions.documents.documents:read': 'Read documents',
|
||||
@@ -539,6 +547,7 @@ export const translations = {
|
||||
|
||||
'api-errors.document.already_exists': 'The document already exists',
|
||||
'api-errors.document.size_too_large': 'The file size is too large',
|
||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||
'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
|
||||
'api-errors.default': 'An error occurred while processing your request.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Eliminar organización',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Miembros',
|
||||
'organizations.members.description': 'Administra los miembros de tu organización',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Seleccionar todo',
|
||||
'api-keys.permissions.deselect-all': 'Deseleccionar todo',
|
||||
'api-keys.permissions.organizations.title': 'Organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Crear organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Leer organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Actualizar organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizaciones',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Crear documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Leer documentos',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'El documento ya existe',
|
||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||
'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
|
||||
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
|
||||
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Membres',
|
||||
'organizations.members.description': 'Gérez les membres de votre organisation.',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Tout sélectionner',
|
||||
'api-keys.permissions.deselect-all': 'Tout désélectionner',
|
||||
'api-keys.permissions.organizations.title': 'Organisations',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Créer des organisations',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Lire des organisations',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Mettre à jour des organisations',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Supprimer des organisations',
|
||||
'api-keys.permissions.documents.title': 'Documents',
|
||||
'api-keys.permissions.documents.documents:create': 'Créer des documents',
|
||||
'api-keys.permissions.documents.documents:read': 'Lire des documents',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||
'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
|
||||
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
|
||||
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Membri',
|
||||
'organizations.members.description': 'Gestisci i membri della tua organizzazione',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Seleziona tutto',
|
||||
'api-keys.permissions.deselect-all': 'Deseleziona tutto',
|
||||
'api-keys.permissions.organizations.title': 'Organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Crea organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Leggi organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Aggiorna organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Elimina organizzazioni',
|
||||
'api-keys.permissions.documents.title': 'Documenti',
|
||||
'api-keys.permissions.documents.documents:create': 'Crea documenti',
|
||||
'api-keys.permissions.documents.documents:read': 'Leggi documenti',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||
'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
|
||||
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
|
||||
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Usuń organizację',
|
||||
'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ę.',
|
||||
|
||||
'organizations.members.title': 'Członkowie',
|
||||
'organizations.members.description': 'Zarządzaj członkami swojej organizacji',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Zaznacz wszystko',
|
||||
'api-keys.permissions.deselect-all': 'Odznacz wszystko',
|
||||
'api-keys.permissions.organizations.title': 'Organizacje',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Tworzenie organizacji',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Odczyt organizacji',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Aktualizacja organizacji',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Usuwanie organizacji',
|
||||
'api-keys.permissions.documents.title': 'Dokumenty',
|
||||
'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów',
|
||||
'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||
'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
|
||||
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
|
||||
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Excluir organização',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Membros',
|
||||
'organizations.members.description': 'Gerencie os membros da sua organização',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selecionar tudo',
|
||||
'api-keys.permissions.deselect-all': 'Desmarcar tudo',
|
||||
'api-keys.permissions.organizations.title': 'Organizações',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Excluir organizações',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Criar documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Ler documentos',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Eliminar organização',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Membros',
|
||||
'organizations.members.description': 'Gira os membros da sua organização',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selecionar tudo',
|
||||
'api-keys.permissions.deselect-all': 'Desselecionar tudo',
|
||||
'api-keys.permissions.organizations.title': 'Organizações',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizações',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Criar documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Ler documentos',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.confirm-button': 'Șterge organizație',
|
||||
'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.',
|
||||
|
||||
'organizations.members.title': 'Membri',
|
||||
'organizations.members.description': 'Gestionează membrii organizației tale',
|
||||
@@ -417,6 +418,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selectează tot',
|
||||
'api-keys.permissions.deselect-all': 'Deselectează tot',
|
||||
'api-keys.permissions.organizations.title': 'Organizații',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Creează organizații',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Citește organizații',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Actualizează organizații',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Șterge organizații',
|
||||
'api-keys.permissions.documents.title': 'Documente',
|
||||
'api-keys.permissions.documents.documents:create': 'Creează documente',
|
||||
'api-keys.permissions.documents.documents:read': 'Citește documente',
|
||||
@@ -541,6 +549,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Documentul există deja',
|
||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||
'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
|
||||
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
|
||||
'api-errors.default': 'A apărut o eroare la procesarea cererii.',
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
// } as const;
|
||||
|
||||
export const API_KEY_PERMISSIONS = [
|
||||
{
|
||||
section: 'organizations',
|
||||
permissions: [
|
||||
'organizations:create',
|
||||
'organizations:read',
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'documents',
|
||||
permissions: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
|
||||
|
||||
@@ -42,34 +43,78 @@ export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChang
|
||||
props.onChange(permissions());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<For each={getPermissionsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">{section.title}</p>
|
||||
const toggleSection = (sectionName: typeof API_KEY_PERMISSIONS[number]['section']) => {
|
||||
const section = API_KEY_PERMISSIONS.find(s => s.section === sectionName);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="pl-4 flex flex-col gap-4 mt-4">
|
||||
<For each={section.permissions}>
|
||||
{permission => (
|
||||
<Checkbox
|
||||
class="flex items-center gap-2"
|
||||
checked={isPermissionSelected(permission.name)}
|
||||
onChange={() => togglePermission(permission.name)}
|
||||
>
|
||||
<CheckboxControl />
|
||||
<div class="flex flex-col gap-1">
|
||||
<CheckboxLabel class="text-sm leading-none">
|
||||
{permission.description}
|
||||
</CheckboxLabel>
|
||||
</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
const sectionPermissions: ReadonlyArray<string> = section.permissions;
|
||||
const currentPermissions = permissions();
|
||||
|
||||
const allSelected = sectionPermissions.every(p => currentPermissions.includes(p));
|
||||
|
||||
setPermissions((prev) => {
|
||||
if (allSelected) {
|
||||
return [...prev.filter(p => !sectionPermissions.includes(p))];
|
||||
}
|
||||
|
||||
return [...new Set([...prev, ...sectionPermissions])];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<For each={getPermissionsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
<Button variant="link" class="text-muted-foreground text-xs p-0 h-auto hover:no-underline" onClick={() => toggleSection(section.section)}>{section.title}</Button>
|
||||
|
||||
<div class="pl-4 flex flex-col mt-2">
|
||||
<For each={section.permissions}>
|
||||
{permission => (
|
||||
<Checkbox
|
||||
class="flex items-center gap-2"
|
||||
checked={isPermissionSelected(permission.name)}
|
||||
onChange={() => togglePermission(permission.name)}
|
||||
>
|
||||
<CheckboxControl />
|
||||
<div class="flex flex-col gap-1">
|
||||
<CheckboxLabel class="text-sm leading-none py-1">
|
||||
{permission.description}
|
||||
</CheckboxLabel>
|
||||
</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-6 border-t pt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="disabled:(op-100! border-op-50 text-muted-foreground)"
|
||||
onClick={() => setPermissions(API_KEY_PERMISSIONS.flatMap(section => section.permissions))}
|
||||
disabled={permissions().length === API_KEY_PERMISSIONS.flatMap(section => section.permissions).length}
|
||||
>
|
||||
{t('api-keys.permissions.select-all')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="disabled:(op-100! border-op-50 text-muted-foreground)"
|
||||
onClick={() => setPermissions([])}
|
||||
disabled={permissions().length === 0}
|
||||
>
|
||||
{t('api-keys.permissions.deselect-all')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,9 +96,7 @@ export const CreateApiKeyPage: Component = () => {
|
||||
<div>
|
||||
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
|
||||
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
|
||||
</div>
|
||||
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
|
||||
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -72,9 +72,10 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
setState('open');
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
const { maxUploadSize } = config.documentsStorage;
|
||||
updateTaskStatus({ file, status: 'uploading' });
|
||||
|
||||
if (file.size > config.documentsStorage.maxUploadSize) {
|
||||
if (maxUploadSize > 0 && file.size > maxUploadSize) {
|
||||
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,15 +214,6 @@ export const DocumentPage: Component = () => {
|
||||
{t('documents.actions.download')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(getDataUrl()!, '_blank')}
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||
{t('documents.actions.open-in-new-tab')}
|
||||
</Button>
|
||||
|
||||
{getDocument().isDeleted
|
||||
? (
|
||||
<Button
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -187,6 +187,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
@@ -196,16 +197,12 @@ export const IntakeEmailsPage: Component = () => {
|
||||
const createEmail = async () => {
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
if (error) {
|
||||
createToast({
|
||||
message: t('api-errors.intake_email.limit_reached'),
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ 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';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
|
||||
import { useCurrentUserRole, useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
import { fetchOrganization } from '../organizations.services';
|
||||
|
||||
@@ -24,6 +24,8 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: t('organization.settings.delete.confirm.title'),
|
||||
@@ -54,10 +56,16 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter class="pt-6">
|
||||
<Button onClick={handleDelete} variant="destructive">
|
||||
<CardFooter class="pt-6 gap-4">
|
||||
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
|
||||
{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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
|
||||
function codeToKey(code: string): TranslationKeys {
|
||||
@@ -30,6 +31,11 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
||||
return translation;
|
||||
}
|
||||
|
||||
// Fetch error message is not helpful
|
||||
if (error instanceof FetchError) {
|
||||
return getDefaultErrorMessage();
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
9
apps/papra-client/vitest.config.ts
Normal file
9
apps/papra-client/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,62 @@
|
||||
# @papra/app-server
|
||||
|
||||
## 0.9.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#531](https://github.com/papra-hq/papra/pull/531) [`2e2bb6f`](https://github.com/papra-hq/papra/commit/2e2bb6fbbdd02f6b8352ef2653bef0447948c1f0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added env variable to configure ip header for rate limit
|
||||
|
||||
- [#524](https://github.com/papra-hq/papra/pull/524) [`c84a921`](https://github.com/papra-hq/papra/commit/c84a9219886ecb2a77c67d904cf8c8d15b50747b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the api validation of tag colors to make it case incensitive
|
||||
|
||||
## 0.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#521](https://github.com/papra-hq/papra/pull/521) [`b287723`](https://github.com/papra-hq/papra/commit/b28772317c3662555e598755b85597d6cd5aeea1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names encoding (utf8 instead of latin1) to support non-ASCII characters.
|
||||
|
||||
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#508](https://github.com/papra-hq/papra/pull/508) [`782f70f`](https://github.com/papra-hq/papra/commit/782f70ff663634bf9ff7218edabb9885a7c6f965) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added an option to disable PRAGMA statements from sqlite task service migrations
|
||||
|
||||
- [#510](https://github.com/papra-hq/papra/pull/510) [`ab6fd6a`](https://github.com/papra-hq/papra/commit/ab6fd6ad10387f1dcd626936efc195d9d58d40ec) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added fallbacks env variables for the task worker id
|
||||
|
||||
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
|
||||
|
||||
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Split the intake-email username generation from the email address creation, some changes regarding the configuration when using the `random` driver.
|
||||
|
||||
```env
|
||||
# Old configuration
|
||||
INTAKE_EMAILS_DRIVER=random-username
|
||||
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=mydomain.com
|
||||
|
||||
# New configuration
|
||||
INTAKE_EMAILS_DRIVER=catch-all
|
||||
INTAKE_EMAILS_CATCH_ALL_DOMAIN=mydomain.com
|
||||
INTAKE_EMAILS_USERNAME_DRIVER=random
|
||||
```
|
||||
|
||||
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to configure OwlRelay domain
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#493](https://github.com/papra-hq/papra/pull/493) [`ed4d7e4`](https://github.com/papra-hq/papra/commit/ed4d7e4a00b2ca2c7fe808201c322f957d6ed990) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix to allow cross docker volume file moving when consumption is done
|
||||
|
||||
- [#500](https://github.com/papra-hq/papra/pull/500) [`208a561`](https://github.com/papra-hq/papra/commit/208a561668ed2d1019430a9f4f5c5d3fd4cde603) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define a Libsql/Sqlite driver for the tasks service
|
||||
|
||||
- [#499](https://github.com/papra-hq/papra/pull/499) [`40cb1d7`](https://github.com/papra-hq/papra/commit/40cb1d71d5e52c40aab7ea2c6bc222cea6d55b70) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Enhanced security by serving files as attachement and with an octet-stream content type
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-server",
|
||||
"type": "module",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.6",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra app server",
|
||||
@@ -42,6 +42,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.835.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@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/friendly-ids": "^0.0.1",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@papra/lecture": "workspace:*",
|
||||
"@papra/webhooks": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"better-auth": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"c12": "^3.0.4",
|
||||
@@ -61,7 +63,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"figue": "^2.2.3",
|
||||
"figue": "^3.1.1",
|
||||
"hono": "^4.8.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mime-types": "^3.0.1",
|
||||
|
||||
@@ -21,6 +21,8 @@ const { db, client } = setupDatabase(config.database);
|
||||
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
|
||||
|
||||
const taskServices = createTaskServices({ config });
|
||||
await taskServices.initialize();
|
||||
|
||||
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
|
||||
|
||||
const server = serve(
|
||||
|
||||
@@ -5,8 +5,15 @@ export const API_KEY_ID_REGEX = createPrefixedIdRegex({ prefix: API_KEY_ID_PREFI
|
||||
|
||||
export const API_KEY_PREFIX = 'ppapi';
|
||||
export const API_KEY_TOKEN_LENGTH = 64;
|
||||
export const API_KEY_TOKEN_REGEX = new RegExp(`^${API_KEY_PREFIX}_[A-Za-z0-9]{${API_KEY_TOKEN_LENGTH}}$`);
|
||||
|
||||
export const API_KEY_PERMISSIONS = {
|
||||
ORGANIZATIONS: {
|
||||
CREATE: 'organizations:create',
|
||||
READ: 'organizations:read',
|
||||
UPDATE: 'organizations:update',
|
||||
DELETE: 'organizations:delete',
|
||||
},
|
||||
DOCUMENTS: {
|
||||
CREATE: 'documents:create',
|
||||
READ: 'documents:read',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createMiddleware } from 'hono/factory';
|
||||
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||
import { getAuthorizationHeader } from '../shared/headers/headers.models';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { looksLikeAnApiKey } from './api-keys.models';
|
||||
import { createApiKeysRepository } from './api-keys.repository';
|
||||
import { getApiKey } from './api-keys.usecases';
|
||||
|
||||
@@ -31,8 +32,7 @@ export function createApiKeyMiddleware({ db }: { db: Database }) {
|
||||
throw createUnauthorizedError();
|
||||
}
|
||||
|
||||
if (isNil(token)) {
|
||||
// For type safety
|
||||
if (!looksLikeAnApiKey(token)) {
|
||||
throw createUnauthorizedError();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getApiKeyUiPrefix } from './api-keys.models';
|
||||
import { getApiKeyUiPrefix, looksLikeAnApiKey } from './api-keys.models';
|
||||
import { generateApiToken } from './api-keys.services';
|
||||
|
||||
describe('api-keys models', () => {
|
||||
describe('getApiKeyUiPrefix', () => {
|
||||
@@ -11,4 +12,39 @@ describe('api-keys models', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeAnApiKey', () => {
|
||||
test(`validate that a token looks like an api key
|
||||
- it starts with the api key prefix
|
||||
- it has the correct length
|
||||
- it only contains alphanumeric characters`, () => {
|
||||
expect(
|
||||
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
looksLikeAnApiKey(''),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
looksLikeAnApiKey('ppapi_'),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD_extra'),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
looksLikeAnApiKey('invalidprefix_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('a freshly generated token should always look like an api key', () => {
|
||||
const { token } = generateApiToken();
|
||||
|
||||
expect(
|
||||
looksLikeAnApiKey(token),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sha256 } from '../shared/crypto/hash';
|
||||
import { API_KEY_PREFIX } from './api-keys.constants';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { API_KEY_PREFIX, API_KEY_TOKEN_REGEX } from './api-keys.constants';
|
||||
|
||||
export function getApiKeyUiPrefix({ token }: { token: string }) {
|
||||
return {
|
||||
@@ -12,3 +13,12 @@ export function getApiKeyHash({ token }: { token: string }) {
|
||||
keyHash: sha256(token, { digest: 'base64url' }),
|
||||
};
|
||||
}
|
||||
|
||||
// Positional argument as TS does not like named argument with type guards
|
||||
export function looksLikeAnApiKey(token?: string | null | undefined): token is string {
|
||||
if (isNil(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return API_KEY_TOKEN_REGEX.test(token);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,17 @@ export const authConfig = {
|
||||
default: false,
|
||||
env: 'AUTH_SHOW_LEGAL_LINKS',
|
||||
},
|
||||
ipAddressHeaders: {
|
||||
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
|
||||
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
|
||||
- If behind Cloudflare, you might want to set this to "cf-connecting-ip".`,
|
||||
schema: z.union([
|
||||
z.string(),
|
||||
z.array(z.string()),
|
||||
]).transform(value => (typeof value === 'string' ? value.split(',').map(v => v.trim()) : value)),
|
||||
default: ['x-forwarded-for'],
|
||||
env: 'AUTH_IP_ADDRESS_HEADERS',
|
||||
},
|
||||
providers: {
|
||||
email: {
|
||||
isEnabled: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Context, RouteDefinitionContext } from '../server.types';
|
||||
import type { Session } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { isDefined } from '../../shared/utils';
|
||||
import { isDefined, isString } from '../../shared/utils';
|
||||
|
||||
export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) {
|
||||
app.on(
|
||||
@@ -26,7 +26,7 @@ export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext
|
||||
app.use('*', async (context: Context, next) => {
|
||||
const overrideUserId: unknown = get(context.env, 'loggedInUserId');
|
||||
|
||||
if (isDefined(overrideUserId) && typeof overrideUserId === 'string') {
|
||||
if (isDefined(overrideUserId) && isString(overrideUserId)) {
|
||||
context.set('userId', overrideUserId);
|
||||
context.set('session', {} as Session);
|
||||
context.set('authType', 'session');
|
||||
|
||||
@@ -37,8 +37,8 @@ export function getAuth({
|
||||
trustedOrigins,
|
||||
logger: {
|
||||
disabled: false,
|
||||
log: (baseLevel, message) => {
|
||||
logger[baseLevel ?? 'info'](message);
|
||||
log: (baseLevel, message, ...args: unknown[]) => {
|
||||
logger[baseLevel ?? 'info']({ ...args }, message);
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
@@ -85,6 +85,9 @@ export function getAuth({
|
||||
advanced: {
|
||||
// Drizzle tables handle the id generation
|
||||
database: { generateId: false },
|
||||
ipAddress: {
|
||||
ipAddressHeaders: config.auth.ipAddressHeaders,
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
|
||||
|
||||
const app = new Hono<ServerInstanceGenerics>({ strict: true });
|
||||
|
||||
app.use(createLoggerMiddleware());
|
||||
app.use(createLoggerMiddleware({ config }));
|
||||
app.use(createCorsMiddleware({ config }));
|
||||
app.use(createTimeoutMiddleware({ config }));
|
||||
app.use(secureHeaders());
|
||||
|
||||
@@ -15,6 +15,7 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
|
||||
import { organizationsConfig } from '../organizations/organizations.config';
|
||||
import { organizationPlansConfig } from '../plans/plans.config';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { isString } from '../shared/utils';
|
||||
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
||||
import { tasksConfig } from '../tasks/tasks.config';
|
||||
import { trackingConfig } from '../tracking/tracking.config';
|
||||
@@ -71,7 +72,7 @@ export const configDefinition = {
|
||||
schema: z.union([
|
||||
z.string(),
|
||||
z.array(z.string()),
|
||||
]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
|
||||
]).transform(value => (isString(value) ? value.split(',') : value)),
|
||||
default: ['http://localhost:3000'],
|
||||
env: 'SERVER_CORS_ORIGINS',
|
||||
},
|
||||
|
||||
@@ -288,9 +288,13 @@ function setupGetDocumentFileRoute({ app, db, documentsStorageService }: RouteDe
|
||||
Readable.toWeb(fileStream),
|
||||
200,
|
||||
{
|
||||
'Content-Type': document.mimeType,
|
||||
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
|
||||
// Prevent XSS by serving the file as an octet-stream
|
||||
'Content-Type': 'application/octet-stream',
|
||||
// Always use attachment for defense in depth - client uses blob API anyway
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(document.name)}`,
|
||||
'Content-Length': String(document.originalSize),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -127,5 +127,71 @@ describe('documents e2e', () => {
|
||||
// Ensure no file is saved in the storage
|
||||
expect(documentsStorageService._getStorage().size).to.eql(0);
|
||||
});
|
||||
|
||||
// https://github.com/papra-hq/papra/issues/519
|
||||
test('uploading documents with various UTF-8 characters in filenames', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const { app } = await createServer({
|
||||
db,
|
||||
config: overrideConfig({
|
||||
env: 'test',
|
||||
documentsStorage: {
|
||||
driver: 'in-memory',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Various UTF-8 characters that cause encoding issues
|
||||
const testCases = [
|
||||
{ filename: 'ΒΕΒΑΙΩΣΗ ΧΑΡΕΣ.txt', content: 'Filename with Greek characters' },
|
||||
{ filename: 'résumé français.txt', content: 'French document' },
|
||||
{ filename: 'documento español.txt', content: 'Spanish document' },
|
||||
{ filename: '日本語ファイル.txt', content: 'Japanese document' },
|
||||
{ filename: 'файл на русском.txt', content: 'Russian document' },
|
||||
{ filename: 'émojis 🎉📄.txt', content: 'Document with emojis' },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([testCase.content], testCase.filename, { type: 'text/plain' }));
|
||||
const body = new Response(formData);
|
||||
|
||||
const createDocumentResponse = await app.request(
|
||||
'/api/organizations/org_222222222222222222222222/documents',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...Object.fromEntries(body.headers.entries()),
|
||||
},
|
||||
body: await body.arrayBuffer(),
|
||||
},
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
expect(createDocumentResponse.status).to.eql(200);
|
||||
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||
|
||||
// Each filename should be preserved correctly
|
||||
expect(document.name).to.eql(testCase.filename);
|
||||
expect(document.originalName).to.eql(testCase.filename);
|
||||
|
||||
// Retrieve the document
|
||||
const getDocumentResponse = await app.request(
|
||||
`/api/organizations/org_222222222222222222222222/documents/${document.id}`,
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||
);
|
||||
|
||||
expect(getDocumentResponse.status).to.eql(200);
|
||||
const { document: retrievedDocument } = (await getDocumentResponse.json()) as { document: Document };
|
||||
|
||||
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, S3Client } fr
|
||||
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { isString } from '../../../../shared/utils';
|
||||
import { createFileNotFoundError } from '../../document-storage.errors';
|
||||
import { defineStorageDriver } from '../drivers.models';
|
||||
|
||||
@@ -12,7 +13,7 @@ function isS3NotFoundError(error: Error) {
|
||||
const codes = ['NoSuchKey', 'NotFound'];
|
||||
|
||||
return codes.includes(error.name)
|
||||
|| ('Code' in error && typeof error.Code === 'string' && codes.includes(error.Code));
|
||||
|| ('Code' in error && isString(error.Code) && codes.includes(error.Code));
|
||||
}
|
||||
|
||||
export const s3StorageDriverFactory = defineStorageDriver(({ documentStorageConfig }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { isString } from '../shared/utils';
|
||||
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
||||
|
||||
export const ingestionFolderConfig = {
|
||||
@@ -61,7 +62,7 @@ export const ingestionFolderConfig = {
|
||||
schema: z.union([
|
||||
z.string(),
|
||||
z.array(z.string()),
|
||||
]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
|
||||
]).transform(value => (isString(value) ? value.split(',') : value)),
|
||||
default: defaultIgnoredPatterns,
|
||||
env: 'INGESTION_FOLDER_IGNORED_PATTERNS',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const randomUsernameIntakeEmailDriverConfig = {
|
||||
export const catchAllIntakeEmailDriverConfig = {
|
||||
domain: {
|
||||
doc: 'The domain to use when generating email addresses for intake emails when using the random username driver',
|
||||
doc: 'The domain to use when generating email addresses for intake emails when using the `catch-all` driver',
|
||||
schema: z.string(),
|
||||
default: 'papra.email',
|
||||
env: 'INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN',
|
||||
default: 'papra.local',
|
||||
env: 'INTAKE_EMAILS_CATCH_ALL_DOMAIN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildEmailAddress } from '../../intake-emails.models';
|
||||
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
|
||||
|
||||
export const CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME = 'catch-all';
|
||||
|
||||
// This driver is used when no external service is used to manage the email addresses
|
||||
// like for example when using a catch-all domain
|
||||
export const catchAllIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { domain } = config.intakeEmails.drivers.catchAll;
|
||||
|
||||
return {
|
||||
name: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
|
||||
createEmailAddress: async ({ username }) => {
|
||||
const emailAddress = buildEmailAddress({ username, domain });
|
||||
|
||||
return { emailAddress };
|
||||
},
|
||||
deleteEmailAddress: async () => {},
|
||||
};
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import type { Config } from '../../config/config.types';
|
||||
|
||||
export type IntakeEmailsServices = {
|
||||
name: string;
|
||||
generateEmailAddress: () => Promise<{ emailAddress: string }>;
|
||||
deleteEmailAddress: ({ emailAddress }: { emailAddress: string }) => Promise<void>;
|
||||
createEmailAddress: (args: { username: string }) => Promise<{ emailAddress: string }>;
|
||||
deleteEmailAddress: (args: { emailAddress: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export type IntakeEmailDriverFactory = (args: { config: Config }) => IntakeEmailsServices;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME, catchAllIntakeEmailDriverFactory } from './catch-all/catch-all.intake-email-driver';
|
||||
import { OWLRELAY_INTAKE_EMAIL_DRIVER_NAME, owlrelayIntakeEmailDriverFactory } from './owlrelay/owlrelay.intake-email-driver';
|
||||
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME, randomUsernameIntakeEmailDriverFactory } from './random-username/random-username.intake-email-driver';
|
||||
|
||||
export const intakeEmailDrivers = {
|
||||
[RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME]: randomUsernameIntakeEmailDriverFactory,
|
||||
[OWLRELAY_INTAKE_EMAIL_DRIVER_NAME]: owlrelayIntakeEmailDriverFactory,
|
||||
[CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME]: catchAllIntakeEmailDriverFactory,
|
||||
} as const;
|
||||
|
||||
export type IntakeEmailDriverName = keyof typeof intakeEmailDrivers;
|
||||
|
||||
@@ -15,4 +15,10 @@ export const owlrelayIntakeEmailDriverConfig = {
|
||||
default: undefined,
|
||||
env: 'OWLRELAY_WEBHOOK_URL',
|
||||
},
|
||||
domain: {
|
||||
doc: 'The domain to use when generating email addresses for intake emails with OwlRelay, if not provided, the OwlRelay will use their default domain',
|
||||
schema: z.string().optional(), // TODO: check valid hostname
|
||||
default: undefined,
|
||||
env: 'OWLRELAY_DOMAIN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildUrl, safely } from '@corentinth/chisels';
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { createClient } from '@owlrelay/api-sdk';
|
||||
import { getServerBaseUrl } from '../../../config/config.models';
|
||||
import { createError } from '../../../shared/errors/errors';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { INTAKE_EMAILS_INGEST_ROUTE } from '../../intake-emails.constants';
|
||||
import { buildEmailAddress } from '../../intake-emails.models';
|
||||
@@ -14,24 +14,35 @@ const logger = createLogger({ namespace: 'intake-emails.drivers.owlrelay' });
|
||||
export const owlrelayIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { serverBaseUrl } = getServerBaseUrl({ config });
|
||||
const { webhookSecret } = config.intakeEmails;
|
||||
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl } = config.intakeEmails.drivers.owlrelay;
|
||||
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl, domain } = config.intakeEmails.drivers.owlrelay;
|
||||
|
||||
const client = createClient({
|
||||
apiKey: owlrelayApiKey,
|
||||
});
|
||||
const client = createClient({ apiKey: owlrelayApiKey });
|
||||
|
||||
const webhookUrl = configuredWebhookUrl ?? buildUrl({ baseUrl: serverBaseUrl, path: INTAKE_EMAILS_INGEST_ROUTE });
|
||||
|
||||
return {
|
||||
name: OWLRELAY_INTAKE_EMAIL_DRIVER_NAME,
|
||||
generateEmailAddress: async () => {
|
||||
const { domain, username, id: owlrelayEmailId } = await client.createEmail({
|
||||
username: generateHumanReadableId(),
|
||||
createEmailAddress: async ({ username }) => {
|
||||
const [result, error] = await safely(client.createEmail({
|
||||
username,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
});
|
||||
domain,
|
||||
}));
|
||||
|
||||
const emailAddress = buildEmailAddress({ username, domain });
|
||||
if (error) {
|
||||
logger.error({ error, username }, 'Failed to create email address in OwlRelay');
|
||||
|
||||
throw createError({
|
||||
code: 'intake_emails.create_email_address_failed',
|
||||
message: 'Failed to create email address in OwlRelay',
|
||||
statusCode: 500,
|
||||
isInternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { id: owlrelayEmailId, username: createdAddressUsername, domain: createdAddressDomain } = result;
|
||||
const emailAddress = buildEmailAddress({ username: createdAddressUsername, domain: createdAddressDomain });
|
||||
|
||||
logger.info({ emailAddress, owlrelayEmailId }, 'Created email address in OwlRelay');
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
|
||||
|
||||
export const RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME = 'random-username';
|
||||
|
||||
export const randomUsernameIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { domain } = config.intakeEmails.drivers.randomUsername;
|
||||
|
||||
return {
|
||||
name: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
|
||||
generateEmailAddress: async () => {
|
||||
const randomUsername = generateHumanReadableId();
|
||||
|
||||
return {
|
||||
emailAddress: `${randomUsername}@${domain}`,
|
||||
};
|
||||
},
|
||||
// Deletion functionality is not required for this driver
|
||||
deleteEmailAddress: async () => {},
|
||||
};
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME } from './drivers/catch-all/catch-all.intake-email-driver';
|
||||
import { catchAllIntakeEmailDriverConfig } from './drivers/catch-all/catch-all.intake-email-driver.config';
|
||||
import { intakeEmailDrivers } from './drivers/intake-emails.drivers';
|
||||
import { owlrelayIntakeEmailDriverConfig } from './drivers/owlrelay/owlrelay.intake-email-driver.config';
|
||||
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME } from './drivers/random-username/random-username.intake-email-driver';
|
||||
import { randomUsernameIntakeEmailDriverConfig } from './drivers/random-username/random-username.intake-email-driver.config';
|
||||
import { intakeEmailUsernameConfig } from './username-drivers/intake-email-username.config';
|
||||
|
||||
export const intakeEmailsConfig = {
|
||||
isEnabled: {
|
||||
@@ -13,20 +14,21 @@ export const intakeEmailsConfig = {
|
||||
default: false,
|
||||
env: 'INTAKE_EMAILS_IS_ENABLED',
|
||||
},
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}`,
|
||||
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
|
||||
default: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_DRIVER',
|
||||
},
|
||||
webhookSecret: {
|
||||
doc: 'The secret to use when verifying webhooks',
|
||||
schema: z.string(),
|
||||
default: 'change-me',
|
||||
env: 'INTAKE_EMAILS_WEBHOOK_SECRET',
|
||||
},
|
||||
drivers: {
|
||||
randomUsername: randomUsernameIntakeEmailDriverConfig,
|
||||
owlrelay: owlrelayIntakeEmailDriverConfig,
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}.`,
|
||||
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
|
||||
default: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_DRIVER',
|
||||
},
|
||||
drivers: {
|
||||
owlrelay: owlrelayIntakeEmailDriverConfig,
|
||||
catchAll: catchAllIntakeEmailDriverConfig,
|
||||
},
|
||||
username: intakeEmailUsernameConfig,
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -11,3 +11,9 @@ export const createIntakeEmailNotFoundError = createErrorFactory({
|
||||
code: 'intake_email.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
export const createIntakeEmailAlreadyExistsError = createErrorFactory({
|
||||
message: 'Intake email already exists',
|
||||
code: 'intake_email.already_exists',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ export function parseEmailAddress({ email }: { email: string }) {
|
||||
const [username, ...plusParts] = fullUsername.split('+');
|
||||
const plusPart = plusParts.length > 0 ? plusParts.join('+') : undefined;
|
||||
|
||||
if (isNil(username)) {
|
||||
throw createError({
|
||||
message: 'Badly formatted email address',
|
||||
code: 'intake_emails.badly_formatted_email_address',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return { username, domain, plusPart };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { injectArguments, safely } from '@corentinth/chisels';
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { isUniqueConstraintError } from '../shared/db/constraints.models';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { omitUndefined } from '../shared/utils';
|
||||
import { createIntakeEmailNotFoundError } from './intake-emails.errors';
|
||||
import { createIntakeEmailAlreadyExistsError, createIntakeEmailNotFoundError } from './intake-emails.errors';
|
||||
import { intakeEmailsTable } from './intake-emails.tables';
|
||||
|
||||
export type IntakeEmailsRepository = ReturnType<typeof createIntakeEmailsRepository>;
|
||||
@@ -24,7 +25,17 @@ export function createIntakeEmailsRepository({ db }: { db: Database }) {
|
||||
}
|
||||
|
||||
async function createIntakeEmail({ organizationId, emailAddress, db }: { organizationId: string; emailAddress: string; db: Database }) {
|
||||
const [intakeEmail] = await db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning();
|
||||
const [result, error] = await safely(db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning());
|
||||
|
||||
if (isUniqueConstraintError({ error })) {
|
||||
throw createIntakeEmailAlreadyExistsError();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [intakeEmail] = result;
|
||||
|
||||
if (!intakeEmail) {
|
||||
// Very unlikely to happen as the insertion should throw an issue, it's for type safety
|
||||
|
||||
@@ -15,11 +15,13 @@ import { createLogger } from '../shared/logger/logger';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createUsersRepository } from '../users/users.repository';
|
||||
import { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
|
||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { allowedOriginsSchema, intakeEmailIdSchema, intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
|
||||
import { createIntakeEmailsServices } from './intake-emails.services';
|
||||
import { createIntakeEmail, deleteIntakeEmail, processIntakeEmailIngestion } from './intake-emails.usecases';
|
||||
import { createIntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
|
||||
|
||||
const logger = createLogger({ namespace: 'intake-emails.routes' });
|
||||
|
||||
@@ -65,20 +67,24 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
|
||||
const { userId } = getUser({ context });
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const intakeEmailsServices = createIntakeEmailsServices({ config });
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const intakeEmailUsernameServices = createIntakeEmailUsernameServices({ config, usersRepository, organizationsRepository });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { intakeEmail } = await createIntakeEmail({
|
||||
userId,
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailUsernameServices,
|
||||
});
|
||||
|
||||
return context.json({ intakeEmail });
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Logger } from '../shared/logger/logger';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
|
||||
import type { IntakeEmailsRepository } from './intake-emails.repository';
|
||||
import type { IntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
import { addLogContext, createLogger } from '../shared/logger/logger';
|
||||
@@ -12,17 +13,21 @@ import { createIntakeEmailLimitReachedError, createIntakeEmailNotFoundError } fr
|
||||
import { getIsFromAllowedOrigin } from './intake-emails.models';
|
||||
|
||||
export async function createIntakeEmail({
|
||||
userId,
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailUsernameServices,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
intakeEmailsServices: IntakeEmailsServices;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
intakeEmailUsernameServices: IntakeEmailUsernameServices;
|
||||
}) {
|
||||
await checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId,
|
||||
@@ -31,7 +36,9 @@ export async function createIntakeEmail({
|
||||
intakeEmailsRepository,
|
||||
});
|
||||
|
||||
const { emailAddress } = await intakeEmailsServices.generateEmailAddress();
|
||||
const { username } = await intakeEmailUsernameServices.generateIntakeEmailUsername({ userId, organizationId });
|
||||
|
||||
const { emailAddress } = await intakeEmailsServices.createEmailAddress({ username });
|
||||
|
||||
const { intakeEmail } = await intakeEmailsRepository.createIntakeEmail({ organizationId, emailAddress });
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
|
||||
import { patternIntakeEmailDriverConfig } from './pattern/pattern.intake-email-username-driver.config';
|
||||
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './random/random.intake-email-username-driver';
|
||||
|
||||
export const intakeEmailUsernameConfig = {
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailUsernameDrivers).map(x => `\`${x}\``).join(', ')}`,
|
||||
schema: z.enum(Object.keys(intakeEmailUsernameDrivers) as [string, ...string[]]),
|
||||
default: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_USERNAME_DRIVER',
|
||||
},
|
||||
drivers: {
|
||||
pattern: patternIntakeEmailDriverConfig,
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { patternIntakeEmailUsernameDriverFactory } from './pattern/pattern.intake-email-username-driver';
|
||||
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern/pattern.intake-email-username-driver.config';
|
||||
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME, randomIntakeEmailUsernameDriverFactory } from './random/random.intake-email-username-driver';
|
||||
|
||||
export const intakeEmailUsernameDrivers = {
|
||||
[RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: randomIntakeEmailUsernameDriverFactory,
|
||||
[PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: patternIntakeEmailUsernameDriverFactory,
|
||||
} as const;
|
||||
|
||||
export type IntakeEmailUsernameDriverName = keyof typeof intakeEmailUsernameDrivers;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Logger } from '@crowlog/logger';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
|
||||
import type { UsersRepository } from '../../users/users.repository';
|
||||
|
||||
export type IntakeEmailUsernameDriver = {
|
||||
name: string;
|
||||
generateIntakeEmailUsername: (args: { userId: string; organizationId: string }) => Promise<{ username: string }>;
|
||||
};
|
||||
|
||||
export type IntakeEmailUsernameDriverFactory = (args: {
|
||||
config: Config;
|
||||
logger?: Logger;
|
||||
usersRepository: UsersRepository;
|
||||
organizationsRepository: OrganizationsRepository;
|
||||
}) => IntakeEmailUsernameDriver;
|
||||
|
||||
export function defineIntakeEmailUsernameDriverFactory(factory: IntakeEmailUsernameDriverFactory) {
|
||||
return factory;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
|
||||
import type { UsersRepository } from '../../users/users.repository';
|
||||
import type { IntakeEmailUsernameDriverName } from './intake-email-username.drivers';
|
||||
import type { IntakeEmailUsernameDriver, IntakeEmailUsernameDriverFactory } from './intake-email-username.models';
|
||||
import { createError } from '../../shared/errors/errors';
|
||||
import { isNil } from '../../shared/utils';
|
||||
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
|
||||
|
||||
export type IntakeEmailUsernameServices = IntakeEmailUsernameDriver;
|
||||
|
||||
export function createIntakeEmailUsernameServices({
|
||||
config,
|
||||
...dependencies
|
||||
}: {
|
||||
config: Config;
|
||||
usersRepository: UsersRepository;
|
||||
organizationsRepository: OrganizationsRepository;
|
||||
}) {
|
||||
const { driver } = config.intakeEmails.username;
|
||||
const intakeEmailUsernameDriver: IntakeEmailUsernameDriverFactory | undefined = intakeEmailUsernameDrivers[driver as IntakeEmailUsernameDriverName];
|
||||
|
||||
if (isNil(intakeEmailUsernameDriver)) {
|
||||
throw createError({
|
||||
message: `Invalid intake email addresses driver ${driver}`,
|
||||
code: 'intake-emails.addresses.invalid_driver',
|
||||
statusCode: 500,
|
||||
isInternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const intakeEmailUsernameServices = intakeEmailUsernameDriver({ config, ...dependencies });
|
||||
|
||||
return intakeEmailUsernameServices;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
|
||||
|
||||
export const PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'pattern';
|
||||
|
||||
export const patternIntakeEmailDriverConfig = {
|
||||
pattern: {
|
||||
doc: `The pattern to use when generating email addresses usernames (before the @) for intake emails. Available placeholders are: ${Object.values(PATTERNS_PLACEHOLDERS).join(', ')}. Note: the resulting username will be slugified to remove special characters and spaces.`,
|
||||
schema: z.string(),
|
||||
default: `${PATTERNS_PLACEHOLDERS.USER_NAME}-${PATTERNS_PLACEHOLDERS.RANDOM_DIGITS}`,
|
||||
env: 'INTAKE_EMAILS_USERNAME_DRIVER_PATTERN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,8 @@
|
||||
export const PATTERNS_PLACEHOLDERS = {
|
||||
USER_NAME: '{{user.name}}',
|
||||
USER_ID: '{{user.id}}',
|
||||
USER_EMAIL_USERNAME: '{{user.email.username}}',
|
||||
ORGANIZATION_ID: '{{organization.id}}',
|
||||
ORGANIZATION_NAME: '{{organization.name}}',
|
||||
RANDOM_DIGITS: '{{random.digits}}',
|
||||
} as const;
|
||||
@@ -0,0 +1,52 @@
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { createError } from '../../../shared/errors/errors';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { isNil } from '../../../shared/utils';
|
||||
import { parseEmailAddress } from '../../intake-emails.models';
|
||||
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
|
||||
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern.intake-email-username-driver.config';
|
||||
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
|
||||
|
||||
export const patternIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({
|
||||
logger = createLogger({ namespace: 'intake-emails.addresses-drivers.pattern' }),
|
||||
config,
|
||||
usersRepository,
|
||||
organizationsRepository,
|
||||
}) => {
|
||||
const { pattern } = config.intakeEmails.username.drivers.pattern;
|
||||
|
||||
return {
|
||||
name: PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
generateIntakeEmailUsername: async ({ userId, organizationId }) => {
|
||||
const [{ user }, { organization }] = await Promise.all([
|
||||
usersRepository.getUserById({ userId }),
|
||||
organizationsRepository.getOrganizationById({ organizationId }),
|
||||
]);
|
||||
|
||||
if (isNil(user) || isNil(organization)) {
|
||||
// Should not really happen, there is a check on the routes handlers
|
||||
throw createError({
|
||||
message: 'User or organization not found',
|
||||
code: 'intake-emails.addresses.user_or_organization_not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const { username: userEmailUsername } = parseEmailAddress({ email: user.email });
|
||||
|
||||
const rawUsername = pattern
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_NAME, user.name ?? '')
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_ID, user.id)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_EMAIL_USERNAME, userEmailUsername)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_ID, organization.id)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_NAME, organization.name)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.RANDOM_DIGITS, () => Math.floor(Math.random() * 10000).toString());
|
||||
|
||||
const username = slugify(rawUsername);
|
||||
|
||||
logger.debug({ rawUsername, username, pattern, userId, organizationId }, 'Generated email address');
|
||||
|
||||
return { username };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
|
||||
|
||||
export const RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'random';
|
||||
|
||||
export const randomIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({ logger = createLogger({ namespace: 'intake-emails.addresses-drivers.random' }) }) => {
|
||||
return {
|
||||
name: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
generateIntakeEmailUsername: async () => {
|
||||
const username = generateHumanReadableId();
|
||||
|
||||
logger.debug({ username }, 'Generated email address');
|
||||
|
||||
return { username };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { createUsersRepository } from '../users/users.repository';
|
||||
import { memberIdSchema, organizationIdSchema } from './organization.schemas';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganizationsRepository } from './organizations.repository';
|
||||
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
|
||||
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
|
||||
|
||||
export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
|
||||
setupGetOrganizationsRoute(context);
|
||||
@@ -27,7 +27,7 @@ export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
|
||||
function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.get(
|
||||
'/api/organizations',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: ['organizations:read'] }),
|
||||
async (context) => {
|
||||
const { userId } = getUser({ context });
|
||||
|
||||
@@ -45,7 +45,7 @@ function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
'/api/organizations',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: ['organizations:create'] }),
|
||||
validateJsonBody(z.object({
|
||||
name: z.string().min(3).max(50),
|
||||
})),
|
||||
@@ -70,7 +70,7 @@ function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContex
|
||||
function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.get(
|
||||
'/api/organizations/:organizationId',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: ['organizations:read'] }),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
@@ -92,7 +92,7 @@ function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.put(
|
||||
'/api/organizations/:organizationId',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: ['organizations:update'] }),
|
||||
validateJsonBody(z.object({
|
||||
name: z.string().min(3).max(50),
|
||||
})),
|
||||
@@ -120,7 +120,7 @@ function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.delete(
|
||||
'/api/organizations/:organizationId',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: ['organizations:delete'] }),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
@@ -130,7 +130,9 @@ function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
// No Promise.all as we want to ensure consistency in error handling
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
await ensureUserIsOwnerOfOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
await organizationsRepository.deleteOrganization({ organizationId });
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FsNative } from './fs.services';
|
||||
import { memfs } from 'memfs';
|
||||
import { createFsServices } from './fs.services';
|
||||
|
||||
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
|
||||
export function buildInMemoryFs(volume: NestedDirectoryJSON) {
|
||||
const { vol } = memfs(volume);
|
||||
|
||||
const fs = {
|
||||
@@ -12,7 +12,16 @@ export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
|
||||
} as FsNative;
|
||||
|
||||
return {
|
||||
fs,
|
||||
getFsState: () => vol.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
|
||||
const { fs, getFsState } = buildInMemoryFs(volume);
|
||||
|
||||
return {
|
||||
getFsState,
|
||||
fs: createFsServices({ fs }),
|
||||
};
|
||||
}
|
||||
|
||||
12
apps/papra-server/src/modules/shared/fs/fs.models.ts
Normal file
12
apps/papra-server/src/modules/shared/fs/fs.models.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { isNil, isString } from '../utils';
|
||||
|
||||
export function isCrossDeviceError({ error }: { error: Error & { code?: unknown } }) {
|
||||
if (isNil(error.code) || !isString(error.code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'EXDEV', // Linux based OS (see `man rename`)
|
||||
'ERROR_NOT_SAME_DEVICE', // Windows
|
||||
].includes(error.code);
|
||||
}
|
||||
45
apps/papra-server/src/modules/shared/fs/fs.services.test.ts
Normal file
45
apps/papra-server/src/modules/shared/fs/fs.services.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildInMemoryFs } from './fs.in-memory';
|
||||
import { moveFile } from './fs.services';
|
||||
|
||||
describe('fs services', () => {
|
||||
describe('moveFile', () => {
|
||||
test('moves a file from the source path to the destination path', async () => {
|
||||
const { fs, getFsState } = buildInMemoryFs({
|
||||
'/file.txt': 'test content',
|
||||
});
|
||||
|
||||
await moveFile({
|
||||
sourceFilePath: '/file.txt',
|
||||
destinationFilePath: '/renamed.txt',
|
||||
fs,
|
||||
});
|
||||
|
||||
expect(getFsState()).to.eql({
|
||||
'/renamed.txt': 'test content',
|
||||
});
|
||||
});
|
||||
|
||||
test('if the destination file is in a different partition or disk, or a different docker volume, the underlying rename operation fails with an EXDEV error, so we fallback to copy + delete the source file', async () => {
|
||||
const { fs, getFsState } = buildInMemoryFs({
|
||||
'/file.txt': 'test content',
|
||||
});
|
||||
|
||||
await moveFile({
|
||||
sourceFilePath: '/file.txt',
|
||||
destinationFilePath: '/renamed.txt',
|
||||
fs: {
|
||||
...fs,
|
||||
rename: async () => {
|
||||
// Simulate an EXDEV error
|
||||
throw Object.assign(new Error('EXDEV'), { code: 'EXDEV' });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getFsState()).to.eql({
|
||||
'/renamed.txt': 'test content',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import type { Readable } from 'node:stream';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import fsSyncNative from 'node:fs';
|
||||
import fsPromisesNative from 'node:fs/promises';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { injectArguments, safely } from '@corentinth/chisels';
|
||||
import { pick } from 'lodash-es';
|
||||
import { isCrossDeviceError } from './fs.models';
|
||||
|
||||
// what we use from the native fs module
|
||||
export type FsNative = {
|
||||
@@ -13,12 +14,13 @@ export type FsNative = {
|
||||
stat: (path: string) => Promise<{ size: number }>;
|
||||
readFile: (path: string) => Promise<Buffer>;
|
||||
access: (path: string, mode: number) => Promise<void>;
|
||||
copyFile: (sourcePath: string, destinationPath: string) => Promise<void>;
|
||||
constants: { F_OK: number };
|
||||
createReadStream: (path: string) => Readable;
|
||||
};
|
||||
|
||||
const fsNative = {
|
||||
...pick(fsPromisesNative, 'mkdir', 'unlink', 'rename', 'readFile', 'access', 'constants', 'stat'),
|
||||
...pick(fsPromisesNative, 'mkdir', 'unlink', 'rename', 'readFile', 'access', 'constants', 'stat', 'copyFile'),
|
||||
createReadStream: fsSyncNative.createReadStream.bind(fsSyncNative) as (filePath: string) => Readable,
|
||||
} as FsNative;
|
||||
|
||||
@@ -66,7 +68,19 @@ export async function deleteFile({ filePath, fs = fsNative }: { filePath: string
|
||||
}
|
||||
|
||||
export async function moveFile({ sourceFilePath, destinationFilePath, fs = fsNative }: { sourceFilePath: string; destinationFilePath: string; fs?: FsNative }) {
|
||||
await fs.rename(sourceFilePath, destinationFilePath);
|
||||
const [, error] = await safely(fs.rename(sourceFilePath, destinationFilePath));
|
||||
|
||||
// With different docker volumes, the rename operation fails with an EXDEV error,
|
||||
// so we fallback to copy and delete the source file
|
||||
if (error && isCrossDeviceError({ error })) {
|
||||
await fs.copyFile(sourceFilePath, destinationFilePath);
|
||||
await fs.unlink(sourceFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFile({ filePath, fs = fsNative }: { filePath: string; fs?: FsNative }) {
|
||||
|
||||
@@ -26,3 +26,15 @@ export function getContentLengthHeader({ headers }: { headers: Record<string, st
|
||||
|
||||
return Number(contentLengthHeaderValue);
|
||||
}
|
||||
|
||||
export function getIpFromHeaders({ context, headerNames }: { context: Context; headerNames: string[] }): string | undefined {
|
||||
for (const headerName of headerNames) {
|
||||
const headerValue = getHeader({ context, name: headerName });
|
||||
|
||||
if (!isNil(headerValue)) {
|
||||
return headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { Context } from '../../app/server.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { getHeader } from '../headers/headers.models';
|
||||
import { routePath } from 'hono/route';
|
||||
import { getHeader, getIpFromHeaders } from '../headers/headers.models';
|
||||
import { generateId } from '../random/ids';
|
||||
import { createLogger, wrapWithLoggerContext } from './logger';
|
||||
|
||||
const logger = createLogger({ namespace: 'app' });
|
||||
|
||||
export function createLoggerMiddleware() {
|
||||
export function createLoggerMiddleware({ config }: { config: Config }) {
|
||||
return createMiddleware(async (context: Context, next) => {
|
||||
const requestId = getHeader({ context, name: 'x-request-id' });
|
||||
const requestId = getHeader({ context, name: 'x-request-id' }) ?? generateId({ prefix: 'req' });
|
||||
const ip = getIpFromHeaders({ context, headerNames: config.auth.ipAddressHeaders });
|
||||
|
||||
await wrapWithLoggerContext(
|
||||
{
|
||||
requestId: requestId ?? generateId({ prefix: 'req' }),
|
||||
requestId,
|
||||
},
|
||||
async () => {
|
||||
const requestedAt = new Date();
|
||||
@@ -26,9 +29,10 @@ export function createLoggerMiddleware() {
|
||||
status: context.res.status,
|
||||
method: context.req.method,
|
||||
path: context.req.path,
|
||||
routePath: context.req.routePath,
|
||||
routePath: routePath(context),
|
||||
userAgent: getHeader({ context, name: 'User-Agent' }),
|
||||
durationMs,
|
||||
ip,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function getFileStreamFromMultipartForm({
|
||||
files: 1, // Only allow one file
|
||||
fileSize: maxFileSize,
|
||||
},
|
||||
defParamCharset: 'utf8',
|
||||
})
|
||||
.on('file', (formFieldname, fileStream, info) => {
|
||||
if (formFieldname !== fieldName) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { isDefined, isNil, omitUndefined } from './utils';
|
||||
import { isDefined, isNil, isNonEmptyString, isString, omitUndefined } from './utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('omitUndefined', () => {
|
||||
@@ -47,4 +47,38 @@ describe('utils', () => {
|
||||
expect(isDefined({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isString', () => {
|
||||
test('returns true if the value is a string', () => {
|
||||
expect(isString('')).toBe(true);
|
||||
expect(isString('foo')).toBe(true);
|
||||
expect(isString(String(1))).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the value is not a string', () => {
|
||||
expect(isString(undefined)).toBe(false);
|
||||
expect(isString(null)).toBe(false);
|
||||
expect(isString(0)).toBe(false);
|
||||
expect(isString(false)).toBe(false);
|
||||
expect(isString({})).toBe(false);
|
||||
expect(isString([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNonEmptyString', () => {
|
||||
test('returns true if the value is a non-empty string', () => {
|
||||
expect(isNonEmptyString('')).toBe(false);
|
||||
expect(isNonEmptyString('foo')).toBe(true);
|
||||
expect(isNonEmptyString(String(1))).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the value is not a non-empty string', () => {
|
||||
expect(isNonEmptyString(undefined)).toBe(false);
|
||||
expect(isNonEmptyString(null)).toBe(false);
|
||||
expect(isNonEmptyString(0)).toBe(false);
|
||||
expect(isNonEmptyString(false)).toBe(false);
|
||||
expect(isNonEmptyString({})).toBe(false);
|
||||
expect(isNonEmptyString([])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,11 @@ export function isNil(value: unknown): value is undefined | null {
|
||||
export function isDefined<T>(value: T): value is Exclude<T, undefined | null> {
|
||||
return !isNil(value);
|
||||
}
|
||||
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return isString(value) && value.length > 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isNil } from '../shared/utils';
|
||||
import { isNil, isNonEmptyString } from '../shared/utils';
|
||||
|
||||
export function coerceStripeTimestampToDate(timestamp: number) {
|
||||
return new Date(timestamp * 1000);
|
||||
@@ -9,5 +9,5 @@ export function isSignatureHeaderFormatValid(signature: string | undefined): sig
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof signature === 'string' && signature.length > 0;
|
||||
return isNonEmptyString(signature);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPrefixedIdRegex } from '../shared/random/ids';
|
||||
|
||||
export const TagColorRegex = /^#[0-9a-f]{6}$/;
|
||||
export const TagColorRegex = /^#[0-9A-F]{6}$/;
|
||||
|
||||
export const tagIdPrefix = 'tag';
|
||||
export const tagIdRegex = createPrefixedIdRegex({ prefix: tagIdPrefix });
|
||||
|
||||
@@ -14,10 +14,9 @@ import { ensureUserIsInOrganization } from '../organizations/organizations.useca
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { TagColorRegex } from './tags.constants';
|
||||
import { createTagNotFoundError } from './tags.errors';
|
||||
import { createTagsRepository } from './tags.repository';
|
||||
import { tagIdSchema } from './tags.schemas';
|
||||
import { tagColorSchema, tagIdSchema } from './tags.schemas';
|
||||
|
||||
export function registerTagsRoutes(context: RouteDefinitionContext) {
|
||||
setupCreateNewTagRoute(context);
|
||||
@@ -38,7 +37,7 @@ function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
validateJsonBody(z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000'),
|
||||
color: tagColorSchema,
|
||||
description: z.string().max(256).optional(),
|
||||
})),
|
||||
|
||||
@@ -95,7 +94,7 @@ function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
validateJsonBody(z.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000').optional(),
|
||||
color: tagColorSchema.optional(),
|
||||
description: z.string().max(256).optional(),
|
||||
})),
|
||||
|
||||
|
||||
25
apps/papra-server/src/modules/tags/tags.schemas.test.ts
Normal file
25
apps/papra-server/src/modules/tags/tags.schemas.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { tagColorSchema } from './tags.schemas';
|
||||
|
||||
describe('tags schemas', () => {
|
||||
describe('tagColorSchema', () => {
|
||||
test('the color of a tag is a 6 digits hex color code', () => {
|
||||
expect(() => tagColorSchema.parse('#FFFFFF')).not.toThrow();
|
||||
expect(() => tagColorSchema.parse('#000000')).not.toThrow();
|
||||
expect(() => tagColorSchema.parse('#123ABC')).not.toThrow();
|
||||
expect(() => tagColorSchema.parse('#abcdef')).not.toThrow();
|
||||
|
||||
expect(() => tagColorSchema.parse('FFFFFF')).toThrow();
|
||||
expect(() => tagColorSchema.parse('#FFF')).toThrow();
|
||||
expect(() => tagColorSchema.parse('#123ABCG')).toThrow();
|
||||
expect(() => tagColorSchema.parse('#123AB')).toThrow();
|
||||
expect(() => tagColorSchema.parse('blue')).toThrow();
|
||||
});
|
||||
|
||||
test('the color of a tag is always uppercased', () => {
|
||||
expect(tagColorSchema.parse('#abcdef')).toBe('#ABCDEF');
|
||||
expect(tagColorSchema.parse('#abCdEf')).toBe('#ABCDEF');
|
||||
expect(tagColorSchema.parse('#123abc')).toBe('#123ABC');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { tagIdRegex } from './tags.constants';
|
||||
import { TagColorRegex, tagIdRegex } from './tags.constants';
|
||||
|
||||
export const tagIdSchema = z.string().regex(tagIdRegex);
|
||||
export const tagColorSchema = z.string().toUpperCase().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000');
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TaskPersistenceConfig, TaskServiceDriverDefinition } from '../../tasks.types';
|
||||
import { createLibSqlDriver, setupSchema } from '@cadence-mq/driver-libsql';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { createClient } from '@libsql/client';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks-driver:libsql' });
|
||||
|
||||
export function createLibSqlTaskServiceDriver({ taskPersistenceConfig }: { taskPersistenceConfig: TaskPersistenceConfig }): TaskServiceDriverDefinition {
|
||||
const { url, authToken, pollIntervalMs, migrateWithPragma } = taskPersistenceConfig.drivers.libSql;
|
||||
|
||||
const client = createClient({ url, authToken });
|
||||
const driver = createLibSqlDriver({ client, pollIntervalMs });
|
||||
|
||||
return {
|
||||
driver,
|
||||
initialize: async () => {
|
||||
logger.debug('Initializing LibSQL task service driver');
|
||||
const [, error] = await safely(setupSchema({ client, withPragma: migrateWithPragma }));
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, 'Failed to set up LibSQL task service schema');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('LibSQL task service driver initialized');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TaskServiceDriverDefinition } from '../../tasks.types';
|
||||
import { createMemoryDriver } from '@cadence-mq/driver-memory';
|
||||
|
||||
export function createMemoryTaskServiceDriver(): TaskServiceDriverDefinition {
|
||||
const driver = createMemoryDriver();
|
||||
|
||||
return {
|
||||
driver,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const TASKS_DRIVER_NAMES = {
|
||||
memory: 'memory',
|
||||
libsql: 'libsql',
|
||||
} as const;
|
||||
|
||||
export const tasksDriverNames = Object.keys(TASKS_DRIVER_NAMES);
|
||||
|
||||
export type TasksDriverName = keyof typeof TASKS_DRIVER_NAMES;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TaskServiceDriverFactory } from '../tasks.types';
|
||||
import { createLibSqlTaskServiceDriver } from './libsql/libsql.tasks-driver';
|
||||
import { createMemoryTaskServiceDriver } from './memory/memory.tasks-driver';
|
||||
import { TASKS_DRIVER_NAMES } from './tasks-driver.constants';
|
||||
|
||||
export const tasksDrivers = {
|
||||
[TASKS_DRIVER_NAMES.memory]: createMemoryTaskServiceDriver,
|
||||
[TASKS_DRIVER_NAMES.libsql]: createLibSqlTaskServiceDriver,
|
||||
} as const satisfies Record<string, TaskServiceDriverFactory>;
|
||||
@@ -1,21 +1,51 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
||||
|
||||
export const tasksConfig = {
|
||||
persistence: {
|
||||
driver: {
|
||||
doc: 'The driver to use for the tasks persistence',
|
||||
schema: z.enum(['memory']),
|
||||
driverName: {
|
||||
doc: `The driver to use for the tasks persistence, values can be one of: ${tasksDriverNames.map(x => `\`${x}\``).join(', ')}. Using the memory driver is enough when running a single instance of the server.`,
|
||||
schema: z.enum(tasksDriverNames as [TasksDriverName, ...TasksDriverName[]]),
|
||||
default: 'memory',
|
||||
env: 'TASKS_PERSISTENCE_DRIVER',
|
||||
},
|
||||
drivers: {
|
||||
libSql: {
|
||||
url: {
|
||||
doc: 'The URL of the LibSQL database, can be either a file-protocol url with a local path or a remote LibSQL database URL',
|
||||
schema: z.string().url(),
|
||||
default: 'file:./tasks-db.sqlite',
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_URL',
|
||||
},
|
||||
authToken: {
|
||||
doc: 'The auth token for the LibSQL database',
|
||||
schema: z.string().optional(),
|
||||
default: undefined,
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_AUTH_TOKEN',
|
||||
},
|
||||
migrateWithPragma: {
|
||||
doc: 'Whether to include the PRAGMA statements when setting up the LibSQL database schema.',
|
||||
schema: booleanishSchema,
|
||||
default: true,
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_MIGRATE_WITH_PRAGMA',
|
||||
},
|
||||
pollIntervalMs: {
|
||||
doc: 'The interval at which the task persistence driver polls for new tasks',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 1_000,
|
||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
id: {
|
||||
doc: 'The id of the task worker, used to identify the worker in the Cadence cluster in case of multiple workers',
|
||||
schema: z.string().optional(),
|
||||
env: 'TASKS_WORKER_ID',
|
||||
env: ['TASKS_WORKER_ID', 'DYNO', 'RENDER_SERVICE_ID'],
|
||||
},
|
||||
},
|
||||
hardDeleteExpiredDocuments: {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import { createCadence } from '@cadence-mq/core';
|
||||
import { createMemoryDriver } from '@cadence-mq/driver-memory';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { tasksDrivers } from './drivers/tasks-driver.registry';
|
||||
|
||||
export type TaskServices = ReturnType<typeof createTaskServices>;
|
||||
|
||||
@@ -9,12 +11,30 @@ const logger = createLogger({ namespace: 'tasks:services' });
|
||||
|
||||
export function createTaskServices({ config }: { config: Config }) {
|
||||
const workerId = config.tasks.worker.id ?? 'default';
|
||||
const taskPersistenceConfig = config.tasks.persistence;
|
||||
const { driverName } = taskPersistenceConfig;
|
||||
|
||||
const driver = createMemoryDriver();
|
||||
const driverFactory = tasksDrivers[driverName];
|
||||
|
||||
if (isNil(driverFactory)) {
|
||||
// Should not happen as the config validation should catch invalid driver names
|
||||
throw createError({
|
||||
message: `Invalid task service driver: ${driverName}`,
|
||||
code: 'tasks.invalid_driver',
|
||||
statusCode: 500,
|
||||
isInternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { driver, initialize } = driverFactory({ taskPersistenceConfig });
|
||||
const cadence = createCadence({ driver, logger });
|
||||
|
||||
return {
|
||||
...cadence,
|
||||
initialize: async () => {
|
||||
await initialize?.();
|
||||
logger.debug({ driverName }, 'Task persistence driver initialized');
|
||||
},
|
||||
start: () => {
|
||||
const worker = cadence.createWorker({ workerId });
|
||||
|
||||
|
||||
7
apps/papra-server/src/modules/tasks/tasks.types.ts
Normal file
7
apps/papra-server/src/modules/tasks/tasks.types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { JobRepositoryDriver } from '@cadence-mq/core';
|
||||
import type { Config } from '../config/config.types';
|
||||
|
||||
export type TaskPersistenceConfig = Config['tasks']['persistence'];
|
||||
|
||||
export type TaskServiceDriverDefinition = { driver: JobRepositoryDriver; initialize?: () => Promise<void> };
|
||||
export type TaskServiceDriverFactory = (args: { taskPersistenceConfig: TaskPersistenceConfig }) => TaskServiceDriverDefinition;
|
||||
@@ -1,3 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,6 +48,9 @@ ENV PAPRA_CONFIG_DIR=./app-data
|
||||
ENV EMAILS_DRY_RUN=true
|
||||
ENV CLIENT_BASE_URL=http://localhost:1221
|
||||
|
||||
# Disable Better Auth telemetry
|
||||
ENV BETTER_AUTH_TELEMETRY=0
|
||||
|
||||
RUN mkdir -p ./app-data/db ./app-data/documents ./ingestion
|
||||
|
||||
CMD ["pnpm", "start:with-migrations"]
|
||||
|
||||
@@ -61,4 +61,7 @@ ENV INGESTION_FOLDER_ROOT=./ingestion
|
||||
ENV EMAILS_DRY_RUN=true
|
||||
ENV CLIENT_BASE_URL=http://localhost:1221
|
||||
|
||||
# Disable Better Auth telemetry
|
||||
ENV BETTER_AUTH_TELEMETRY=0
|
||||
|
||||
CMD ["pnpm", "start:with-migrations"]
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@papra/root",
|
||||
"type": "module",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra document management monorepo root",
|
||||
@@ -15,8 +16,8 @@
|
||||
"version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||
"changeset": "changeset",
|
||||
"build:packages": "pnpm --filter './packages/*' --stream build",
|
||||
"test": "TZ=UTC vitest run",
|
||||
"test:watch": "TZ=UTC vitest watch"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
194
pnpm-lock.yaml
generated
194
pnpm-lock.yaml
generated
@@ -100,8 +100,8 @@ importers:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)
|
||||
figue:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.3(zod@3.25.67)
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -253,6 +253,9 @@ importers:
|
||||
'@cadence-mq/core':
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
'@cadence-mq/driver-libsql':
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4(@cadence-mq/core@0.2.1)(@libsql/client@0.14.0)
|
||||
'@cadence-mq/driver-memory':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(@cadence-mq/core@0.2.1)
|
||||
@@ -289,6 +292,9 @@ importers:
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
'@sindresorhus/slugify':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
better-auth:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.4(react-dom@19.0.0(react@18.3.1))(react@18.3.1)
|
||||
@@ -311,8 +317,8 @@ importers:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@libsql/client@0.14.0)(kysely@0.28.2)(react@18.3.1)
|
||||
figue:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3(zod@3.25.67)
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
hono:
|
||||
specifier: ^4.8.2
|
||||
version: 4.8.2
|
||||
@@ -1019,6 +1025,11 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.25.9':
|
||||
resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1045,6 +1056,10 @@ packages:
|
||||
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@balena/dockerignore@1.0.2':
|
||||
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
|
||||
|
||||
@@ -1061,6 +1076,12 @@ packages:
|
||||
'@cadence-mq/core@0.2.1':
|
||||
resolution: {integrity: sha512-Cu/jqR7mNhMZ1U4Boiudy2nePyf4PtqBUFGhUcsCQPJfymKcrDm4xjp8A/2tKZr5JSgkN/7L0/+mHZ27GVSryQ==}
|
||||
|
||||
'@cadence-mq/driver-libsql@0.2.4':
|
||||
resolution: {integrity: sha512-JXsajpPXJRQolYiPzYI5rpQyTjH1g7AZMh3KYnHHs8nieLekYhU885iRPCu80RXQsYN2CJa08Vj5hgQhGP9rjw==}
|
||||
peerDependencies:
|
||||
'@cadence-mq/core': ^0.2.3
|
||||
'@libsql/client': ^0.15.9
|
||||
|
||||
'@cadence-mq/driver-memory@0.2.0':
|
||||
resolution: {integrity: sha512-U/L5nkCu+BYO814oQAYbFYSNASha+6Om3Px3+Jm47YzFmSrQhrnX6fTyICVQFz+MflWndhke6Bh1mwik6nbrcw==}
|
||||
peerDependencies:
|
||||
@@ -1176,6 +1197,10 @@ packages:
|
||||
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-calc@2.1.3':
|
||||
resolution: {integrity: sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1190,13 +1215,6 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.0.10':
|
||||
resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.0.9':
|
||||
resolution: {integrity: sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1204,6 +1222,13 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^3.0.4
|
||||
'@csstools/css-tokenizer': ^3.0.3
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.4':
|
||||
resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2440,6 +2465,9 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
@@ -3055,6 +3083,14 @@ packages:
|
||||
resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@sindresorhus/slugify@3.0.0':
|
||||
resolution: {integrity: sha512-SCrKh1zS96q+CuH5GumHcyQEVPsM4Ve8oE0E6tw7AAhGq50K8ojbTUOQnX/j9Mhcv/AXiIsbCfquovyGOo5fGw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@sindresorhus/transliterate@2.0.0':
|
||||
resolution: {integrity: sha512-lRx63oCHxeJ90DqIgmbxH1PQmiBDY1wVaLzB4hK0d/xS5BrG1iZO3HdCJS/DQJk6GJ8xHDev8OMI7iGxvE1ZUA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@smithy/abort-controller@4.0.4':
|
||||
resolution: {integrity: sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3981,8 +4017,8 @@ packages:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
aproba@2.0.0:
|
||||
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
|
||||
aproba@2.1.0:
|
||||
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
|
||||
@@ -4542,9 +4578,21 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.5.0:
|
||||
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
|
||||
|
||||
@@ -4606,6 +4654,10 @@ packages:
|
||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-libc@2.1.0:
|
||||
resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
deterministic-object-hash@2.0.2:
|
||||
resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5359,10 +5411,8 @@ packages:
|
||||
fflate@0.4.8:
|
||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||
|
||||
figue@2.2.3:
|
||||
resolution: {integrity: sha512-Dwj/Na3KuiGeFhiM2AieEcMhgXDbHfdWrRcSGdfAspeWTEC8OXleY/Mf+yty4YIK4gxpgQcpKhQm84XTMvXUqg==}
|
||||
peerDependencies:
|
||||
zod: ^3.22.4
|
||||
figue@3.1.1:
|
||||
resolution: {integrity: sha512-LYqDGKztWh0tU5eCTzW5nK3MAp4K5ihEsnyWiNwU6jW0yVle9DqQCiMra5lMTXE21d9x882ZoOK4SitMXvaQ3Q==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
@@ -5422,8 +5472,8 @@ packages:
|
||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.3:
|
||||
resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==}
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
format@0.2.2:
|
||||
@@ -6108,6 +6158,9 @@ packages:
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
|
||||
magic-string@0.30.19:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
|
||||
magicast@0.3.5:
|
||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||
|
||||
@@ -6432,6 +6485,9 @@ packages:
|
||||
nan@2.22.0:
|
||||
resolution: {integrity: sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==}
|
||||
|
||||
nan@2.23.0:
|
||||
resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -6527,6 +6583,9 @@ packages:
|
||||
nwsapi@2.2.20:
|
||||
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
|
||||
|
||||
nwsapi@2.2.22:
|
||||
resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
|
||||
|
||||
nypm@0.6.0:
|
||||
resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==}
|
||||
engines: {node: ^14.16.0 || >=16.10.0}
|
||||
@@ -8573,7 +8632,7 @@ snapshots:
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
@@ -9390,6 +9449,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.0
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.4
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.0
|
||||
@@ -9427,6 +9490,11 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
|
||||
'@balena/dockerignore@1.0.2': {}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
@@ -9445,6 +9513,11 @@ snapshots:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
cron-parser: 5.3.0
|
||||
|
||||
'@cadence-mq/driver-libsql@0.2.4(@cadence-mq/core@0.2.1)(@libsql/client@0.14.0)':
|
||||
dependencies:
|
||||
'@cadence-mq/core': 0.2.1
|
||||
'@libsql/client': 0.14.0
|
||||
|
||||
'@cadence-mq/driver-memory@0.2.0(@cadence-mq/core@0.2.1)':
|
||||
dependencies:
|
||||
'@cadence-mq/core': 0.2.1
|
||||
@@ -9666,6 +9739,9 @@ snapshots:
|
||||
|
||||
'@csstools/color-helpers@5.0.2': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
optional: true
|
||||
|
||||
'@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
|
||||
@@ -9677,14 +9753,6 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
optional: true
|
||||
|
||||
'@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.0.2
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
optional: true
|
||||
|
||||
'@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.0.2
|
||||
@@ -9692,6 +9760,14 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
|
||||
'@csstools/css-tokenizer': 3.0.3
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
optional: true
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 3.0.3
|
||||
@@ -10536,6 +10612,8 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.4': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
@@ -10672,7 +10750,7 @@ snapshots:
|
||||
|
||||
'@mapbox/node-pre-gyp@1.0.11':
|
||||
dependencies:
|
||||
detect-libc: 2.0.3
|
||||
detect-libc: 2.1.0
|
||||
https-proxy-agent: 5.0.1
|
||||
make-dir: 3.1.0
|
||||
node-fetch: 2.7.0
|
||||
@@ -11139,6 +11217,13 @@ snapshots:
|
||||
'@peculiar/asn1-schema': 2.3.15
|
||||
'@peculiar/asn1-x509': 2.3.15
|
||||
|
||||
'@sindresorhus/slugify@3.0.0':
|
||||
dependencies:
|
||||
'@sindresorhus/transliterate': 2.0.0
|
||||
escape-string-regexp: 5.0.0
|
||||
|
||||
'@sindresorhus/transliterate@2.0.0': {}
|
||||
|
||||
'@smithy/abort-controller@4.0.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.1
|
||||
@@ -12348,7 +12433,7 @@ snapshots:
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/shared': 3.5.13
|
||||
entities: 4.5.0
|
||||
estree-walker: 2.0.2
|
||||
@@ -12361,13 +12446,13 @@ snapshots:
|
||||
|
||||
'@vue/compiler-sfc@3.5.13':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/compiler-core': 3.5.13
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
'@vue/compiler-ssr': 3.5.13
|
||||
'@vue/shared': 3.5.13
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
postcss: 8.5.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -12421,7 +12506,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
@@ -12469,7 +12554,7 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
aproba@2.0.0:
|
||||
aproba@2.1.0:
|
||||
optional: true
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
@@ -12914,7 +12999,7 @@ snapshots:
|
||||
canvas@2.11.2:
|
||||
dependencies:
|
||||
'@mapbox/node-pre-gyp': 1.0.11
|
||||
nan: 2.22.0
|
||||
nan: 2.23.0
|
||||
simple-get: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
@@ -13167,8 +13252,16 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
decimal.js@10.5.0: {}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
optional: true
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -13213,6 +13306,9 @@ snapshots:
|
||||
|
||||
detect-libc@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.0:
|
||||
optional: true
|
||||
|
||||
deterministic-object-hash@2.0.2:
|
||||
dependencies:
|
||||
base-64: 1.0.0
|
||||
@@ -14258,9 +14354,9 @@ snapshots:
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
figue@2.2.3(zod@3.25.67):
|
||||
figue@3.1.1:
|
||||
dependencies:
|
||||
zod: 3.25.67
|
||||
'@standard-schema/spec': 1.0.0
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
@@ -14324,7 +14420,7 @@ snapshots:
|
||||
es-set-tostringtag: 2.1.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.3:
|
||||
form-data@4.0.4:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
@@ -14371,7 +14467,7 @@ snapshots:
|
||||
|
||||
gauge@3.0.2:
|
||||
dependencies:
|
||||
aproba: 2.0.0
|
||||
aproba: 2.1.0
|
||||
color-support: 1.1.3
|
||||
console-control-strings: 1.1.0
|
||||
has-unicode: 2.0.1
|
||||
@@ -14763,7 +14859,7 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
@@ -15003,13 +15099,13 @@ snapshots:
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.5.0
|
||||
form-data: 4.0.3
|
||||
decimal.js: 10.6.0
|
||||
form-data: 4.0.4
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.20
|
||||
nwsapi: 2.2.22
|
||||
parse5: 7.3.0
|
||||
rrweb-cssom: 0.8.0
|
||||
saxes: 6.0.0
|
||||
@@ -15152,6 +15248,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
magic-string@0.30.19:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
@@ -15800,6 +15900,9 @@ snapshots:
|
||||
nan@2.22.0:
|
||||
optional: true
|
||||
|
||||
nan@2.23.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
@@ -15876,6 +15979,9 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.20: {}
|
||||
|
||||
nwsapi@2.2.22:
|
||||
optional: true
|
||||
|
||||
nypm@0.6.0:
|
||||
dependencies:
|
||||
citty: 0.1.6
|
||||
@@ -17677,7 +17783,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@22.16.0))
|
||||
'@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@24.0.10))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
test: {
|
||||
reporters: ['verbose'],
|
||||
projects: ['apps/*', 'packages/*'],
|
||||
coverage: {
|
||||
include: ['packages/*/src'],
|
||||
}
|
||||
},
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user