mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-19 03:51:28 -06:00
Compare commits
11 Commits
@papra/app
...
formisch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f0b83863 | ||
|
|
41a113334a | ||
|
|
6723baf98a | ||
|
|
bbe5fe74e2 | ||
|
|
a8cff8cedc | ||
|
|
67b3b14cdf | ||
|
|
ffdae8db56 | ||
|
|
7768840aa4 | ||
|
|
dd3862e50c | ||
|
|
a82ff3a755 | ||
|
|
d5b00307da |
5
.changeset/beige-houses-confess.md
Normal file
5
.changeset/beige-houses-confess.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added diacritics and improved wording for Romanian translation
|
||||
5
.changeset/bumpy-aliens-juggle.md
Normal file
5
.changeset/bumpy-aliens-juggle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Breaking change: updated webhooks signatures and payload format to match standard-webhook spec
|
||||
5
.changeset/few-pugs-wink.md
Normal file
5
.changeset/few-pugs-wink.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Simplified the organization intake email list
|
||||
7
.changeset/heavy-chairs-look.md
Normal file
7
.changeset/heavy-chairs-look.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Added new webhook events: document:updated, document:tag:added, document:tag:removed
|
||||
7
.changeset/itchy-candies-marry.md
Normal file
7
.changeset/itchy-candies-marry.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@papra/app-client": minor
|
||||
"@papra/app-server": minor
|
||||
"@papra/webhooks": minor
|
||||
---
|
||||
|
||||
Webhooks invocation is now defered
|
||||
5
.changeset/shaggy-olives-speak.md
Normal file
5
.changeset/shaggy-olives-speak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/lecture": minor
|
||||
---
|
||||
|
||||
Added support for scanned pdf content extraction
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#417](https://github.com/papra-hq/papra/pull/417) [`a82ff3a`](https://github.com/papra-hq/papra/commit/a82ff3a755fa1164b4d8ff09b591ed6482af0ccc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - v0.7 release
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
@@ -31,9 +31,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@formisch/solid": "^0.2.0",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook lösch
|
||||
webhooks.delete.confirm.confirm-button: Löschen
|
||||
webhooks.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
webhooks.events.documents.title: Dokumente Ereignisse
|
||||
webhooks.events.documents.document:created.description: Dokument erstellt
|
||||
webhooks.events.documents.document:deleted.description: Dokument gelöscht
|
||||
webhooks.events.documents.document:updated.description: Dokument aktualisiert
|
||||
webhooks.events.documents.document:tag:added.description: Ein Tag wurde zu einem Dokument hinzugefügt
|
||||
webhooks.events.documents.document:tag:removed.description: Ein Tag wurde von einem Dokument entfernt
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
|
||||
webhooks.delete.confirm.confirm-button: Delete
|
||||
webhooks.delete.confirm.cancel-button: Cancel
|
||||
|
||||
webhooks.events.documents.title: Documents events
|
||||
webhooks.events.documents.document:created.description: Document created
|
||||
webhooks.events.documents.document:deleted.description: Document deleted
|
||||
webhooks.events.documents.document:updated.description: Document updated
|
||||
webhooks.events.documents.document:tag:added.description: A tag is added to a document
|
||||
webhooks.events.documents.document:tag:removed.description: A tag is removed from a document
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: ¿Estás seguro de que deseas eliminar este web
|
||||
webhooks.delete.confirm.confirm-button: Eliminar
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento creado
|
||||
webhooks.events.documents.document:deleted.description: Documento eliminado
|
||||
webhooks.events.documents.document:updated.description: Documento actualizado
|
||||
webhooks.events.documents.document:tag:added.description: Una etiqueta se ha añadido a un documento
|
||||
webhooks.events.documents.document:tag:removed.description: Una etiqueta se ha eliminado de un documento
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook
|
||||
webhooks.delete.confirm.confirm-button: Supprimer
|
||||
webhooks.delete.confirm.cancel-button: Annuler
|
||||
|
||||
webhooks.events.documents.title: Événements de documents
|
||||
webhooks.events.documents.document:created.description: Document créé
|
||||
webhooks.events.documents.document:deleted.description: Document supprimé
|
||||
webhooks.events.documents.document:updated.description: Document mis à jour
|
||||
webhooks.events.documents.document:tag:added.description: Un tag est ajouté à un document
|
||||
webhooks.events.documents.document:tag:removed.description: Un tag est retiré d'un document
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Czy na pewno chcesz usunąć ten webhook?
|
||||
webhooks.delete.confirm.confirm-button: Usuń
|
||||
webhooks.delete.confirm.cancel-button: Anuluj
|
||||
|
||||
webhooks.events.documents.title: Zdarzenia dokumentów
|
||||
webhooks.events.documents.document:created.description: Utworzono dokument
|
||||
webhooks.events.documents.document:deleted.description: Usunięto dokument
|
||||
webhooks.events.documents.document:updated.description: Dokument został zaktualizowany
|
||||
webhooks.events.documents.document:tag:added.description: Tag został dodany do dokumentu
|
||||
webhooks.events.documents.document:tag:removed.description: Tag został usunięty z dokumentu
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -489,8 +489,12 @@ webhooks.delete.confirm.message: Tem certeza de que deseja excluir este webhook?
|
||||
webhooks.delete.confirm.confirm-button: Excluir
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento criado
|
||||
webhooks.events.documents.document:deleted.description: Documento excluído
|
||||
webhooks.events.documents.document:updated.description: Documento atualizado
|
||||
webhooks.events.documents.document:tag:added.description: Uma tag foi adicionada a um documento
|
||||
webhooks.events.documents.document:tag:removed.description: Uma tag foi removida de um documento
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ auth.legal-links.description: Ao continuar, reconhece que compreende e concorda
|
||||
auth.legal-links.terms: Termos de Serviço
|
||||
auth.legal-links.privacy: Política de Privacidade
|
||||
|
||||
# auth.no-auth-provider.title: No authentication provider
|
||||
# auth.no-auth-provider.description: There are no authentication providers enabled on this instance of Papra. Please contact the administrator of this instance to enable them.
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Definições do utilizador
|
||||
@@ -486,8 +489,12 @@ webhooks.delete.confirm.message: Tem a certeza de que deseja eliminar este webho
|
||||
webhooks.delete.confirm.confirm-button: Eliminar
|
||||
webhooks.delete.confirm.cancel-button: Cancelar
|
||||
|
||||
webhooks.events.documents.title: Eventos de documentos
|
||||
webhooks.events.documents.document:created.description: Documento criado
|
||||
webhooks.events.documents.document:deleted.description: Documento eliminado
|
||||
webhooks.events.documents.document:updated.description: Documento atualizado
|
||||
webhooks.events.documents.document:tag:added.description: Uma etiqueta foi adicionada a um documento
|
||||
webhooks.events.documents.document:tag:removed.description: Uma etiqueta foi removida de um documento
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { setInput } from '@formisch/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -79,35 +79,35 @@ export const CreateApiKeyPage: Component = () => {
|
||||
|
||||
<Show when={!getToken()}>
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="permissions" type="string[]">
|
||||
<Field path={['permissions']}>
|
||||
{field => (
|
||||
<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)} />
|
||||
<ApiKeyPermissionsPicker permissions={(field.input as string[]) ?? []} onChange={permissions => setInput(form, { path: ['permissions'], input: permissions })} />
|
||||
</div>
|
||||
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</div>
|
||||
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<Button type="submit" isLoading={form.submitting}>
|
||||
<Button type="submit" isLoading={form.isSubmitting}>
|
||||
{t('api-keys.create.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const EmailLoginForm: Component = () => {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
throw new Error(error.message);
|
||||
}
|
||||
},
|
||||
schema: v.object({
|
||||
@@ -54,32 +54,32 @@ export const EmailLoginForm: Component = () => {
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['email']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">{t('auth.login.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...field.props} value={field.input} autoFocus aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="password">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['password']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="password">{t('auth.login.form.password.label')}</TextFieldLabel>
|
||||
|
||||
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<Field name="rememberMe" type="boolean">
|
||||
{(field, inputProps) => (
|
||||
<Checkbox class="flex items-center gap-2" defaultChecked={field.value}>
|
||||
<CheckboxControl inputProps={inputProps} />
|
||||
<Field path={['rememberMe']}>
|
||||
{field => (
|
||||
<Checkbox class="flex items-center gap-2" defaultChecked={field.input as boolean}>
|
||||
<CheckboxControl inputProps={field.props} />
|
||||
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{t('auth.login.form.remember-me.label')}
|
||||
</CheckboxLabel>
|
||||
@@ -94,9 +94,9 @@ export const EmailLoginForm: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>{t('auth.login.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export const EmailRegisterForm: Component = () => {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (config.auth.isEmailVerificationRequired) {
|
||||
@@ -63,41 +63,42 @@ export const EmailRegisterForm: Component = () => {
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['email']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">{t('auth.register.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="name">{t('auth.register.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="password">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['password']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="password">{t('auth.register.form.password.label')}</TextFieldLabel>
|
||||
|
||||
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...field.props} value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
|
||||
{t('auth.register.form.submit')}
|
||||
</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,21 +29,21 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string })
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['email']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="email">{t('auth.request-password-reset.form.email.label')}</TextFieldLabel>
|
||||
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
|
||||
{t('auth.request-password-reset.form.submit')}
|
||||
</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm mt-2">{form.errors?.[0]}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -27,21 +27,21 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="newPassword">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['newPassword']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="newPassword">{t('auth.reset-password.form.new-password.label')}</TextFieldLabel>
|
||||
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
<Button type="submit" class="w-full" isLoading={form.isSubmitting}>
|
||||
{t('auth.reset-password.form.submit')}
|
||||
</Button>
|
||||
|
||||
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm mt-2">{form.errors?.[0]}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component, ParentComponent } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { setInput } from '@formisch/solid';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -58,7 +58,7 @@ export const RenameDocumentDialog: Component<{
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
|
||||
setInput(form, { path: ['name'], input: getDocumentNameWithoutExtension({ name: props.documentName }) });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -69,21 +69,21 @@ export const RenameDocumentDialog: Component<{
|
||||
</DialogHeader>
|
||||
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot>
|
||||
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
|
||||
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField {...field.props} value={field.input} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
|
||||
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)} disabled={form.isSubmitting}>
|
||||
{t('documents.rename.cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('documents.rename.form.submit')}</Button>
|
||||
<Button type="submit" isLoading={form.isSubmitting}>{t('documents.rename.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -38,7 +38,8 @@ describe('locales', () => {
|
||||
const dynamicKeysMatchers = [
|
||||
/^api-errors\./, // api-errors.document.already_exists
|
||||
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
|
||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
||||
/^webhooks\.events\.[a-z0-9]+\.[a-z0-9:]+.description$/, // webhooks.events.documents.document:created.description
|
||||
/^webhooks\.events\.[a-z0-9]+\.title$/, // webhooks.events.documents.title
|
||||
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
|
||||
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
|
||||
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
|
||||
|
||||
@@ -440,8 +440,12 @@ export type LocaleKeys =
|
||||
| 'webhooks.delete.confirm.message'
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.title'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| 'webhooks.events.documents.document:updated.description'
|
||||
| 'webhooks.events.documents.document:tag:added.description'
|
||||
| 'webhooks.events.documents.document:tag:removed.description'
|
||||
| 'layout.menu.home'
|
||||
| 'layout.menu.documents'
|
||||
| 'layout.menu.tags'
|
||||
|
||||
@@ -17,16 +17,26 @@ import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
const AllowedOriginsDialog: Component<{
|
||||
children: (props: DialogTriggerProps) => JSX.Element;
|
||||
intakeEmails: IntakeEmail;
|
||||
open?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal(props.intakeEmails?.allowedOrigins || []);
|
||||
const { t } = useI18n();
|
||||
|
||||
const update = async () => {
|
||||
if (!props.intakeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateIntakeEmail({
|
||||
organizationId: props.intakeEmails.organizationId,
|
||||
intakeEmailId: props.intakeEmails.id,
|
||||
@@ -58,13 +68,29 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
});
|
||||
|
||||
async function invalidateQuery() {
|
||||
if (!props.intakeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', props.intakeEmails.organizationId, 'intake-emails'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.intakeEmails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={isOpen => !isOpen && invalidateQuery()}>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
invalidateQuery();
|
||||
}
|
||||
props.onOpenChange?.(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger as={props.children} />
|
||||
|
||||
<DialogContent>
|
||||
@@ -76,21 +102,21 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
</DialogHeader>
|
||||
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['email']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
|
||||
<TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<Button type="submit">
|
||||
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
<Button type="submit" isLoading={form.isSubmitting}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('intake-emails.allowed-origins.add.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error }</div>}
|
||||
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
@@ -129,6 +155,8 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
export const IntakeEmailsPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { t, te } = useI18n();
|
||||
const [selectedIntakeEmail, setSelectedIntakeEmail] = createSignal<IntakeEmail | null>(null);
|
||||
const [openDropdownId, setOpenDropdownId] = createSignal<string | null>(null);
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
@@ -225,6 +253,11 @@ export const IntakeEmailsPage: Component = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openAllowedOriginsDialog = (intakeEmail: IntakeEmail) => {
|
||||
setOpenDropdownId(null);
|
||||
setSelectedIntakeEmail(intakeEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||
@@ -313,39 +346,46 @@ export const IntakeEmailsPage: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||
<DropdownMenu
|
||||
open={openDropdownId() === intakeEmail.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenDropdownId(isOpen ? intakeEmail.id : null);
|
||||
}}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</Button>
|
||||
|
||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||
{(props: DialogTriggerProps) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Edit intake email"
|
||||
{...props}
|
||||
class="flex items-center gap-2 leading-none"
|
||||
<DropdownMenuTrigger as={Button} variant="outline" aria-label="More actions" size="icon">
|
||||
<div class="i-tabler-dots-vertical size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpenDropdownId(null);
|
||||
updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled });
|
||||
}}
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</Button>
|
||||
)}
|
||||
</AllowedOriginsDialog>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => deleteEmail({ intakeEmailId: intakeEmail.id })}
|
||||
aria-label="Delete intake email"
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('intake-emails.actions.delete')}
|
||||
</Button>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openAllowedOriginsDialog(intakeEmail)}
|
||||
>
|
||||
<div class="i-tabler-edit size-4 mr-2" />
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpenDropdownId(null);
|
||||
deleteEmail({ intakeEmailId: intakeEmail.id });
|
||||
}}
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
{t('intake-emails.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -355,6 +395,22 @@ export const IntakeEmailsPage: Component = () => {
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<Show when={selectedIntakeEmail()}>
|
||||
{intakeEmail => (
|
||||
<AllowedOriginsDialog
|
||||
intakeEmails={intakeEmail()}
|
||||
open={true}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSelectedIntakeEmail(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{() => <div />}
|
||||
</AllowedOriginsDialog>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,23 +37,23 @@ export const CreateOrganizationForm: Component<{
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['organizationName']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-6">
|
||||
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" isLoading={form.submitting} class="w-full">
|
||||
<Button type="submit" isLoading={form.isSubmitting} class="w-full">
|
||||
{t('organizations.create.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm mt-4">{form.errors?.[0]}</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { setInput } from '@formisch/solid';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { onMount, Show } from 'solid-js';
|
||||
@@ -101,8 +101,8 @@ export const InviteMemberPage: Component = () => {
|
||||
|
||||
<div class="mt-10 max-w-xs mx-auto">
|
||||
<Form>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['email']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="email">
|
||||
{t('organizations.invite-member.form.email.label')}
|
||||
@@ -113,16 +113,16 @@ export const InviteMemberPage: Component = () => {
|
||||
placeholder={t(
|
||||
'organizations.invite-member.form.email.placeholder',
|
||||
)}
|
||||
{...inputProps}
|
||||
{...field.props}
|
||||
/>
|
||||
{field.error && (
|
||||
<div class="text-red-500 text-sm">{field.error}</div>
|
||||
{field.errors && (
|
||||
<div class="text-red-500 text-sm">{field.errors[0]}</div>
|
||||
)}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="role">
|
||||
<Field path={['role']}>
|
||||
{field => (
|
||||
<div>
|
||||
<label for="role" class="text-sm font-medium mb-1 block">
|
||||
@@ -139,9 +139,9 @@ export const InviteMemberPage: Component = () => {
|
||||
{tRole(props.item.rawValue)}
|
||||
</SelectItem>
|
||||
)}
|
||||
value={field.value}
|
||||
value={field.input as InvitableRole}
|
||||
onChange={value =>
|
||||
setValue(form, 'role', value as InvitableRole)}
|
||||
setInput(form, { path: ['role'], input: value as InvitableRole })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue<string>>
|
||||
|
||||
@@ -138,25 +138,25 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
<Form>
|
||||
<CardContent class="pt-6 ">
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['organizationName']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="organizationName" class="sr-only">
|
||||
{t('organization.settings.name.title')}
|
||||
</TextFieldLabel>
|
||||
<div class="flex gap-2 flex-col sm:flex-row">
|
||||
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} />
|
||||
|
||||
<Button type="submit" isLoading={form.submitting} class="flex-shrink-0" disabled={field.value?.trim() === props.organization.name}>
|
||||
<Button type="submit" isLoading={form.isSubmitting} class="flex-shrink-0" disabled={(field.input as string)?.trim() === props.organization.name}>
|
||||
{t('organization.settings.name.update')}
|
||||
</Button>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
|
||||
</CardContent>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { FormErrors, FormProps, PartialValues } from '@modular-forms/solid';
|
||||
import type { FieldArrayProps, FieldProps, FormProps } from '@formisch/solid';
|
||||
import type * as v from 'valibot';
|
||||
import { createForm as createModularForm, FormError, valiForm } from '@modular-forms/solid';
|
||||
import { createForm as createFormishForm, Field, FieldArray, Form } from '@formisch/solid';
|
||||
import { createHook } from '../hooks/hooks';
|
||||
|
||||
// Extracted from the library to avoid type errors
|
||||
type FormishDeepPartial<TValue> = TValue extends readonly unknown[] ? number extends TValue['length'] ? TValue : { [Key in keyof TValue]?: FormishDeepPartial<TValue[Key]> | undefined } : TValue extends Record<PropertyKey, unknown> ? { [Key in keyof TValue]?: FormishDeepPartial<TValue[Key]> | undefined } : TValue | undefined;
|
||||
|
||||
export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
schema,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
}: {
|
||||
schema: Schema;
|
||||
initialValues?: PartialValues<v.InferInput<Schema>>;
|
||||
initialValues?: FormishDeepPartial<v.InferInput<Schema>>;
|
||||
onSubmit?: (values: v.InferInput<Schema>) => Promise<void>;
|
||||
}) {
|
||||
const submitHook = createHook<v.InferInput<Schema>>();
|
||||
@@ -18,18 +21,18 @@ export function createForm<Schema extends v.ObjectSchema<any, any>>({
|
||||
submitHook.on(onSubmit);
|
||||
}
|
||||
|
||||
const [form, { Form, Field, FieldArray }] = createModularForm<v.InferInput<Schema>>({
|
||||
validate: valiForm(schema),
|
||||
initialValues,
|
||||
const form = createFormishForm({
|
||||
schema,
|
||||
initialInput: initialValues,
|
||||
});
|
||||
|
||||
return {
|
||||
form,
|
||||
Form: (props: Omit<FormProps<v.InferInput<Schema>, undefined>, 'of'>) => Form({ ...props, onSubmit: submitHook.trigger }),
|
||||
Field,
|
||||
FieldArray,
|
||||
onSubmit: submitHook.on,
|
||||
Form: (props: Omit<FormProps<Schema>, 'of' | 'onSubmit'>) => Form({ of: form, ...props, onSubmit: async (args) => {
|
||||
await submitHook.trigger(args);
|
||||
} }),
|
||||
Field: (props: Omit<FieldProps<Schema>, 'of'>) => Field({ of: form, ...props }),
|
||||
FieldArray: (props: Omit<FieldArrayProps<Schema>, 'of'>) => FieldArray({ of: form, ...props }),
|
||||
submit: submitHook.trigger,
|
||||
createFormError: ({ message, fields }: { message: string; fields?: FormErrors<v.InferInput<Schema>> }) => new FormError<v.InferInput<Schema>>(message, fields),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
|
||||
import { insert, remove, setValue } from '@modular-forms/solid';
|
||||
import { insert, remove, setInput } from '@formisch/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -45,24 +45,26 @@ export const TaggingRuleForm: Component<{
|
||||
}
|
||||
}
|
||||
|
||||
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
|
||||
props.onSubmit({ taggingRule: { name, conditions, tagIds, description: description ?? '' } });
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('tagging-rules.form.name.min-length')),
|
||||
v.minLength(1, t('tagging-rules.form.name.min-length')),
|
||||
v.maxLength(64, t('tagging-rules.form.name.max-length')),
|
||||
),
|
||||
description: v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(256, t('tagging-rules.form.description.max-length')),
|
||||
description: v.optional(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(256, t('tagging-rules.form.description.max-length')),
|
||||
),
|
||||
),
|
||||
conditions: v.optional(
|
||||
v.array(v.object({
|
||||
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
|
||||
operator: v.picklist(Object.values(TAGGING_RULE_OPERATORS)),
|
||||
value: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('tagging-rules.form.conditions.value.min-length')),
|
||||
v.minLength(1, t('tagging-rules.form.conditions.value.min-length')),
|
||||
),
|
||||
})),
|
||||
@@ -90,33 +92,33 @@ export const TaggingRuleForm: Component<{
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="name">{t('tagging-rules.form.name.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder={t('tagging-rules.form.name.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['description']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mt-6">
|
||||
<TextFieldLabel for="description">{t('tagging-rules.form.description.label')}</TextFieldLabel>
|
||||
<TextArea
|
||||
id="description"
|
||||
placeholder={t('tagging-rules.form.description.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
@@ -126,7 +128,7 @@ export const TaggingRuleForm: Component<{
|
||||
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
|
||||
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
|
||||
|
||||
<FieldArray name="conditions">
|
||||
<FieldArray path={['conditions']}>
|
||||
{fieldArray => (
|
||||
<div>
|
||||
<For each={fieldArray.items}>
|
||||
@@ -134,12 +136,12 @@ export const TaggingRuleForm: Component<{
|
||||
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
|
||||
<div>When</div>
|
||||
|
||||
<Field name={`conditions.${index()}.field`}>
|
||||
<Field path={['conditions', index(), 'field']}>
|
||||
{field => (
|
||||
<Select
|
||||
id="field"
|
||||
defaultValue={field.value}
|
||||
onChange={value => value && setValue(form, `conditions.${index()}.field`, value)}
|
||||
defaultValue={field.input as string}
|
||||
onChange={value => value && setInput(form, { path: ['conditions', index(), 'field'], input: value })}
|
||||
options={Object.values(TAGGING_RULE_FIELDS)}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>{getFieldLabel(props.item.rawValue)}</SelectItem>
|
||||
@@ -153,12 +155,12 @@ export const TaggingRuleForm: Component<{
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name={`conditions.${index()}.operator`}>
|
||||
<Field path={['conditions', index(), 'operator']}>
|
||||
{field => (
|
||||
<Select
|
||||
id="operator"
|
||||
defaultValue={field.value}
|
||||
onChange={value => value && setValue(form, `conditions.${index()}.operator`, value)}
|
||||
defaultValue={field.input as string}
|
||||
onChange={value => value && setInput(form, { path: ['conditions', index(), 'operator'], input: value })}
|
||||
options={Object.values(TAGGING_RULE_OPERATORS)}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>{getOperatorLabel(props.item.rawValue)}</SelectItem>
|
||||
@@ -172,36 +174,36 @@ export const TaggingRuleForm: Component<{
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name={`conditions.${index()}.value`}>
|
||||
{(field, inputProps) => (
|
||||
<Field path={['conditions', index(), 'value']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 flex-1">
|
||||
<TextField
|
||||
id="value"
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
placeholder={t('tagging-rules.form.conditions.value.placeholder')}
|
||||
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => remove(form, 'conditions', { at: index() })}>
|
||||
<Button variant="outline" size="icon" onClick={() => remove(form, { path: ['conditions'], at: index() })}>
|
||||
<div class="i-tabler-x size-4"></div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{fieldArray.error && <div class="text-red-500 text-sm">{fieldArray.error}</div>}
|
||||
{fieldArray.errors && <div class="text-red-500 text-sm">{fieldArray.errors[0]}</div>}
|
||||
</div>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => insert(form, 'conditions', { value: { field: 'name', operator: 'contains', value: '' } })}
|
||||
onClick={() => insert(form, { path: ['conditions'], input: { field: 'name', operator: 'contains', value: '' } })}
|
||||
class="gap-2 mt-2"
|
||||
>
|
||||
<div class="i-tabler-plus size-4"></div>
|
||||
@@ -213,7 +215,7 @@ export const TaggingRuleForm: Component<{
|
||||
<p class="mb-1 font-medium">{t('tagging-rules.form.tags.label')}</p>
|
||||
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.tags.description')}</p>
|
||||
|
||||
<Field name="tagIds" type="string[]">
|
||||
<Field path={['tagIds']}>
|
||||
{field => (
|
||||
<>
|
||||
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
|
||||
@@ -221,8 +223,8 @@ export const TaggingRuleForm: Component<{
|
||||
|
||||
<DocumentTagPicker
|
||||
organizationId={props.organizationId}
|
||||
tagIds={field.value ?? []}
|
||||
onTagsChange={({ tags }) => setValue(form, 'tagIds', tags.map(tag => tag.id))}
|
||||
tagIds={(field.input as string[]) ?? []}
|
||||
onTagsChange={({ tags }) => setInput(form, { path: ['tagIds'], input: tags.map(tag => tag.id) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +237,7 @@ export const TaggingRuleForm: Component<{
|
||||
)}
|
||||
</CreateTagModal>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { Tag as TagType } from '../tags.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { getValues, setValue } from '@modular-forms/solid';
|
||||
import { getInput, setInput } from '@formisch/solid';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
@@ -45,7 +45,7 @@ const TagColorPicker: Component<{
|
||||
};
|
||||
|
||||
const TagForm: Component<{
|
||||
onSubmit: (values: { name: string; color: string; description: string }) => Promise<void>;
|
||||
onSubmit: (values: { name: string; color: string; description?: string }) => Promise<void>;
|
||||
initialValues?: { name?: string; color?: string; description?: string | null };
|
||||
submitLabel?: string;
|
||||
}> = (props) => {
|
||||
@@ -54,21 +54,23 @@ const TagForm: Component<{
|
||||
onSubmit: props.onSubmit,
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('tags.form.name.required')),
|
||||
v.trim(),
|
||||
v.nonEmpty(t('tags.form.name.required')),
|
||||
v.maxLength(64, t('tags.form.name.max-length')),
|
||||
),
|
||||
color: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('tags.form.color.required')),
|
||||
v.trim(),
|
||||
v.nonEmpty(t('tags.form.color.required')),
|
||||
v.hexColor(t('tags.form.color.invalid')),
|
||||
),
|
||||
description: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(256, t('tags.form.description.max-length')),
|
||||
description: v.optional(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(256, t('tags.form.description.max-length')),
|
||||
),
|
||||
'',
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
@@ -77,39 +79,39 @@ const TagForm: Component<{
|
||||
},
|
||||
});
|
||||
|
||||
const getFormValues = () => getValues(form);
|
||||
const getFormValues = () => getInput(form);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="name">{t('tags.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.name.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextField type="text" id="name" {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} placeholder={t('tags.form.name.placeholder')} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="color">
|
||||
<Field path={['color']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
|
||||
<TagColorPicker color={field.value ?? ''} onChange={color => setValue(form, 'color', color)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TagColorPicker color={(field.input as string) ?? ''} onChange={color => setInput(form, { path: ['color'], input: color })} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="description">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['description']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="description">
|
||||
{t('tags.form.description.label')}
|
||||
<span class="font-normal ml-1 text-muted-foreground">{t('tags.form.description.optional')}</span>
|
||||
</TextFieldLabel>
|
||||
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.description.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
<TextArea id="description" {...field.props} autoFocus value={field.input} aria-invalid={Boolean(field.errors)} placeholder={t('tags.form.description.placeholder')} />
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
@@ -137,7 +139,7 @@ export const CreateTagModal: Component<{
|
||||
const { t } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description?: string }) => {
|
||||
const [,error] = await safely(createTag({
|
||||
name,
|
||||
color: color.toLowerCase(),
|
||||
@@ -188,7 +190,7 @@ const UpdateTagModal: Component<{
|
||||
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description?: string }) => {
|
||||
await updateTag({
|
||||
name,
|
||||
color: color.toLowerCase(),
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function fetchTags({ organizationId }: { organizationId: string })
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTag({ organizationId, name, color, description }: { organizationId: string; name: string; color: string; description: string }) {
|
||||
export async function createTag({ organizationId, name, color, description = '' }: { organizationId: string; name: string; color: string; description?: string }) {
|
||||
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
|
||||
path: `/api/organizations/${organizationId}/tags`,
|
||||
method: 'POST',
|
||||
@@ -26,7 +26,7 @@ export async function createTag({ organizationId, name, color, description }: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTag({ organizationId, tagId, name, color, description }: { organizationId: string; tagId: string; name: string; color: string; description: string }) {
|
||||
export async function updateTag({ organizationId, tagId, name, color, description = '' }: { organizationId: string; tagId: string; name: string; color: string; description?: string }) {
|
||||
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
|
||||
path: `/api/organizations/${organizationId}/tags/${tagId}`,
|
||||
method: 'PUT',
|
||||
|
||||
@@ -90,8 +90,8 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
||||
|
||||
<Form>
|
||||
<CardContent class="pt-6">
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="name" class="sr-only">
|
||||
{t('user.settings.name.label')}
|
||||
@@ -101,25 +101,25 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder={t('user.settings.name.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={form.submitting}
|
||||
isLoading={form.isSubmitting}
|
||||
class="flex-shrink-0"
|
||||
disabled={field.value?.trim() === props.name}
|
||||
disabled={(field.input as string)?.trim() === props.name}
|
||||
>
|
||||
{t('user.settings.name.update')}
|
||||
</Button>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
|
||||
</CardContent>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -46,7 +46,8 @@ export const WebhookEventsPicker: Component<{ events: WebhookEvent[]; onChange:
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
{/* <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> */}
|
||||
<For each={getEventsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import type { WebhookEvent } from '../webhooks.types';
|
||||
import { setInput } from '@formisch/solid';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
@@ -39,11 +40,11 @@ export const CreateWebhookPage: Component = () => {
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('webhooks.create.form.name.required')),
|
||||
v.nonEmpty(t('webhooks.create.form.name.required')),
|
||||
),
|
||||
url: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('webhooks.create.form.url.required')),
|
||||
v.nonEmpty(t('webhooks.create.form.url.required')),
|
||||
v.url(t('webhooks.create.form.url.invalid')),
|
||||
),
|
||||
@@ -71,68 +72,68 @@ export const CreateWebhookPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder={t('webhooks.create.form.name.placeholder')}
|
||||
{...inputProps}
|
||||
{...field.props}
|
||||
autoFocus
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="url">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['url']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="url"
|
||||
id="url"
|
||||
placeholder={t('webhooks.create.form.url.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="secret">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['secret']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="password"
|
||||
id="secret"
|
||||
placeholder={t('webhooks.create.form.secret.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="events" type="string[]">
|
||||
<Field path={['events']}>
|
||||
{field => (
|
||||
<div>
|
||||
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
|
||||
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
|
||||
<WebhookEventsPicker events={(field.input as WebhookEvent[]) ?? []} onChange={events => setInput(form, { path: ['events'], input: events })} />
|
||||
</div>
|
||||
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
@@ -141,10 +142,12 @@ export const CreateWebhookPage: Component = () => {
|
||||
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
|
||||
{t('webhooks.create.back')}
|
||||
</Button>
|
||||
<Button type="submit" class="ml-2" isLoading={form.submitting}>
|
||||
<Button type="submit" class="ml-2" isLoading={form.isSubmitting}>
|
||||
{t('webhooks.create.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 text-sm">{form.errors?.[0]}</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Webhook } from '../webhooks.types';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import type { Webhook, WebhookEvent } from '../webhooks.types';
|
||||
import { setInput } from '@formisch/solid';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
@@ -53,11 +53,11 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('webhooks.create.form.name.required')),
|
||||
v.nonEmpty(t('webhooks.create.form.name.required')),
|
||||
),
|
||||
url: v.pipe(
|
||||
v.string(),
|
||||
v.string(t('webhooks.create.form.url.required')),
|
||||
v.nonEmpty(t('webhooks.create.form.url.required')),
|
||||
v.url(t('webhooks.create.form.url.invalid')),
|
||||
),
|
||||
@@ -79,44 +79,44 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
|
||||
return (
|
||||
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['name']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder={t('webhooks.create.form.name.placeholder')}
|
||||
{...inputProps}
|
||||
{...field.props}
|
||||
autoFocus
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="url">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['url']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mb-6">
|
||||
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="url"
|
||||
id="url"
|
||||
placeholder={t('webhooks.create.form.url.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="mb-6">
|
||||
<Field name="secret">
|
||||
{(field, inputProps) => (
|
||||
<Field path={['secret']}>
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col mt-4">
|
||||
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -124,9 +124,9 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
|
||||
type="password"
|
||||
id="secret"
|
||||
placeholder={rotateSecret() ? t('webhooks.update.form.secret.placeholder') : t('webhooks.update.form.secret.placeholder-redacted')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
{...field.props}
|
||||
value={field.input}
|
||||
aria-invalid={Boolean(field.errors)}
|
||||
disabled={!rotateSecret()}
|
||||
/>
|
||||
<Show when={!rotateSecret()}>
|
||||
@@ -135,22 +135,22 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field name="events" type="string[]">
|
||||
<Field path={['events']}>
|
||||
{field => (
|
||||
<div>
|
||||
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
|
||||
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
|
||||
<WebhookEventsPicker events={(field.input as WebhookEvent[]) ?? []} onChange={events => setInput(form, { path: ['events'], input: events })} />
|
||||
</div>
|
||||
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
{field.errors && <div class="text-red-500 text-sm">{field.errors[0]}</div>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
@@ -159,7 +159,7 @@ export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
|
||||
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
|
||||
{t('webhooks.update.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" class="ml-2" isLoading={form.submitting}>
|
||||
<Button type="submit" class="ml-2" isLoading={form.isSubmitting}>
|
||||
{t('webhooks.update.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,9 @@ export const WEBHOOK_EVENTS = [
|
||||
events: [
|
||||
'document:created',
|
||||
'document:deleted',
|
||||
'document:updated',
|
||||
'document:tag:added',
|
||||
'document:tag:removed',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/app-server
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#417](https://github.com/papra-hq/papra/pull/417) [`a82ff3a`](https://github.com/papra-hq/papra/commit/a82ff3a755fa1164b4d8ff09b591ed6482af0ccc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - v0.7 release
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-server",
|
||||
"type": "module",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra app server",
|
||||
@@ -33,6 +33,8 @@
|
||||
"@aws-sdk/client-s3": "^3.835.0",
|
||||
"@aws-sdk/lib-storage": "^3.835.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@cadence-mq/core": "^0.1.0",
|
||||
"@cadence-mq/driver-memory": "^0.1.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/friendly-ids": "^0.0.1",
|
||||
"@crowlog/async-context-plugin": "^1.2.1",
|
||||
|
||||
@@ -7,8 +7,8 @@ import { createServer } from './modules/app/server';
|
||||
import { parseConfig } from './modules/config/config';
|
||||
import { createIngestionFolderWatcher } from './modules/ingestion-folders/ingestion-folders.usecases';
|
||||
import { createLogger } from './modules/shared/logger/logger';
|
||||
import { createTaskScheduler } from './modules/tasks/task-scheduler';
|
||||
import { taskDefinitions } from './modules/tasks/tasks.defiitions';
|
||||
import { registerTaskDefinitions } from './modules/tasks/tasks.definitions';
|
||||
import { createTaskServices } from './modules/tasks/tasks.services';
|
||||
|
||||
const logger = createLogger({ namespace: 'app-server' });
|
||||
|
||||
@@ -17,8 +17,8 @@ const { config } = await parseConfig({ env });
|
||||
await ensureLocalDatabaseDirectoryExists({ config });
|
||||
const { db, client } = setupDatabase(config.database);
|
||||
|
||||
const { app } = await createServer({ config, db });
|
||||
const { taskScheduler } = createTaskScheduler({ config, taskDefinitions, tasksArgs: { db } });
|
||||
const taskServices = createTaskServices({ config });
|
||||
const { app } = await createServer({ config, db, taskServices });
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
@@ -37,11 +37,12 @@ if (config.ingestionFolder.isEnabled) {
|
||||
await startWatchingIngestionFolders();
|
||||
}
|
||||
|
||||
taskScheduler.start();
|
||||
await registerTaskDefinitions({ taskServices, db, config });
|
||||
|
||||
taskServices.start();
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
server.close();
|
||||
taskScheduler.stop();
|
||||
client.close();
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseConfig } from '../config/config';
|
||||
import { createEmailsServices } from '../emails/emails.services';
|
||||
import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
|
||||
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import { createTaskServices } from '../tasks/tasks.services';
|
||||
import { createTrackingServices } from '../tracking/tracking.services';
|
||||
import { createAuthEmailsServices } from './auth/auth.emails.services';
|
||||
import { getAuth } from './auth/auth.services';
|
||||
@@ -23,6 +24,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
const trackingServices = createTrackingServices({ config });
|
||||
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }), trackingServices }).auth;
|
||||
const subscriptionsServices = createSubscriptionsServices({ config });
|
||||
const taskServices = partialDeps.taskServices ?? createTaskServices({ config });
|
||||
|
||||
return {
|
||||
config,
|
||||
@@ -31,6 +33,7 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
|
||||
emailsServices,
|
||||
subscriptionsServices,
|
||||
trackingServices,
|
||||
taskServices,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { EmailsServices } from '../emails/emails.services';
|
||||
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
|
||||
import type { TaskServices } from '../tasks/tasks.services';
|
||||
import type { TrackingServices } from '../tracking/tracking.services';
|
||||
import type { Auth } from './auth/auth.services';
|
||||
import type { Session } from './auth/auth.types';
|
||||
@@ -28,6 +29,7 @@ export type GlobalDependencies = {
|
||||
emailsServices: EmailsServices;
|
||||
subscriptionsServices: SubscriptionsServices;
|
||||
trackingServices: TrackingServices;
|
||||
taskServices: TaskServices;
|
||||
};
|
||||
|
||||
export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createError } from '../shared/errors/errors';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { validateFormData, validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
|
||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||
@@ -244,7 +244,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
|
||||
|
||||
await triggerWebhooks({
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:deleted',
|
||||
@@ -479,6 +479,7 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
||||
@@ -489,6 +490,13 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
...updateData,
|
||||
});
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:updated',
|
||||
payload: { documentId, organizationId, ...updateData },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'updated',
|
||||
|
||||
@@ -24,7 +24,7 @@ import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
|
||||
import { createTagsRepository } from '../tags/tags.repository';
|
||||
import { createTrackingServices } from '../tracking/tracking.services';
|
||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
|
||||
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
|
||||
import { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
|
||||
@@ -133,7 +133,7 @@ export async function createDocument({
|
||||
|
||||
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
|
||||
|
||||
await triggerWebhooks({
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:created',
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { defineTask } from '../../tasks/tasks.models';
|
||||
import type { Database } from '../../app/database/database.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { TaskServices } from '../../tasks/tasks.services';
|
||||
import { createLogger } from '../../shared/logger/logger';
|
||||
import { createDocumentsRepository } from '../documents.repository';
|
||||
import { deleteExpiredDocuments } from '../documents.usecases';
|
||||
import { createDocumentStorageService } from '../storage/documents.storage.services';
|
||||
|
||||
export const hardDeleteExpiredDocumentsTaskDefinition = defineTask({
|
||||
name: 'hard-delete-expired-documents',
|
||||
isEnabled: ({ config }) => config.tasks.hardDeleteExpiredDocuments.enabled,
|
||||
cronSchedule: ({ config }) => config.tasks.hardDeleteExpiredDocuments.cron,
|
||||
runOnStartup: ({ config }) => config.tasks.hardDeleteExpiredDocuments.runOnStartup,
|
||||
handler: async ({ db, config, now, logger }) => {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
const logger = createLogger({ namespace: 'documents:tasks:hardDeleteExpiredDocuments' });
|
||||
|
||||
const { deletedDocumentsCount } = await deleteExpiredDocuments({
|
||||
config,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
now,
|
||||
});
|
||||
export async function registerHardDeleteExpiredDocumentsTask({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
const taskName = 'hard-delete-expired-documents';
|
||||
const { cron, runOnStartup } = config.tasks.hardDeleteExpiredDocuments;
|
||||
|
||||
logger.info({ deletedDocumentsCount }, 'Expired documents deleted');
|
||||
},
|
||||
});
|
||||
taskServices.registerTask({
|
||||
taskName,
|
||||
handler: async () => {
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
|
||||
const { deletedDocumentsCount } = await deleteExpiredDocuments({
|
||||
config,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
});
|
||||
|
||||
logger.info({ deletedDocumentsCount }, 'Expired documents deleted');
|
||||
},
|
||||
});
|
||||
|
||||
await taskServices.schedulePeriodicJob({
|
||||
scheduleId: `periodic-${taskName}`,
|
||||
taskName,
|
||||
cron,
|
||||
immediate: runOnStartup,
|
||||
});
|
||||
|
||||
logger.info({ taskName, cron, runOnStartup }, 'Hard delete expired documents task registered');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { defineTask } from '../../tasks/tasks.models';
|
||||
import type { Database } from '../../app/database/database.types';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { TaskServices } from '../../tasks/tasks.services';
|
||||
import { createLogger } from '../../shared/logger/logger';
|
||||
import { createOrganizationsRepository } from '../organizations.repository';
|
||||
|
||||
export const expireInvitationsTaskDefinition = defineTask({
|
||||
name: 'expire-invitations',
|
||||
isEnabled: ({ config }) => config.tasks.expireInvitations.enabled,
|
||||
cronSchedule: ({ config }) => config.tasks.expireInvitations.cron,
|
||||
runOnStartup: ({ config }) => config.tasks.expireInvitations.runOnStartup,
|
||||
handler: async ({ db, now }) => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const logger = createLogger({ namespace: 'organizations:tasks:expireInvitations' });
|
||||
|
||||
await organizationsRepository.updateExpiredPendingInvitationsStatus({ now });
|
||||
},
|
||||
});
|
||||
export async function registerExpireInvitationsTask({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
const taskName = 'expire-invitations';
|
||||
const { cron, runOnStartup } = config.tasks.expireInvitations;
|
||||
|
||||
taskServices.registerTask({
|
||||
taskName,
|
||||
handler: async () => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
await organizationsRepository.updateExpiredPendingInvitationsStatus();
|
||||
|
||||
logger.info('Updated expired pending invitations status');
|
||||
},
|
||||
});
|
||||
|
||||
await taskServices.schedulePeriodicJob({
|
||||
scheduleId: `periodic-${taskName}`,
|
||||
taskName,
|
||||
cron,
|
||||
immediate: runOnStartup,
|
||||
});
|
||||
|
||||
logger.info({ taskName, cron, runOnStartup }, 'Update expired pending invitations status task registered');
|
||||
}
|
||||
|
||||
@@ -11,3 +11,9 @@ export const createTagAlreadyExistsError = createErrorFactory({
|
||||
code: 'tags.already_exists',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
export const createTagNotFoundError = createErrorFactory({
|
||||
message: 'Tag not found',
|
||||
code: 'tags.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createTagsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationTags,
|
||||
getTagById,
|
||||
createTag,
|
||||
deleteTag,
|
||||
updateTag,
|
||||
@@ -50,6 +51,20 @@ async function getOrganizationTags({ organizationId, db }: { organizationId: str
|
||||
return { tags };
|
||||
}
|
||||
|
||||
async function getTagById({ tagId, organizationId, db }: { tagId: string; organizationId: string; db: Database }) {
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tagsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(tagsTable.id, tagId),
|
||||
eq(tagsTable.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
|
||||
return { tag };
|
||||
}
|
||||
|
||||
async function createTag({ tag, db }: { tag: DbInsertableTag; db: Database }) {
|
||||
const [result, error] = await safely(db.insert(tagsTable).values(tag).returning());
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@ import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { createDocumentActivityRepository } from '../documents/document-activity/document-activity.repository';
|
||||
import { deferRegisterDocumentActivityLog } from '../documents/document-activity/document-activity.usecases';
|
||||
import { createDocumentNotFoundError } from '../documents/documents.errors';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { documentIdSchema } from '../documents/documents.schemas';
|
||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
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';
|
||||
|
||||
@@ -161,12 +166,34 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.addTagToDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:added',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'tagged',
|
||||
@@ -197,12 +224,34 @@ function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
|
||||
const tagsRepository = createTagsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [{ document }, { tag }] = await Promise.all([
|
||||
documentsRepository.getDocumentById({ organizationId, documentId }),
|
||||
tagsRepository.getTagById({ tagId, organizationId }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
throw createDocumentNotFoundError();
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
throw createTagNotFoundError();
|
||||
}
|
||||
|
||||
await tagsRepository.removeTagFromDocument({ tagId, documentId });
|
||||
|
||||
deferTriggerWebhooks({
|
||||
webhookRepository,
|
||||
organizationId,
|
||||
event: 'document:tag:removed',
|
||||
payload: { documentId, organizationId, tagId, tagName: tag.name },
|
||||
});
|
||||
|
||||
deferRegisterDocumentActivityLog({
|
||||
documentId,
|
||||
event: 'untagged',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { TaskDefinition } from './tasks.models';
|
||||
import cron from 'node-cron';
|
||||
import { createLogger, wrapWithLoggerContext } from '../shared/logger/logger';
|
||||
import { generateId } from '../shared/random/ids';
|
||||
|
||||
export { createTaskScheduler };
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:scheduler' });
|
||||
|
||||
function createTaskScheduler({
|
||||
config,
|
||||
taskDefinitions,
|
||||
tasksArgs,
|
||||
}: {
|
||||
config: Config;
|
||||
taskDefinitions: TaskDefinition[];
|
||||
tasksArgs: { db: Database };
|
||||
}) {
|
||||
const scheduledTasks = taskDefinitions.map((taskDefinition) => {
|
||||
const isEnabled = taskDefinition.getIsEnabled({ config });
|
||||
const cronSchedule = taskDefinition.getCronSchedule({ config });
|
||||
const runOnStartup = taskDefinition.getRunOnStartup({ config });
|
||||
|
||||
if (!isEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronSchedule,
|
||||
async () => wrapWithLoggerContext(
|
||||
{
|
||||
taskId: generateId({ prefix: 'task' }),
|
||||
taskName: taskDefinition.taskName,
|
||||
},
|
||||
async () => taskDefinition.run({ ...tasksArgs, config }),
|
||||
),
|
||||
{
|
||||
scheduled: false,
|
||||
runOnInit: runOnStartup,
|
||||
},
|
||||
);
|
||||
|
||||
return { job: task, taskName: taskDefinition.taskName };
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
taskScheduler: {
|
||||
scheduledTasks,
|
||||
start() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.start();
|
||||
logger.debug({ taskName }, 'Task scheduled');
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
scheduledTasks.forEach(({ taskName, job }) => {
|
||||
job.stop();
|
||||
logger.debug({ taskName }, 'Task unscheduled');
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
|
||||
export const tasksConfig = {
|
||||
persistence: {
|
||||
driver: {
|
||||
doc: 'The driver to use for the tasks persistence',
|
||||
schema: z.enum(['memory']),
|
||||
default: 'memory',
|
||||
env: 'TASKS_PERSISTENCE_DRIVER',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
hardDeleteExpiredDocuments: {
|
||||
enabled: {
|
||||
doc: 'Whether the task to hard delete expired "soft deleted" documents is enabled',
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { hardDeleteExpiredDocumentsTaskDefinition } from '../documents/tasks/hard-delete-expired-documents.task';
|
||||
import { expireInvitationsTaskDefinition } from '../organizations/tasks/expire-invitations.task';
|
||||
|
||||
export const taskDefinitions = [
|
||||
hardDeleteExpiredDocumentsTaskDefinition,
|
||||
expireInvitationsTaskDefinition,
|
||||
];
|
||||
10
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal file
10
apps/papra-server/src/modules/tasks/tasks.definitions.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { TaskServices } from './tasks.services';
|
||||
import { registerHardDeleteExpiredDocumentsTask } from '../documents/tasks/hard-delete-expired-documents.task';
|
||||
import { registerExpireInvitationsTask } from '../organizations/tasks/expire-invitations.task';
|
||||
|
||||
export async function registerTaskDefinitions({ taskServices, db, config }: { taskServices: TaskServices; db: Database; config: Config }) {
|
||||
await registerHardDeleteExpiredDocumentsTask({ taskServices, db, config });
|
||||
await registerExpireInvitationsTask({ taskServices, db, config });
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export { defineTask };
|
||||
|
||||
export type TaskDefinition = ReturnType<typeof defineTask>;
|
||||
|
||||
function defineTask({
|
||||
name: taskName,
|
||||
cronSchedule,
|
||||
isEnabled,
|
||||
runOnStartup = false,
|
||||
handler,
|
||||
logger: taskLogger = createLogger({ namespace: `tasks:${taskName}` }),
|
||||
}: {
|
||||
name: string;
|
||||
isEnabled: boolean | ((args: { config: Config }) => boolean);
|
||||
cronSchedule: string | ((args: { config: Config }) => string);
|
||||
runOnStartup?: boolean | ((args: { config: Config }) => boolean);
|
||||
handler: (handlerArgs: { db: Database; config: Config; logger: Logger; now: Date }) => Promise<void>;
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const run = async ({
|
||||
getNow = () => new Date(),
|
||||
logger = taskLogger,
|
||||
...handlerArgs
|
||||
}: {
|
||||
db: Database;
|
||||
config: Config;
|
||||
getNow?: () => Date;
|
||||
logger?: Logger;
|
||||
}) => {
|
||||
const startedAt = getNow();
|
||||
|
||||
try {
|
||||
logger.debug({ taskName, startedAt }, 'Task started');
|
||||
|
||||
await handler({ ...handlerArgs, logger, now: getNow() });
|
||||
|
||||
const durationMs = getNow().getTime() - startedAt.getTime();
|
||||
logger.info({ taskName, durationMs, startedAt }, 'Task completed');
|
||||
} catch (error) {
|
||||
logger.error({ error, taskName, startedAt }, 'Task failed');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
taskName,
|
||||
run,
|
||||
getIsEnabled: (args: { config: Config }) => (isFunction(isEnabled) ? isEnabled(args) : isEnabled),
|
||||
getCronSchedule: (args: { config: Config }) => (isFunction(cronSchedule) ? cronSchedule(args) : cronSchedule),
|
||||
getRunOnStartup: (args: { config: Config }) => (isFunction(runOnStartup) ? runOnStartup(args) : runOnStartup),
|
||||
};
|
||||
}
|
||||
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
28
apps/papra-server/src/modules/tasks/tasks.services.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import { createCadence } from '@cadence-mq/core';
|
||||
import { createMemoryDriver } from '@cadence-mq/driver-memory';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
|
||||
export type TaskServices = ReturnType<typeof createTaskServices>;
|
||||
|
||||
const logger = createLogger({ namespace: 'tasks:services' });
|
||||
|
||||
export function createTaskServices({ config }: { config: Config }) {
|
||||
const workerId = config.tasks.worker.id ?? 'default';
|
||||
|
||||
const driver = createMemoryDriver();
|
||||
const cadence = createCadence({ driver });
|
||||
|
||||
return {
|
||||
...cadence,
|
||||
start: () => {
|
||||
const worker = cadence.createWorker({ workerId });
|
||||
|
||||
worker.start();
|
||||
|
||||
logger.info({ workerId }, 'Task worker started');
|
||||
|
||||
return worker;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { WebhookRepository } from './webhook.repository';
|
||||
import type { Webhook } from './webhooks.types';
|
||||
import { triggerWebhook as triggerWebhookServiceImpl } from '@papra/webhooks';
|
||||
import pLimit from 'p-limit';
|
||||
import { createDeferable } from '../shared/async/defer';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { createWebhookNotFoundError } from './webhook.errors';
|
||||
|
||||
@@ -107,6 +108,8 @@ export async function triggerWebhooks({
|
||||
);
|
||||
}
|
||||
|
||||
export const deferTriggerWebhooks = createDeferable(triggerWebhooks);
|
||||
|
||||
export async function triggerWebhook({
|
||||
webhook,
|
||||
webhookRepository,
|
||||
|
||||
@@ -16,6 +16,7 @@ COPY pnpm-workspace.yaml ./
|
||||
COPY apps/papra-client/package.json apps/papra-client/package.json
|
||||
COPY apps/papra-server/package.json apps/papra-server/package.json
|
||||
COPY packages/webhooks/package.json packages/webhooks/package.json
|
||||
COPY packages/lecture/package.json packages/lecture/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ COPY pnpm-workspace.yaml ./
|
||||
COPY apps/papra-client/package.json apps/papra-client/package.json
|
||||
COPY apps/papra-server/package.json apps/papra-server/package.json
|
||||
COPY packages/webhooks/package.json packages/webhooks/package.json
|
||||
COPY packages/lecture/package.json packages/lecture/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.5.0",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"eslint": "catalog:",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.5.0",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Volutpat massa enim mi lectys auisque faucibus sapien parturigng
|
||||
aliquet. Pulvinar vehicula cura nostra ultricies aptent sollicitugin
|
||||
egestas posuere justo, Hendrerit sollicitudin mus amet condimentum
|
||||
feugiat maecenas sit iacyis himenaeos. Tacit ultrices purgs posuere
|
||||
lacinia porta nisi varius placerat Porta. Sagitts ligula in vel egestas
|
||||
natoque feugiat ligula omare soos.
|
||||
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
BIN
packages/lecture/fixtures/010-image-only-pdf/010.input.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
import type { PartialExtractorConfig } from '../../src/types';
|
||||
|
||||
export const config: PartialExtractorConfig = {
|
||||
tesseract: {
|
||||
languages: ['fra'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
EF: Look Scanned
|
||||
Comment Utiliser Look Scanned pour
|
||||
Numériser vos Documents
|
||||
Look Scanned vous permet de transformer facilement vos
|
||||
documents en versions numérisées d'aspect professionnel. Voici
|
||||
comment procéder :
|
||||
Importez votre Fichier
|
||||
Cliquez sur le bouton "Importer un Fichier" ou glissez-déposez
|
||||
directement votre document sur la page. Look Scanned prend en
|
||||
charge de nombreux formats : PDF, images (JPG, PNG), DOCX, PPTX,
|
||||
Excel, Markdown, HTML et TXT. Dès que votre fichier est importé, un
|
||||
aperçu s'affiche instantanément pour vous permettre d'ajuster les
|
||||
effets.
|
||||
Personnalisez l'Effet de Numérisation
|
||||
Une fois votre fichier importé, vous pouvez personnaliser les effets
|
||||
selon vos besoins. Ajustez l'angle d'inclinaison, la luminosité, le
|
||||
contraste et le niveau de flou pour obtenir l'aspect d'un véritable
|
||||
document numérisé. Chaque modification est visible en temps réel
|
||||
dans l'aperçu, vous permettant d'obtenir exactement le résultat
|
||||
souhaité.
|
||||
Look Scanned traite les documents de plusieurs pages en maintenant
|
||||
une apparence cohérente sur l'ensemble du document.
|
||||
Téléchargez votre Document
|
||||
Une fois satisfait du résultat, cliquez sur "Générer le Document
|
||||
Numérisé”. Le traitement ne prend que quelques secondes. Vous
|
||||
pourrez ensuite télécharger votre fichier en cliquant sur
|
||||
"Télécharger". Tout le processus s'effectue localement sur votre
|
||||
|
||||
appareil et nous ne conservons aucun contenu, garantissant ainsi la
|
||||
confidentialité de vos documents.
|
||||
|
||||
Conseils d'Utilisation
|
||||
|
||||
Look Scanned offre une solution rapide et efficace pour créer des
|
||||
documents à l'aspect authentiquement numérisé, sans installation de
|
||||
logiciel. Rendez-vous sur lookscanned.io pour donner un aspect
|
||||
professionnel à vos documents !
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
"name": "@papra/lecture",
|
||||
"type": "module",
|
||||
"version": "0.0.7",
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "A simple library to extract text from files",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "MIT",
|
||||
@@ -40,15 +40,15 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"generate-fixtures": "vitest --update",
|
||||
"prepare": "pnpm run build",
|
||||
"build": "unbuild",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"sharp": "^0.32.6",
|
||||
"tesseract.js": "^6.0.0",
|
||||
"unpdf": "^0.12.1"
|
||||
"unpdf": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
@@ -58,7 +58,7 @@
|
||||
"mime": "^4.0.6",
|
||||
"tinyglobby": "^0.2.10",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.3.1",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('extractors usecases', () => {
|
||||
|
||||
for (const fixture of fixturesDir) {
|
||||
// use test.concurrent to run the tests in parallel -> need to use the provided expect
|
||||
test.concurrent(`fixture ${fixture}`, async ({ expect }) => {
|
||||
test(`fixture ${fixture}`, { timeout: 10_000, concurrent: true }, async ({ expect }) => {
|
||||
const fixtureFilesPaths = await glob([`${fixture}/*`]);
|
||||
const inputFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.input\.\w+$/));
|
||||
const configFilePath = fixtureFilesPaths.find(name => name.match(/\/\d{3}\.config\.ts$/));
|
||||
|
||||
@@ -2,6 +2,17 @@ import { Buffer } from 'node:buffer';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
|
||||
export async function extractTextFromImage(maybeArrayBuffer: ArrayBuffer | Buffer, { languages }: { languages: string[] }) {
|
||||
const buffer = maybeArrayBuffer instanceof ArrayBuffer ? Buffer.from(maybeArrayBuffer) : maybeArrayBuffer;
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export const imageExtractorDefinition = defineTextExtractor({
|
||||
name: 'image',
|
||||
mimeTypes: [
|
||||
@@ -13,13 +24,8 @@ export const imageExtractorDefinition = defineTextExtractor({
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const content = await extractTextFromImage(arrayBuffer, { languages });
|
||||
|
||||
const worker = await createWorker(languages);
|
||||
|
||||
const { data: { text } } = await worker.recognize(buffer);
|
||||
await worker.terminate();
|
||||
|
||||
return { content: text };
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { extractText } from 'unpdf';
|
||||
import sharp from 'sharp';
|
||||
import { extractImages, extractText, getDocumentProxy } from 'unpdf';
|
||||
import { defineTextExtractor } from '../extractors.models';
|
||||
import { extractTextFromImage } from './img.extractor';
|
||||
|
||||
export const pdfExtractorDefinition = defineTextExtractor({
|
||||
name: 'pdf',
|
||||
mimeTypes: ['application/pdf'],
|
||||
extract: async ({ arrayBuffer }) => {
|
||||
const { text } = await extractText(arrayBuffer, { mergePages: true });
|
||||
extract: async ({ arrayBuffer, config }) => {
|
||||
const { languages } = config.tesseract;
|
||||
|
||||
return { content: text };
|
||||
const pdf = await getDocumentProxy(arrayBuffer);
|
||||
|
||||
const { text, totalPages } = await extractText(pdf, { mergePages: true });
|
||||
|
||||
if (text && text.trim().length > 0) {
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
const imageTexts = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const images = await extractImages(pdf, i);
|
||||
|
||||
for (const image of images) {
|
||||
const imageBuffer = await sharp(image.data, {
|
||||
raw: { width: image.width, height: image.height, channels: image.channels },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const imageText = await extractTextFromImage(imageBuffer, { languages });
|
||||
imageTexts.push(imageText);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: imageTexts.join('\n') };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,14 +42,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"ofetch": "^1.4.1",
|
||||
"tsee": "^1.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"standardwebhooks": "^1.0.0",
|
||||
"typescript": "catalog:",
|
||||
"unbuild": "^3.5.0",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,25 @@ export function createInvalidSignatureError() {
|
||||
return Object.assign(
|
||||
new Error('[Papra Webhooks] Invalid signature'),
|
||||
{
|
||||
code: 'INVALID_SIGNATURE',
|
||||
code: 'webhook.invalid_signature',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createUnsupportedSignatureVersionError() {
|
||||
return Object.assign(
|
||||
new Error('[Papra Webhooks] Unsupported signature version, supported versions are "v1"'),
|
||||
{
|
||||
code: 'webhook.unsupported_signature_version',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createInvalidSignatureFormatError() {
|
||||
return Object.assign(
|
||||
new Error('[Papra Webhooks] Invalid signature format, unprocessable signature'),
|
||||
{
|
||||
code: 'webhook.invalid_signature_format',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
import type { BuildWebhookEventPayload, WebhookEvents, WebhookPayloads } from '../webhooks.types';
|
||||
import type { StandardWebhookEventPayload, WebhookEvents } from '../webhooks.types';
|
||||
import { EventEmitter } from 'tsee';
|
||||
import { verifySignature } from '../signature';
|
||||
import { parseBody } from '../webhooks.models';
|
||||
import { createInvalidSignatureError } from './handler.errors';
|
||||
|
||||
function handleError({ error }: { error: unknown }) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createInvalidSignatureError();
|
||||
}
|
||||
|
||||
export function createWebhooksHandler({
|
||||
secret,
|
||||
onInvalidSignature = () => {
|
||||
createInvalidSignatureError();
|
||||
},
|
||||
onError = handleError,
|
||||
}: {
|
||||
secret: string;
|
||||
onInvalidSignature?: ({ bodyBuffer, signature }: { bodyBuffer: ArrayBuffer; signature: string }) => void | Promise<void>;
|
||||
onError?: (args: { body: string; signature: string; webhookId: string; timestamp: string; error: unknown }) => void | Promise<void>;
|
||||
}) {
|
||||
const eventEmitter = new EventEmitter<WebhookEvents & { '*': (payload: BuildWebhookEventPayload<WebhookPayloads>) => void }>();
|
||||
const eventEmitter = new EventEmitter<WebhookEvents & { '*': (payload: StandardWebhookEventPayload) => void }>();
|
||||
|
||||
return {
|
||||
on: eventEmitter.on,
|
||||
ee: eventEmitter,
|
||||
handle: async ({ bodyBuffer, signature }: { bodyBuffer: ArrayBuffer; signature: string }) => {
|
||||
const isValid = await verifySignature({ bodyBuffer, signature, secret });
|
||||
handle: async ({ body, signature, webhookId, timestamp }: { body: string; signature: string; webhookId: string; timestamp: string }) => {
|
||||
try {
|
||||
const isValid = await verifySignature({ serializedPayload: body, signature, secret, webhookId, timestamp });
|
||||
|
||||
if (!isValid) {
|
||||
await onInvalidSignature({ bodyBuffer, signature });
|
||||
return;
|
||||
if (!isValid) {
|
||||
throw createInvalidSignatureError();
|
||||
}
|
||||
|
||||
const parsedBody = parseBody(body);
|
||||
const { type } = parsedBody;
|
||||
|
||||
eventEmitter.emit(type, parsedBody as any);
|
||||
eventEmitter.emit('*', parsedBody);
|
||||
} catch (error) {
|
||||
await onError({ body, signature, webhookId, timestamp, error });
|
||||
}
|
||||
|
||||
const payload = parseBody(bodyBuffer.toString());
|
||||
const { event } = payload;
|
||||
|
||||
eventEmitter.emit(event, payload as any);
|
||||
eventEmitter.emit('*', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createWebhooksHandler } from './handler/handler.services';
|
||||
export { EVENT_NAMES, type EventName } from './webhooks.constants';
|
||||
export { triggerWebhook } from './webhooks.services';
|
||||
export type { WebhookEventPayload, WebhookEvents, WebhookPayload, WebhookPayloads } from './webhooks.types';
|
||||
export type { StandardWebhookEventPayload, WebhookEvents, WebhookPayload, WebhookPayloads } from './webhooks.types';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { Webhook } from 'standardwebhooks';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createInvalidSignatureFormatError, createUnsupportedSignatureVersionError } from './handler/handler.errors';
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer, signBody, verifySignature } from './signature';
|
||||
|
||||
const arrayBuffer = (str: string) => new TextEncoder().encode(str).buffer as ArrayBuffer;
|
||||
@@ -6,25 +9,103 @@ const arrayBuffer = (str: string) => new TextEncoder().encode(str).buffer as Arr
|
||||
describe('signature', () => {
|
||||
describe('signBody', () => {
|
||||
test('a buffer can be signed with a secret, the resulting signature is a base64 encoded string', async () => {
|
||||
const bodyBuffer = arrayBuffer('test');
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
|
||||
const { signature } = await signBody({ bodyBuffer, secret });
|
||||
const { signature } = await signBody({ serializedPayload, webhookId, timestamp, secret });
|
||||
|
||||
expect(signature).to.equal('2yIt56m6njKnw7VCoPEYRQE1jSIxyuYutt8/c1ezh9M=');
|
||||
expect(signature).to.equal('v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature', () => {
|
||||
test('verify that the signature of a buffer has been created with a given secret', async () => {
|
||||
const bodyBuffer = arrayBuffer('test');
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
const signature = '2yIt56m6njKnw7VCoPEYRQE1jSIxyuYutt8/c1ezh9M=';
|
||||
const signature = 'v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=';
|
||||
|
||||
const result = await verifySignature({ bodyBuffer, signature, secret });
|
||||
const result = await verifySignature({ serializedPayload, webhookId, timestamp, signature, secret });
|
||||
|
||||
expect(result).to.equal(true);
|
||||
});
|
||||
|
||||
test('an error is thrown when the version is not supported', async () => {
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
const signature = 'v2,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=';
|
||||
|
||||
expect(verifySignature({ serializedPayload, webhookId, timestamp, signature, secret })).rejects.toThrow(createUnsupportedSignatureVersionError());
|
||||
});
|
||||
|
||||
test('an error is thrown when the signature is not valid', async () => {
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
const signature = '';
|
||||
|
||||
expect(verifySignature({ serializedPayload, webhookId, timestamp, signature, secret })).rejects.toThrow(createInvalidSignatureFormatError());
|
||||
});
|
||||
});
|
||||
|
||||
describe('standardwebhooks compatibility', () => {
|
||||
// Because standardwebhooks uses hardcoded Date.now()
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('a signed payload can be verified using the "standardwebhooks" package', async () => {
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
|
||||
// Because standardwebhooks uses hardcoded Date.now() to check for webhook expiration...
|
||||
vi.setSystemTime(new Date(Number(timestamp) * 1000));
|
||||
|
||||
const webhook = new Webhook(Buffer.from(secret).toString('base64'));
|
||||
|
||||
const result = await webhook.verify(serializedPayload, {
|
||||
'webhook-id': webhookId,
|
||||
'webhook-timestamp': timestamp,
|
||||
'webhook-signature': 'v1,POSJo83MmyWmTh3NJOtEpBZSn+CmdpjHSS05p3wYAVE=',
|
||||
});
|
||||
|
||||
expect(result).to.eql({
|
||||
event: 'foo.bar',
|
||||
payload: { biz: 'baz' },
|
||||
now: '2025-07-25T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('the signature is the same as the one generated by the "standardwebhooks" package', async () => {
|
||||
const payload = { event: 'foo.bar', payload: { biz: 'baz' }, now: new Date('2025-07-25') };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const webhookId = 'msg_a1hm8ojetdjhf5boqd2hz244';
|
||||
const timestamp = '1753390766';
|
||||
const secret = 'secret-key';
|
||||
|
||||
const { signature } = await signBody({ serializedPayload, webhookId, timestamp, secret });
|
||||
|
||||
const standardWebhookSignature = new Webhook(Buffer.from(secret).toString('base64')).sign(webhookId, new Date(Number(timestamp) * 1000), serializedPayload);
|
||||
|
||||
expect(standardWebhookSignature).to.equal(signature);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayBufferToBase64', () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createInvalidSignatureFormatError, createUnsupportedSignatureVersionError } from './handler/handler.errors';
|
||||
|
||||
const WEBHOOK_SIGNATURE_HMAC_VERSION = 'v1';
|
||||
|
||||
export function arrayBufferToBase64(arrayBuffer: ArrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
@@ -6,37 +10,74 @@ export function base64ToArrayBuffer(base64: string) {
|
||||
return new Uint8Array(atob(base64).split('').map(char => char.charCodeAt(0))).buffer;
|
||||
}
|
||||
|
||||
function createSignaturePayload({
|
||||
serializedPayload,
|
||||
webhookId,
|
||||
timestamp,
|
||||
}: {
|
||||
serializedPayload: string;
|
||||
webhookId: string;
|
||||
timestamp: string;
|
||||
}) {
|
||||
return `${webhookId}.${timestamp}.${serializedPayload}`;
|
||||
}
|
||||
|
||||
async function hmacSign({ secret, payload }: { secret: string; payload: string }) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
return crypto.subtle.sign('HMAC', key, encoder.encode(payload));
|
||||
}
|
||||
|
||||
export async function signBody({
|
||||
bodyBuffer,
|
||||
serializedPayload,
|
||||
webhookId,
|
||||
timestamp,
|
||||
secret,
|
||||
}: {
|
||||
bodyBuffer: ArrayBuffer;
|
||||
serializedPayload: string;
|
||||
webhookId: string;
|
||||
timestamp: string;
|
||||
secret: string;
|
||||
}) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp });
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, bodyBuffer);
|
||||
const signatureBase64 = arrayBufferToBase64(signature);
|
||||
const rawSignature = await hmacSign({ secret, payload });
|
||||
const signatureBase64 = arrayBufferToBase64(rawSignature);
|
||||
const signature = `${WEBHOOK_SIGNATURE_HMAC_VERSION},${signatureBase64}`;
|
||||
|
||||
return { signature: signatureBase64 };
|
||||
return { signature };
|
||||
}
|
||||
|
||||
export async function verifySignature({
|
||||
bodyBuffer,
|
||||
serializedPayload,
|
||||
webhookId,
|
||||
timestamp,
|
||||
signature: base64Signature,
|
||||
secret,
|
||||
}: {
|
||||
bodyBuffer: ArrayBuffer;
|
||||
serializedPayload: string;
|
||||
webhookId: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
secret: string;
|
||||
}): Promise<boolean> {
|
||||
const [version, signature] = base64Signature.split(',', 2);
|
||||
|
||||
if (!signature || !version) {
|
||||
throw createInvalidSignatureFormatError();
|
||||
}
|
||||
|
||||
if (version !== WEBHOOK_SIGNATURE_HMAC_VERSION) {
|
||||
throw createUnsupportedSignatureVersionError();
|
||||
}
|
||||
|
||||
const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp });
|
||||
|
||||
const signatureBuffer = base64ToArrayBuffer(signature);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
||||
|
||||
const signatureBuffer = base64ToArrayBuffer(base64Signature);
|
||||
|
||||
return crypto.subtle.verify('HMAC', key, signatureBuffer, bodyBuffer);
|
||||
return crypto.subtle.verify('HMAC', key, signatureBuffer, encoder.encode(payload));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const EVENT_NAMES = [
|
||||
'document:created',
|
||||
'document:deleted',
|
||||
'document:updated',
|
||||
'document:tag:added',
|
||||
'document:tag:removed',
|
||||
] as const;
|
||||
|
||||
export type EventName = (typeof EVENT_NAMES)[number];
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { WebhookEventPayload, WebhookPayloads } from './webhooks.types';
|
||||
import type { StandardWebhookEventPayload, WebhookPayloads } from './webhooks.types';
|
||||
|
||||
export function serializeBody({ now = new Date(), ...payload }: { now?: Date } & WebhookPayloads) {
|
||||
const body: WebhookEventPayload = {
|
||||
...payload,
|
||||
timestampMs: now.getTime(),
|
||||
export function serializeBody<T extends WebhookPayloads>({ now = new Date(), payload, event }: { now?: Date; payload: T['payload']; event: T['event'] }) {
|
||||
const body: StandardWebhookEventPayload = {
|
||||
data: payload,
|
||||
type: event,
|
||||
timestamp: now.toISOString(),
|
||||
};
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
export function parseBody(body: string) {
|
||||
return JSON.parse(body) as WebhookEventPayload;
|
||||
return JSON.parse(body) as StandardWebhookEventPayload;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebhookPayloads } from './webhooks.types';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ofetch } from 'ofetch';
|
||||
import { signBody } from './signature';
|
||||
import { serializeBody } from './webhooks.models';
|
||||
@@ -9,7 +10,7 @@ export async function webhookHttpClient({
|
||||
}: {
|
||||
url: string;
|
||||
method: string;
|
||||
body: ArrayBuffer;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}) {
|
||||
const response = await ofetch.raw<unknown>(url, {
|
||||
@@ -23,39 +24,43 @@ export async function webhookHttpClient({
|
||||
};
|
||||
}
|
||||
|
||||
export async function triggerWebhook({
|
||||
export async function triggerWebhook<T extends WebhookPayloads>({
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
httpClient = webhookHttpClient,
|
||||
now = new Date(),
|
||||
...payload
|
||||
|
||||
payload,
|
||||
event,
|
||||
webhookId = `msg_${createId()}`,
|
||||
}: {
|
||||
webhookUrl: string;
|
||||
webhookSecret?: string | null;
|
||||
httpClient?: typeof webhookHttpClient;
|
||||
payload: T['payload'];
|
||||
now?: Date;
|
||||
} & WebhookPayloads) {
|
||||
const { event } = payload;
|
||||
event: T['event'];
|
||||
webhookId?: string;
|
||||
}) {
|
||||
const timestamp = Math.floor(now.getTime() / 1000).toString();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'papra-webhook-client',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Event': event,
|
||||
'user-agent': 'papra-webhook-client',
|
||||
'content-type': 'application/json',
|
||||
'webhook-id': webhookId,
|
||||
'webhook-timestamp': timestamp,
|
||||
};
|
||||
|
||||
const body = serializeBody({ ...payload, now });
|
||||
const bodyBuffer = new TextEncoder().encode(body).buffer as ArrayBuffer;
|
||||
const body = serializeBody({ event, payload, now });
|
||||
|
||||
if (webhookSecret) {
|
||||
const { signature } = await signBody({ bodyBuffer, secret: webhookSecret });
|
||||
headers['X-Signature'] = signature;
|
||||
const { signature } = await signBody({ serializedPayload: body, webhookId, timestamp, secret: webhookSecret });
|
||||
headers['webhook-signature'] = signature;
|
||||
}
|
||||
|
||||
const { responseData, responseStatus } = await httpClient({
|
||||
url: webhookUrl,
|
||||
method: 'POST',
|
||||
body: bodyBuffer,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,14 +21,43 @@ export type DocumentDeletedPayload = WebhookPayload<
|
||||
}
|
||||
>;
|
||||
|
||||
export type WebhookPayloads = DocumentCreatedPayload | DocumentDeletedPayload;
|
||||
export type DocumentUpdatedPayload = WebhookPayload<
|
||||
'document:updated',
|
||||
{
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DocumentTagAddedPayload = WebhookPayload<
|
||||
'document:tag:added',
|
||||
{
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DocumentTagRemovedPayload = WebhookPayload<
|
||||
'document:tag:removed',
|
||||
{
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type WebhookPayloads = DocumentCreatedPayload | DocumentDeletedPayload | DocumentUpdatedPayload | DocumentTagAddedPayload | DocumentTagRemovedPayload;
|
||||
type ExtractEventName<T> = T extends WebhookPayload<infer E, any> ? E : never;
|
||||
export type BuildWebhookEventPayload<T> = T & { timestampMs: number };
|
||||
export type BuildStandardWebhookEventPayload<T extends WebhookPayloads> = { type: T['event']; timestamp: string; data: T['payload'] };
|
||||
export type BuildWebhookEvents<T extends WebhookPayloads> = {
|
||||
[K in ExtractEventName<T>]: (args: BuildWebhookEventPayload<Extract<T, WebhookPayload<K, any>>>) => void;
|
||||
[K in ExtractEventName<T>]: (args: BuildStandardWebhookEventPayload<Extract<T, WebhookPayload<K, any>>>) => void;
|
||||
};
|
||||
|
||||
export type WebhookEvents = BuildWebhookEvents<WebhookPayloads>;
|
||||
|
||||
export type WebhookEventPayload = BuildWebhookEventPayload<WebhookPayloads>;
|
||||
export type StandardWebhookEventPayload = BuildStandardWebhookEventPayload<WebhookPayloads>;
|
||||
|
||||
1053
pnpm-lock.yaml
generated
1053
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ catalog:
|
||||
vitest: ^3.0.5
|
||||
'@vitest/coverage-v8': ^3.0.2
|
||||
better-auth: ^1.2.8
|
||||
unbuild: ^3.5.0
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
Reference in New Issue
Block a user