Compare commits

..

11 Commits

Author SHA1 Message Date
Corentin Thomasset
e2f0b83863 refactor(client): switched from ModularForms to Formisch lib 2025-07-31 00:15:47 +02:00
Corentin Thomasset
41a113334a refactor(tasks): integrated cadence task services (#436) 2025-07-28 18:30:11 +00:00
Corentin Thomasset
6723baf98a feat(webhooks): add document update and tag events (#432) 2025-07-25 16:46:05 +02:00
Corentin Thomasset
bbe5fe74e2 test(lecture): added fixture test timeout (#431) 2025-07-25 12:56:46 +00:00
Corentin Thomasset
a8cff8cedc refactor(webhooks): updated webhooks signatures and payload to match standard-webhook spec (#430) 2025-07-25 11:29:26 +02:00
Corentin Thomasset
67b3b14cdf feat(lecture): added ocr support for scanned pdf (#429) 2025-07-24 22:21:10 +02:00
Osaf Ali Sayed
ffdae8db56 feat(intake-emails): redesigned intake email list (#412)
* feat(intake-emails): redesigned intake email list

* fix(intake-emails): fix linting

* fix(intake-emails): set drop down menu trigger size same as icon

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-14 13:28:48 +00:00
Edward205
7768840aa4 refactor(i18n): improved Romanian translation (#419)
* added diacritics and improved wording

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-14 11:36:10 +00:00
Corentin Thomasset
dd3862e50c chore(release): update versions (#418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-13 22:45:48 +02:00
Corentin Thomasset
a82ff3a755 chore(docker): add lecture package.json to Dockerfiles (#417) 2025-07-13 20:42:55 +00:00
Corentin Thomasset
d5b00307da chore(dependencies): put unbuild in pnpm catalog (#416) 2025-07-13 20:19:12 +00:00
81 changed files with 1958 additions and 1718 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added diacritics and improved wording for Romanian translation

View File

@@ -0,0 +1,5 @@
---
"@papra/webhooks": minor
---
Breaking change: updated webhooks signatures and payload format to match standard-webhook spec

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Simplified the organization intake email list

View 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

View File

@@ -0,0 +1,7 @@
---
"@papra/app-client": minor
"@papra/app-server": minor
"@papra/webhooks": minor
---
Webhooks invocation is now defered

View File

@@ -0,0 +1,5 @@
---
"@papra/lecture": minor
---
Added support for scanned pdf content extraction

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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'

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>>

View File

@@ -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>

View File

@@ -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),
};
}

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { insert, remove, setValue } from '@modular-forms/solid';
import { 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>

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -4,6 +4,9 @@ export const WEBHOOK_EVENTS = [
events: [
'document:created',
'document:deleted',
'document:updated',
'document:tag:added',
'document:tag:removed',
],
},

View File

@@ -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

View File

@@ -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",

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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,
});

View File

@@ -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());

View File

@@ -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',

View File

@@ -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');
});
},
},
};
}

View File

@@ -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',

View File

@@ -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,
];

View 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 });
}

View File

@@ -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),
};
}

View 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;
},
};
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -49,7 +49,7 @@
"@antfu/eslint-config": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
"unbuild": "^3.5.0",
"unbuild": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -56,7 +56,7 @@
"eslint": "catalog:",
"tsx": "^4.19.3",
"typescript": "catalog:",
"unbuild": "^3.5.0",
"unbuild": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -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.

View File

@@ -0,0 +1,7 @@
import type { PartialExtractorConfig } from '../../src/types';
export const config: PartialExtractorConfig = {
tesseract: {
languages: ['fra'],
},
};

View File

@@ -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 !

View File

@@ -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:"
}
}

View File

@@ -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$/));

View File

@@ -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 };
},
});

View File

@@ -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') };
},
});

View File

@@ -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:"
}
}

View File

@@ -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',
},
);
}

View File

@@ -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);
},
};
}

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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));
}

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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,
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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