Compare commits

..

20 Commits

Author SHA1 Message Date
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

* Update apps/papra-server/src/modules/documents/storage/drivers/az-blob/az-blob.storage-driver.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix lock file

* bugfixes

* fix lint issues

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-28 09:34:28 +02:00
Corentin Thomasset
803c39cbc8 chore(packages): added api sdk package (#270) 2025-04-27 22:08:59 +02:00
Joshua Anderson
096331a4ee feat(server): add support for b2 object storage type (#232)
* feat(b2): add support for b2 object storage type

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

* refactor(b2): use error factories
2025-04-27 21:35:29 +02:00
Corentin Thomasset
59ba9465f6 docs(features): marked tagging rules and folder ingestion as available features (#268) 2025-04-27 13:57:11 +00:00
Corentin Thomasset
a1056702af feat(docs): fixed broken links with auto check (#267) 2025-04-27 13:20:34 +00:00
Corentin Thomasset
fd44897bca fix(documents): hard delete file in storage driver (#266) 2025-04-27 14:52:05 +02:00
Corentin Thomasset
332d836d11 fix(ingestion-folders): added schema validation coercion in config (#265) 2025-04-27 12:11:05 +00:00
Corentin Thomasset
f613198cbd refactor(storage): replace generic error messages with specific file not found errors (#264) 2025-04-27 13:52:47 +02:00
Corentin Thomasset
80491a5a58 chore(deps): update eslint and and eslint config (#260) 2025-04-27 13:43:17 +02:00
Corentin Thomasset
605e21a410 chore(deps): updated pnpm to 10.9.0 in all package.json files (#258) 2025-04-25 13:15:32 +00:00
Corentin Thomasset
dec589b6ed fix(documents): remove incorrect default tab value (#259) 2025-04-25 13:08:39 +00:00
168 changed files with 8913 additions and 994 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

19
.changeset/config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "papra-hq/papra"}
],
"commit": false,
"fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"tag": true,
"version": true
}
}

View File

@@ -26,7 +26,9 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: |
pnpm i --frozen-lockfile
pnpm --filter "@papra/app-server^..." build
- name: Run linters
run: pnpm lint

View File

@@ -0,0 +1,41 @@
name: CI - Api SDK
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-api-sdk:
name: CI - Api SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/api-sdk
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

44
.github/workflows/ci-packages-cli.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI - CLI
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-cli:
name: CI - CLI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/cli
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build related packages
run: cd ../api-sdk && pnpm build
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Webhooks
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-webhooks:
name: CI - Webhooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/webhooks
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -3,7 +3,7 @@ name: Release new versions
on:
push:
tags:
- 'v*.*.*'
- '@papra/app-server@*'
permissions:
contents: read
@@ -15,13 +15,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get release version from tag
if: ${{ github.event_name == 'push' }}
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/@papra/app-server@}" >> $GITHUB_ENV
- name: Get release version from input
if: ${{ github.event_name == 'workflow_dispatch' }}
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

43
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Create Release Pull Request
id: changesets
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: "chore(release): update versions"
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -59,9 +59,9 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- *Coming soon:* **Document sharing**: Share documents with others.

7
apps/docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# @papra/docs
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,6 +1,7 @@
import { env } from 'node:process';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide';
import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
@@ -16,18 +17,18 @@ export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
starlight({
plugins: [starlightThemeRapide()],
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
logo: {
dark: './src/assets/logo-dark.svg',
light: './src/assets/logo-light.svg',
alt: 'Papra Logo',
},
social: {
github: 'https://github.com/papra-hq/papra',
blueSky: 'https://bsky.app/profile/papra.app',
discord: 'https://papra.app/discord',
},
social: [
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
],
expressiveCode: {
themes: ['vitesse-black', 'vitesse-light'],
},

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.3.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -17,10 +18,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.31.0",
"astro": "^5.1.5",
"@astrojs/starlight": "^0.34.2",
"astro": "^5.7.10",
"sharp": "^0.32.5",
"starlight-theme-rapide": "^0.3.0",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {

View File

@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
<Aside type="note">
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
</Aside>
## Prerequisites
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
- a publicly accessible Papra instance
- basic development skills (git and node.js to setup the Email Worker)
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
## How it works

View File

@@ -91,7 +91,7 @@ environment:
## Configuration
You can find the list of all configuration options in the [configuration reference](/docs/configuration-reference), the related variables are prefixed with `INGESTION_FOLDER_`.
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
## Edge cases and behaviors

View File

@@ -0,0 +1,89 @@
---
title: CLI Documentation
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
slug: resources/cli
---
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
## Installation
For the moment, the CLI is only available as an NPM package.
```bash
# using pnpm
pnpm i -g @papra/cli
# or using npm
npm i -g @papra/cli
# or using yarn
yarn add -g @papra/cli
```
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
## Configuration
Before using the CLI, you need to configure it with your API credentials.
### Initial Setup
To initialize the configuration, run:
```bash
papra config init
```
This command will prompt you for:
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
- **API Key**: Your personal API key (can be created in your User Settings)
### Managing Configuration
You can manage your configuration using the following commands:
- `papra config list`: View your current configuration
- `papra config set api-key`: Set or update your API key
- `papra config set api-url`: Set or update your instance URL
- `papra config set default-org-id`: Set a default organization ID
### Organization IDs
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
```bash
papra config set default-org-id <organization-id>
papra documents import <file-path>
# or
papra documents import -o <organization-id> <file-path>
```
## Available Commands
### Importing documents
The `import` command allows you to import a document into your Papra organization.
```bash
papra documents import -o <organization-id> <file-path>
```
## Getting Help
For more information about any command, you can use the `--help` flag:
```bash
papra --help
papra config --help
papra documents --help
```
## About the CLI
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).

View File

@@ -51,9 +51,9 @@ In today's digital world, managing countless important documents efficiently and
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- *Coming soon:* **Document sharing**: Share documents with others.

View File

@@ -35,6 +35,10 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
{
label: 'Resources',
items: [
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',

View File

@@ -0,0 +1,23 @@
# @papra/app-client
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.4.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -63,6 +64,7 @@
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tinyglobby": "^0.2.13",
"tsx": "^4.19.1",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",

View File

@@ -9,7 +9,6 @@ import { render, Suspense } from 'solid-js/web';
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
import { ConfigProvider } from './modules/config/config.provider';
import { DemoIndicator } from './modules/demo/demo.provider';
import { DevTools } from './modules/dev-tools/components/dev-tools.components';
import { I18nProvider } from './modules/i18n/i18n.provider';
import { ConfirmModalProvider } from './modules/shared/confirm';
import { queryClient } from './modules/shared/query/query-client';
@@ -34,7 +33,6 @@ render(
<QueryClientProvider client={queryClient}>
<PageViewTracker />
<IdentifyUser />
<DevTools />
<Suspense>
<I18nProvider>

View File

@@ -77,12 +77,14 @@ layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.integrations: Integrations
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Organization settings
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
tagging-rules.field.name: document name
tagging-rules.field.content: document content
@@ -121,9 +123,6 @@ tagging-rules.form.conditions.no-conditions.title: No conditions
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
tagging-rules.form.conditions.no-conditions.cancel: Cancel
tagging-rules.form.conditions.field.label: Field
tagging-rules.form.conditions.operator.label: Operator
tagging-rules.form.conditions.value.label: Value
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
@@ -132,7 +131,6 @@ tagging-rules.form.tags.min-length: At least one tag to apply is required
tagging-rules.form.tags.add-tag: Create tag
tagging-rules.form.submit: Create rule
tagging-rules.update.title: Update tagging rule
tagging-rules.update.success: Tagging rule updated successfully
tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
@@ -186,23 +184,62 @@ api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Example: My API key'
api-keys.create.form.name.required: Please enter a name for the API key
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.description: Select the permissions for the API key.
api-keys.create.form.permissions.required: Please select at least one permission
api-keys.create.form.submit: Create API key
api-keys.create.created.title: API key created
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
api-keys.list.title: API keys
api-keys.list.description: Manage your API keys here.
api-keys.list.delete: Delete
api-keys.list.create: Create API key
api-keys.list.empty.title: No API keys
api-keys.list.empty.description: Create an API key to access the Papra API.
api-keys.list.card.last-used: Last used
api-keys.list.card.never: Never
api-keys.list.card.created: Created
api-keys.list.card.delete: Delete
api-keys.delete.success: The API key has been deleted successfully
api-keys.delete.confirm.title: Delete API key
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks
webhooks.list.empty.description: Create your first webhook to start receiving events
webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never
webhooks.list.card.created: Created
webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully
webhooks.create.back: Back
webhooks.create.form.submit: Create webhook
webhooks.create.form.name.label: Webhook name
webhooks.create.form.name.placeholder: Enter webhook name
webhooks.create.form.name.required: Name is required
webhooks.create.form.url.label: Webhook URL
webhooks.create.form.url.placeholder: Enter webhook URL
webhooks.create.form.url.required: URL is required
webhooks.create.form.url.invalid: URL is invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Enter webhook secret
webhooks.create.form.events.label: Events
webhooks.create.form.events.required: At least one event is required
webhooks.update.title: Edit webhook
webhooks.update.description: Update your webhook details
webhooks.update.success: Webhook updated successfully
webhooks.update.submit: Update webhook
webhooks.update.cancel: Cancel
webhooks.update.form.secret.placeholder: Enter new secret
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
webhooks.update.form.rotate-secret.button: Rotate secret
webhooks.delete.success: Webhook deleted successfully
webhooks.delete.confirm.title: Delete webhook
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.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted

View File

@@ -77,9 +77,8 @@ layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.integrations: Intégrations
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres de l'organisation
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
@@ -123,9 +122,6 @@ tagging-rules.form.conditions.no-conditions.title: Aucune condition
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
tagging-rules.form.conditions.no-conditions.cancel: Annuler
tagging-rules.form.conditions.field.label: Champ
tagging-rules.form.conditions.operator.label: Opérateur
tagging-rules.form.conditions.value.label: Valeur
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
@@ -134,7 +130,6 @@ tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
tagging-rules.form.tags.add-tag: Créer un tag
tagging-rules.form.submit: Créer la règle
tagging-rules.update.title: Mettre à jour la règle de catégorisation
tagging-rules.update.success: Règle de catégorisation mise à jour avec succès
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
@@ -188,21 +183,18 @@ api-keys.create.form.name.label: Nom
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.description: Sélectionnez les permissions pour la clé API.
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
api-keys.create.form.submit: Créer la clé API
api-keys.create.created.title: Clé API créée
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
api-keys.list.title: Clés API
api-keys.list.description: Gérez vos clés API ici.
api-keys.list.delete: Supprimer
api-keys.list.create: Créer une clé API
api-keys.list.empty.title: Aucune clé API
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
api-keys.list.card.last-used: Dernière utilisation
api-keys.list.card.never: Jamais
api-keys.list.card.created: Créée
api-keys.list.card.delete: Supprimer
api-keys.delete.success: La clé API a été supprimée avec succès
api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.

View File

@@ -1,7 +1,8 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { type Component, createSignal, For } from 'solid-js';
import { createSignal, For } from 'solid-js';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -8,7 +9,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { A } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { type Component, For, Match, Show, Suspense, Switch } from 'solid-js';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
@@ -7,7 +8,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { type Component, createSignal, Show } from 'solid-js';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';

View File

@@ -1,7 +1,8 @@
import type { Component, ComponentProps } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { type Component, type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
const providers = [
{

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { type Component, Suspense } from 'solid-js';
import { Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { match } from 'ts-pattern';
import { useSession } from '../auth.services';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { SsoProviderKey } from '../auth.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -7,7 +8,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { ssoProviders } from '../auth.constants';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -6,7 +7,7 @@ import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs } from '../auth.models';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -5,7 +6,7 @@ import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, onMount } from 'solid-js';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';

View File

@@ -1,11 +1,11 @@
import type { Component } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { onMount } from 'solid-js';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';

View File

@@ -1,11 +1,12 @@
import type { ParentComponent } from 'solid-js';
import type { Config, RuntimePublicConfig } from './config';
import { createQuery } from '@tanstack/solid-query';
import { merge } from 'lodash-es';
import { createContext, Match, Switch, useContext } from 'solid-js';
import { Button } from '../ui/components/button';
import { EmptyState } from '../ui/components/empty';
import { createToast } from '../ui/components/sonner';
import { buildTimeConfig, type Config, type RuntimePublicConfig } from './config';
import { buildTimeConfig } from './config';
import { fetchPublicConfig } from './config.services';
const ConfigContext = createContext<{

View File

@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
import { joinUrlPaths } from '@corentinth/chisels';
import { router } from './demo-api-mock';
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
const matchedRoute = router.lookup(path);

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { buildTimeConfig } from '../config/config';
import { useI18n } from '../i18n/i18n.provider';

View File

@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
return values;
}
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T | null> {
const values = await getValues(storage);
const found = values.find(predicate);

View File

@@ -1,43 +0,0 @@
import type { Component } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { makePersisted } from '@solid-primitives/storage';
import { createSignal, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
export const DevToolsOverlayComponent: Component = () => {
const [isCollapsed, setIsCollapsed] = makePersisted(createSignal<boolean>(true), { name: 'papra-dev-tools-collapsed', storage: localStorage });
return (
<Portal>
<Show
when={!isCollapsed()}
fallback={(
<Button
onClick={() => setIsCollapsed(false)}
variant="secondary"
class="fixed bottom-0 left-50% -translate-x-1/2 z-50 rounded-b-none shadow"
>
<div class="i-tabler-chevron-up size-5" />
</Button>
)}
>
<div class="fixed bottom-0 left-50% -translate-x-1/2 z-50 bg-card rounded-t-xl shadow w-full max-w-500px border border-b-none">
<div class="flex items-center justify-between py-2 px-5">
<span class="text-sm font-medium">
Dev Tools
</span>
<Button
onClick={() => setIsCollapsed(true)}
variant="ghost"
size="icon"
>
<div class="i-tabler-chevron-down size-5" />
</Button>
</div>
</div>
</Show>
</Portal>
);
};

View File

@@ -1,12 +0,0 @@
import type { Component } from 'solid-js';
import { lazy, Show } from 'solid-js';
const DevToolsOverlay = lazy(() => import('./dev-tools-overlay.component').then(m => ({ default: m.DevToolsOverlayComponent })));
export const DevTools: Component = () => {
return (
<Show when={import.meta.env.DEV}>
<DevToolsOverlay />
</Show>
);
};

View File

@@ -50,7 +50,7 @@ type TaskError = {
type Task = TaskSuccess | TaskError | {
file: File;
status: 'pending' | 'uploading' ;
status: 'pending' | 'uploading';
};
export const DocumentUploadProvider: ParentComponent = (props) => {

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { Card } from '@/modules/ui/components/card';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';

View File

@@ -1,6 +1,7 @@
import type { Tag } from '@/modules/tags/tags.types';
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Accessor, Component, Setter } from 'solid-js';
import type { Document } from '../documents.types';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
@@ -12,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
import { For, Match, Show, Switch } from 'solid-js';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -9,7 +10,7 @@ import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { useParams } from '@solidjs/router';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
import { DocumentsPaginatedList } from '../components/documents-list.component';
import { useRestoreDocument } from '../documents.composables';
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';

View File

@@ -1,3 +1,4 @@
import type { Component, JSX } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
@@ -16,8 +17,7 @@ import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { formatBytes, safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
@@ -231,7 +231,7 @@ export const DocumentPage: Component = () => {
<Separator class="my-3" />
<Tabs defaultValue="info" class="w-full" value="content">
<Tabs defaultValue="info" class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { castArray } from 'lodash-es';
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';

View File

@@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises';
import { glob } from 'tinyglobby';
import { describe, expect, test } from 'vitest';
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
@@ -28,4 +30,38 @@ describe('locales', () => {
});
});
}
test('all keys in en.yml must be used in the app (dynamic keys are manually excluded)', async () => {
const srcFileNames = await glob(['src/**/*.{ts,tsx}', '!src/**/*.test.*', '!src/modules/i18n/locales.types.ts'], { cwd: process.cwd() });
// Exclude keys that are used in dynamic contexts
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
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
];
const keys = new Set(
Object
.keys(defaultLocal)
.filter(key => !dynamicKeysMatchers.some(matcher => matcher.test(key))),
);
for (const srcFileName of srcFileNames) {
const fileContent = await readFile(srcFileName, 'utf-8');
for (const key of keys) {
if (fileContent.includes(key)) {
keys.delete(key);
}
}
if (keys.size === 0) {
break;
}
}
expect([...keys]).to.eql([], 'Unused keys found in en.yml, please remove them (or add them to the dynamic keys matchers in locales.test.ts if they are used in dynamic contexts)');
});
});

View File

@@ -75,12 +75,14 @@ export type LocaleKeys =
| 'layout.menu.documents'
| 'layout.menu.tags'
| 'layout.menu.tagging-rules'
| 'layout.menu.integrations'
| 'layout.menu.deleted-documents'
| 'layout.menu.organization-settings'
| 'layout.menu.api-keys'
| 'layout.menu.settings'
| 'layout.menu.account'
| 'layout.menu.general-settings'
| 'layout.menu.intake-emails'
| 'layout.menu.webhooks'
| 'tagging-rules.field.name'
| 'tagging-rules.field.content'
| 'tagging-rules.operator.equals'
@@ -118,9 +120,6 @@ export type LocaleKeys =
| 'tagging-rules.form.conditions.no-conditions.description'
| 'tagging-rules.form.conditions.no-conditions.confirm'
| 'tagging-rules.form.conditions.no-conditions.cancel'
| 'tagging-rules.form.conditions.field.label'
| 'tagging-rules.form.conditions.operator.label'
| 'tagging-rules.form.conditions.value.label'
| 'tagging-rules.form.conditions.value.placeholder'
| 'tagging-rules.form.conditions.value.min-length'
| 'tagging-rules.form.tags.label'
@@ -129,7 +128,6 @@ export type LocaleKeys =
| 'tagging-rules.form.tags.add-tag'
| 'tagging-rules.form.submit'
| 'tagging-rules.update.title'
| 'tagging-rules.update.success'
| 'tagging-rules.update.error'
| 'tagging-rules.update.submit'
| 'tagging-rules.update.cancel'
@@ -178,23 +176,59 @@ export type LocaleKeys =
| 'api-keys.create.form.name.placeholder'
| 'api-keys.create.form.name.required'
| 'api-keys.create.form.permissions.label'
| 'api-keys.create.form.permissions.description'
| 'api-keys.create.form.permissions.required'
| 'api-keys.create.form.submit'
| 'api-keys.create.created.title'
| 'api-keys.create.created.description'
| 'api-keys.list.title'
| 'api-keys.list.description'
| 'api-keys.list.delete'
| 'api-keys.list.create'
| 'api-keys.list.empty.title'
| 'api-keys.list.empty.description'
| 'api-keys.list.card.last-used'
| 'api-keys.list.card.never'
| 'api-keys.list.card.created'
| 'api-keys.list.card.delete'
| 'api-keys.delete.success'
| 'api-keys.delete.confirm.title'
| 'api-keys.delete.confirm.message'
| 'api-keys.delete.confirm.confirm-button'
| 'api-keys.delete.confirm.cancel-button';
| 'api-keys.delete.confirm.cancel-button'
| 'webhooks.list.title'
| 'webhooks.list.description'
| 'webhooks.list.empty.title'
| 'webhooks.list.empty.description'
| 'webhooks.list.create'
| 'webhooks.list.card.last-triggered'
| 'webhooks.list.card.never'
| 'webhooks.list.card.created'
| 'webhooks.create.title'
| 'webhooks.create.description'
| 'webhooks.create.success'
| 'webhooks.create.back'
| 'webhooks.create.form.submit'
| 'webhooks.create.form.name.label'
| 'webhooks.create.form.name.placeholder'
| 'webhooks.create.form.name.required'
| 'webhooks.create.form.url.label'
| 'webhooks.create.form.url.placeholder'
| 'webhooks.create.form.url.required'
| 'webhooks.create.form.url.invalid'
| 'webhooks.create.form.secret.label'
| 'webhooks.create.form.secret.placeholder'
| 'webhooks.create.form.events.label'
| 'webhooks.create.form.events.required'
| 'webhooks.update.title'
| 'webhooks.update.description'
| 'webhooks.update.success'
| 'webhooks.update.submit'
| 'webhooks.update.cancel'
| 'webhooks.update.form.secret.placeholder'
| 'webhooks.update.form.secret.placeholder-redacted'
| 'webhooks.update.form.rotate-secret.button'
| 'webhooks.delete.success'
| 'webhooks.delete.confirm.title'
| 'webhooks.delete.confirm.message'
| 'webhooks.delete.confirm.confirm-button'
| 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.description';

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types';
import { useConfig } from '@/modules/config/config.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -16,8 +17,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
@@ -211,9 +211,9 @@ export const IntakeEmailsPage: Component = () => {
};
return (
<Card class="p-6">
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h2 class="text-base font-bold">Intake Emails</h2>
<h1 class="text-xl font-semibold">Intake Emails</h1>
<p class="text-muted-foreground mt-1">
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
@@ -264,7 +264,7 @@ export const IntakeEmailsPage: Component = () => {
<div class="flex flex-col gap-2">
<For each={intakeEmails()}>
{intakeEmail => (
<div class="flex items-center justify-between border rounded-lg p-4">
<div class="flex items-center justify-between border rounded-lg p-4 bg-card">
<div class="flex items-center gap-4">
<div class="bg-muted size-9 rounded-lg flex items-center justify-center">
<div class={cn('i-tabler-mail size-5', intakeEmail.isEnabled ? 'text-primary' : 'text-muted-foreground')} />
@@ -342,6 +342,6 @@ export const IntakeEmailsPage: Component = () => {
)}
</Show>
</Suspense>
</Card>
</div>
);
};

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';
import { fetchOrganizations } from '../organizations.services';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
import { useUploadDocuments } from '@/modules/documents/documents.composables';
@@ -6,7 +7,7 @@ import { Button } from '@/modules/ui/components/button';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
export const OrganizationPage: Component = () => {
const params = useParams();

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -10,7 +11,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
@@ -164,7 +165,7 @@ export const OrganizationsSettingsPage: Component = () => {
}));
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<Suspense>
<Show when={query.data?.organization}>
{ getOrganization => (

View File

@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createEffect, For, on } from 'solid-js';
import { createEffect, For, on } from 'solid-js';
import { fetchOrganizations } from '../organizations.services';
export const OrganizationsPage: Component = () => {

View File

@@ -1,6 +1,7 @@
import type { HttpClientOptions, ResponseType } from './http-client';
import { buildTimeConfig } from '@/modules/config/config';
import { safely } from '@corentinth/chisels';
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
import { httpClient } from './http-client';
import { isHttpErrorWithStatusCode } from './http-errors';
export async function apiClient<T, R extends ResponseType = 'json'>({

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -11,7 +12,7 @@ import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { type Component, For, Show } from 'solid-js';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { TaggingRule } from '../tagging-rules.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -7,7 +8,7 @@ import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { type Component, For, Match, Show, Switch } from 'solid-js';
import { For, Match, Show, Switch } from 'solid-js';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { type Component, Show } from 'solid-js';
import { Show } from 'solid-js';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { getTaggingRule, updateTaggingRule } from '../tagging-rules.services';

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { Tag } from '../tags.types';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For } from 'solid-js';
import { createSignal, For } from 'solid-js';
import { fetchTags } from '../tags.services';
import { Tag as TagComponent } from './tag.component';

View File

@@ -1,6 +1,7 @@
import type { Component, ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { A } from '@solidjs/router';
import { type Component, type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
type TagProps = {
name?: string;

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -15,7 +16,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { Tag } from '../components/tag.component';
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';

View File

@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { useSession } from '@/modules/auth/auth.services';
import { buildTimeConfig } from '@/modules/config/config';
import { type Component, createEffect } from 'solid-js';
import { createEffect } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const IdentifyUser: Component = () => {

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { useCurrentMatches } from '@solidjs/router';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const PageViewTracker: Component = () => {

View File

@@ -1,7 +1,8 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { cva } from 'class-variance-authority';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export const badgeVariants = cva(
'inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:(outline-none ring-1.5 ring-ring)',

View File

@@ -140,7 +140,7 @@ export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
);
}
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuSubTriggerProps<T> & { class?: string }>;
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuSubTriggerProps<T> & { class?: string }>;
export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
@@ -201,7 +201,7 @@ export function DropdownMenuSubContent<T extends ValidComponent = 'div'>(props:
);
}
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuCheckboxItemProps<T> & { class?: string } >;
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuCheckboxItemProps<T> & { class?: string }>;
export function DropdownMenuCheckboxItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [

View File

@@ -1,5 +1,6 @@
import type { Component, ComponentProps, JSX } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type Component, type ComponentProps, type JSX, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export const EmptyState: Component<{
title: JSX.Element;

View File

@@ -138,9 +138,9 @@ export function NumberFieldDecrementTrigger<T extends ValidComponent = 'button',
);
}
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps< NumberFieldIncrementTriggerProps<T> & { class?: string }>;
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps<NumberFieldIncrementTriggerProps<T> & { class?: string }>;
export function NumberFieldIncrementTrigger< T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
export function NumberFieldIncrementTrigger<T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
const [local, rest] = splitProps(props as numberFieldIncrementTriggerProps, ['class']);
return (

View File

@@ -1,5 +1,6 @@
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export function Skeleton(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class']);

View File

@@ -1,5 +1,6 @@
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export function Table(props: ComponentProps<'table'>) {
const [local, rest] = splitProps(props, ['class']);

View File

@@ -3,9 +3,10 @@ import type {
TooltipContentProps,
TooltipRootProps,
} from '@kobalte/core/tooltip';
import type { ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip';
import { mergeProps, splitProps, type ValidComponent } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
export const TooltipTrigger = TooltipPrimitive.Trigger;

View File

@@ -1,11 +1,12 @@
import type { Component, ParentComponent } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { A, useNavigate } from '@solidjs/router';
import { type Component, For, type ParentComponent, Show, Suspense } from 'solid-js';
import { For, Show, Suspense } from 'solid-js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';

View File

@@ -1,33 +0,0 @@
import type { ParentComponent } from 'solid-js';
import { A, useParams } from '@solidjs/router';
import { Button } from '../components/button';
export const IntegrationsLayout: ParentComponent = (props) => {
const params = useParams();
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-5xl">
<div class="border-b mb-6">
<h1 class="text-xl font-bold">
Integrations
</h1>
<p class="text-muted-foreground mt-1">
Manage your organization's integrations
</p>
<div class="flex gap-2 mt-4">
<Button as={A} href={`/organizations/${params.organizationId}/intake-emails`} variant="ghost" activeClass="border-b border-primary text-foreground!" class="text-muted-foreground rounded-b-none">
Intake Emails
</Button>
<Button as={A} href={`/organizations/${params.organizationId}/api-keys`} variant="ghost" activeClass="border-b border-primary text-foreground!" class="text-muted-foreground rounded-b-none">
API Keys
</Button>
</div>
</div>
{props.children}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import type { ParentComponent } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { A, useParams } from '@solidjs/router';
import { Button } from '../components/button';
import { SideNav } from './sidenav.layout';
export const OrganizationSettingsLayout: ParentComponent = (props) => {
const params = useParams();
const { t } = useI18n();
const getNavigationItems = () => [
{
label: t('layout.menu.general-settings'),
href: `/organizations/${params.organizationId}/settings`,
icon: 'i-tabler-settings',
},
{
label: t('layout.menu.intake-emails'),
href: `/organizations/${params.organizationId}/settings/intake-emails`,
icon: 'i-tabler-mail',
},
{
label: t('layout.menu.webhooks'),
href: `/organizations/${params.organizationId}/settings/webhooks`,
icon: 'i-tabler-webhook',
},
];
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<SideNav
mainMenu={getNavigationItems()}
header={() => (
<div class="pl-6 py-3 border-b border-b-border flex items-center gap-1">
<Button variant="ghost" size="icon" class="text-muted-foreground" as={A} href="/">
<div class="i-tabler-arrow-left size-5"></div>
</Button>
<h1 class="text-base font-bold">
Organization Settings
</h1>
</div>
)}
/>
</div>
<div class="flex-1 min-h-0 flex flex-col">
{props.children}
</div>
</div>
);
};

View File

@@ -1,13 +1,14 @@
import type { Organization } from '@/modules/organizations/organizations.types';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import type { Component, ParentComponent } from 'solid-js';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization, fetchOrganizations } from '@/modules/organizations/organizations.services';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, createQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { type Component, createEffect, on, type ParentComponent } from 'solid-js';
import { createEffect, on } from 'solid-js';
import {
Select,
SelectContent,
@@ -44,11 +45,6 @@ const OrganizationLayoutSideNav: Component = () => {
icon: 'i-tabler-list-check',
href: `/organizations/${params.organizationId}/tagging-rules`,
},
{
label: t('layout.menu.integrations'),
icon: 'i-tabler-link',
href: `/organizations/${params.organizationId}/intake-emails`,
},
];

View File

@@ -1,17 +1,18 @@
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { Component, ComponentProps, JSX, ParentComponent } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { useCommandPalette } from '@/modules/command-palette/command-palette.provider';
import { useConfig } from '@/modules/config/config.provider';
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.component';
import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { A, useNavigate, useParams } from '@solidjs/router';
import { type Component, type ComponentProps, type JSX, type ParentComponent, Show, Suspense } from 'solid-js';
import { Show, Suspense } from 'solid-js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
@@ -26,12 +27,10 @@ type MenuItem = {
const MenuItemButton: Component<MenuItem> = (props) => {
return (
<Button class="block" variant="ghost" {...(props.onClick ? { onClick: props.onClick } : { as: A, href: props.href, activeClass: 'bg-accent/50! text-accent-foreground! truncate', end: true } as ComponentProps<typeof Button>)}>
<div class="flex items-center gap-2 dark:text-muted-foreground truncate">
<div class={cn(props.icon, 'size-5')}></div>
<div>{props.label}</div>
{props.badge && <div class="ml-auto">{props.badge}</div>}
</div>
<Button class="justify-start items-center gap-2 dark:text-muted-foreground truncate" variant="ghost" {...(props.onClick ? { onClick: props.onClick } : { as: A, href: props.href, activeClass: 'bg-accent/50! text-accent-foreground! truncate', end: true } as ComponentProps<typeof Button>)}>
<div class={cn(props.icon, 'size-5 text-muted-foreground opacity-50')}></div>
<div>{props.label}</div>
{props.badge && <div class="ml-auto">{props.badge}</div>}
</Button>
);
};

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
@@ -6,7 +7,7 @@ import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useUpdateCurrentUser } from '../users.composables';
import { nameSchema } from '../users.schemas';

View File

@@ -0,0 +1,79 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { createSignal, For } from 'solid-js';
import { WEBHOOK_EVENTS } from '../webhooks.constants';
type WebhookEvent = typeof WEBHOOK_EVENTS[number]['events'][number];
type WebhookSection = typeof WEBHOOK_EVENTS[number];
export const WebhookEventsPicker: Component<{ events: WebhookEvent[]; onChange: (events: WebhookEvent[]) => void }> = (props) => {
const [events, setEvents] = createSignal<WebhookEvent[]>(props.events);
const { t } = useI18n();
const getEventsSections = () => {
return WEBHOOK_EVENTS.map((section: WebhookSection) => ({
...section,
title: t(`webhooks.events.${section.section}.title` as LocaleKeys),
events: section.events.map((event: WebhookEvent) => {
const [prefix, suffix] = event.split(':');
return {
name: event,
prefix,
suffix,
description: t(`webhooks.events.${section.section}.${event}.description`),
};
}),
}));
};
const isEventSelected = (event: WebhookEvent) => {
return events().includes(event);
};
const toggleEvent = (event: WebhookEvent) => {
setEvents((prev) => {
if (prev.includes(event)) {
return prev.filter(e => e !== event);
}
return [...prev, event];
});
props.onChange(events());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getEventsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.events}>
{event => (
<Checkbox
class="flex items-start gap-2"
checked={isEventSelected(event.name)}
onChange={() => toggleEvent(event.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
<div class="font-semibold">{event.description}</div>
<div class="text-muted-foreground text-xs mt-1">{event.name}</div>
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import * as v from 'valibot';
import { WebhookEventsPicker } from '../components/webhook-events-picker.component';
import { WEBHOOK_EVENT_NAMES } from '../webhooks.constants';
import { createWebhook } from '../webhooks.services';
export const CreateWebhookPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
await createWebhook({
name,
url,
secret,
enabled,
events,
organizationId: params.organizationId,
});
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
createToast({
type: 'success',
message: t('webhooks.create.success'),
});
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
secret: v.optional(v.string()),
enabled: v.optional(v.boolean()),
events: v.pipe(
v.array(v.picklist(WEBHOOK_EVENT_NAMES)),
v.nonEmpty(t('webhooks.create.form.events.required')),
),
}),
initialValues: {
name: '',
url: '',
secret: '',
enabled: true,
events: [],
},
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('webhooks.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('webhooks.create.description')}</p>
</div>
<Form>
<Field name="name">
{(field, inputProps) => (
<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}
autoFocus
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<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.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="secret">
{(field, inputProps) => (
<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.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="events" type="string[]">
{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)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<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}>
{t('webhooks.create.form.submit')}
</Button>
</div>
</Form>
</div>
);
};

View File

@@ -0,0 +1,196 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { WebhookEventsPicker } from '../components/webhook-events-picker.component';
import { WEBHOOK_EVENT_NAMES } from '../webhooks.constants';
import { fetchWebhook, updateWebhook } from '../webhooks.services';
export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const [rotateSecret, setRotateSecret] = createSignal(false);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
const updateData = {
name,
url,
enabled,
events,
};
// Only include secret if rotation was requested
if (rotateSecret() && secret) {
Object.assign(updateData, { secret });
}
await updateWebhook({
webhookId: params.webhookId,
organizationId: params.organizationId,
input: updateData,
});
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
await queryClient.invalidateQueries({ queryKey: ['webhook', params.organizationId, params.webhookId] });
createToast({
type: 'success',
message: t('webhooks.update.success'),
});
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
secret: v.optional(v.string()),
enabled: v.optional(v.boolean()),
events: v.pipe(
v.array(v.picklist(WEBHOOK_EVENT_NAMES)),
v.nonEmpty(t('webhooks.create.form.events.required')),
),
}),
initialValues: {
name: props.webhook.name,
url: props.webhook.url,
enabled: props.webhook.enabled,
events: props.webhook.events,
},
});
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<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}
autoFocus
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<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.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<div class="mb-6">
<Field name="secret">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mt-4">
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
<div class="flex items-center gap-2">
<TextField
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)}
disabled={!rotateSecret()}
/>
<Show when={!rotateSecret()}>
<Button type="button" variant="secondary" onClick={() => setRotateSecret(true)} class="flex-shrink-0">
{t('webhooks.update.form.rotate-secret.button')}
</Button>
</Show>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
</div>
<Field name="events" type="string[]">
{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)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<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}>
{t('webhooks.update.submit')}
</Button>
</div>
</Form>
);
};
export const EditWebhookPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const webhookQuery = createQuery(() => ({
queryKey: ['webhook', params.organizationId, params.webhookId],
queryFn: () => fetchWebhook({
organizationId: params.organizationId,
webhookId: params.webhookId,
}),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('webhooks.update.title')}</h1>
<p class="text-sm text-muted-foreground">{t('webhooks.update.description')}</p>
</div>
<Suspense>
<Show when={webhookQuery.data?.webhook}>
{getWebhook => <EditWebhookForm webhook={getWebhook()} />}
</Show>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,157 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteWebhook, fetchWebhooks } from '../webhooks.services';
export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const params = useParams();
const deleteWebhookMutation = createMutation(() => ({
mutationFn: deleteWebhook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
createToast({
message: t('webhooks.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('webhooks.delete.confirm.title'),
message: t('webhooks.delete.confirm.message'),
confirmButton: {
text: t('webhooks.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('webhooks.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteWebhookMutation.mutate({ webhookId: webhook.id, organizationId: params.organizationId });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-webhook text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1 flex flex-col gap-1 overflow-hidden">
<h2 class="text-sm font-medium leading-tight">{webhook.name}</h2>
<p class="text-muted-foreground text-xs font-mono truncate">{webhook.url}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.last-triggered')}
{' '}
{webhook.lastTriggeredAt ? format(webhook.lastTriggeredAt, 'MMM d, yyyy') : t('webhooks.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.created')}
{' '}
{format(webhook.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
as={A}
href={`/organizations/${params.organizationId}/settings/webhooks/${webhook.id}`}
>
<div class="i-tabler-edit text-muted-foreground size-4" />
</Button>
<Button
variant="outline"
size="icon"
isLoading={deleteWebhookMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const WebhooksPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const query = createQuery(() => ({
queryKey: ['webhooks', params.organizationId],
queryFn: () => fetchWebhooks({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<div class="flex gap-4 items-center justify-between">
<div>
<h1 class="text-xl font-semibold mb-2">
{t('webhooks.list.title')}
</h1>
<p class="text-muted-foreground">
{t('webhooks.list.description')}
</p>
</div>
<Show when={query.data?.webhooks?.length}>
<Button as={A} href={`/organizations/${params.organizationId}/settings/webhooks/create`} class="gap-2">
<div class="i-tabler-plus size-4" />
{t('webhooks.list.create')}
</Button>
</Show>
</div>
<Suspense>
<Switch>
<Match when={query.data?.webhooks?.length === 0}>
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
<EmptyState
title={t('webhooks.list.empty.title')}
description={t('webhooks.list.empty.description')}
icon="i-tabler-webhook"
class="p-0"
cta={(
<Button as={A} href={`/organizations/${params.organizationId}/settings/webhooks/create`} class="gap-2">
<div class="i-tabler-plus size-4" />
{t('webhooks.list.create')}
</Button>
)}
/>
</div>
</Match>
<Match when={query.data?.webhooks?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.webhooks}>
{webhook => (
<WebhookCard webhook={webhook} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,12 @@
export const WEBHOOK_EVENTS = [
{
section: 'documents',
events: [
'document:created',
'document:deleted',
],
},
] as const;
export const WEBHOOK_EVENT_NAMES = WEBHOOK_EVENTS.flatMap(event => event.events);

View File

@@ -0,0 +1,62 @@
import type { CreateWebhookInput, UpdateWebhookInput, Webhook } from './webhooks.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createWebhook({ organizationId, ...input }: CreateWebhookInput) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks`,
method: 'POST',
body: input,
});
return {
webhook: coerceDates(webhook),
};
}
export async function fetchWebhooks({ organizationId }: { organizationId: string }) {
const { webhooks } = await apiClient<{
webhooks: Webhook[];
}>({
path: `/api/organizations/${organizationId}/webhooks`,
});
return {
webhooks: webhooks.map(coerceDates),
};
}
export async function fetchWebhook({ webhookId, organizationId }: { webhookId: string; organizationId: string }) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
});
return {
webhook: coerceDates(webhook),
};
}
export async function updateWebhook({ webhookId, organizationId, input }: { webhookId: string; organizationId: string; input: UpdateWebhookInput }) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
method: 'PUT',
body: input,
});
return {
webhook: coerceDates(webhook),
};
}
export async function deleteWebhook({ webhookId, organizationId }: { webhookId: string; organizationId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,34 @@
import type { WEBHOOK_EVENT_NAMES } from './webhooks.constants';
export type WebhookEvent = (typeof WEBHOOK_EVENT_NAMES)[number];
export type Webhook = {
id: string;
name: string;
url: string;
secret?: string;
enabled: boolean;
events: WebhookEvent[];
organizationId: string;
createdAt: Date;
updatedAt: Date;
lastTriggeredAt?: Date;
lastError?: string;
};
export type CreateWebhookInput = {
name: string;
url: string;
secret?: string;
enabled?: boolean;
events?: WebhookEvent[];
organizationId: string;
};
export type UpdateWebhookInput = {
name?: string;
url?: string;
secret?: string;
enabled?: boolean;
events?: WebhookEvent[];
};

View File

@@ -1,4 +1,5 @@
import { Navigate, type RouteDefinition, useParams } from '@solidjs/router';
import type { RouteDefinition } from '@solidjs/router';
import { Navigate, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
@@ -19,17 +20,19 @@ import { CreateOrganizationPage } from './modules/organizations/pages/create-org
import { OrganizationPage } from './modules/organizations/pages/organization.page';
import { OrganizationsSettingsPage } from './modules/organizations/pages/organizations-settings.page';
import { OrganizationsPage } from './modules/organizations/pages/organizations.page';
import { ComingSoonPage } from './modules/shared/pages/coming-soon.page';
import { NotFoundPage } from './modules/shared/pages/not-found.page';
import { CreateTaggingRulePage } from './modules/tagging-rules/pages/create-tagging-rule.page';
import { TaggingRulesPage } from './modules/tagging-rules/pages/tagging-rules.page';
import { UpdateTaggingRulePage } from './modules/tagging-rules/pages/update-tagging-rule.page';
import { TagsPage } from './modules/tags/pages/tags.page';
import { IntegrationsLayout } from './modules/ui/layouts/integrations.layout';
import { OrganizationSettingsLayout } from './modules/ui/layouts/organization-settings.layout';
import { OrganizationLayout } from './modules/ui/layouts/organization.layout';
import { SettingsLayout } from './modules/ui/layouts/settings.layout';
import { CurrentUserProvider, useCurrentUser } from './modules/users/composables/useCurrentUser';
import { UserSettingsPage } from './modules/users/pages/user-settings.page';
import { CreateWebhookPage } from './modules/webhooks/pages/create-webhook.page';
import { EditWebhookPage } from './modules/webhooks/pages/edit-webhook.page';
import { WebhooksPage } from './modules/webhooks/pages/webhooks.page';
export const routes: RouteDefinition[] = [
{
@@ -105,10 +108,6 @@ export const routes: RouteDefinition[] = [
path: '/documents/:documentId',
component: DocumentPage,
},
{
path: '/settings',
component: OrganizationsSettingsPage,
},
{
path: '/deleted',
component: DeletedDocumentsPage,
@@ -129,19 +128,32 @@ export const routes: RouteDefinition[] = [
path: '/tagging-rules/:taggingRuleId',
component: UpdateTaggingRulePage,
},
],
},
{
path: '/:organizationId/settings',
component: OrganizationSettingsLayout,
children: [
{
path: '/',
component: IntegrationsLayout,
children: [
{
path: '/intake-emails',
component: IntakeEmailsPage,
},
{
path: '/api-keys',
component: ComingSoonPage,
},
],
component: OrganizationsSettingsPage,
},
{
path: '/webhooks/create',
component: CreateWebhookPage,
},
{
path: '/webhooks/:webhookId',
component: EditWebhookPage,
},
{
path: '/intake-emails',
component: IntakeEmailsPage,
},
{
path: '/webhooks',
component: WebhooksPage,
},
],
},

View File

@@ -0,0 +1,30 @@
# @papra/app-server
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly hard delete files in storage driver
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for b2 document storage
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for azure blob document storage
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix ingestion config coercion
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count
- Updated dependencies [[`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67)]:
- @papra/webhooks@0.1.0

View File

@@ -0,0 +1,35 @@
CREATE TABLE `webhook_deliveries` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`webhook_id` text NOT NULL,
`event_name` text NOT NULL,
`request_payload` text NOT NULL,
`response_payload` text NOT NULL,
`response_status` integer NOT NULL,
FOREIGN KEY (`webhook_id`) REFERENCES `webhooks`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `webhook_events` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`webhook_id` text NOT NULL,
`event_name` text NOT NULL,
FOREIGN KEY (`webhook_id`) REFERENCES `webhooks`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `webhook_events_webhook_id_event_name_unique` ON `webhook_events` (`webhook_id`,`event_name`);--> statement-breakpoint
CREATE TABLE `webhooks` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`secret` text,
`enabled` integer DEFAULT true NOT NULL,
`created_by` text,
`organization_id` text,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE set null,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE cascade ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1745131802627,
"tag": "0003_api-keys",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1746297779495,
"tag": "0004_organizations-webhooks",
"breakpoints": true
}
]
}

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-server",
"name": "@papra/app-server",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.4.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra app server",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -31,6 +32,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.722.0",
"@aws-sdk/lib-storage": "^3.722.0",
"@azure/storage-blob": "^12.27.0",
"@corentinth/chisels": "^1.1.0",
"@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^1.0.0",
@@ -40,7 +42,9 @@
"@owlrelay/api-sdk": "^0.0.2",
"@owlrelay/webhook": "^0.0.3",
"@papra/lecture": "^0.0.4",
"@papra/webhooks": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"backblaze-b2": "^1.7.0",
"better-auth": "catalog:",
"c12": "^3.0.2",
"chokidar": "^4.0.3",
@@ -66,6 +70,7 @@
"@antfu/eslint-config": "catalog:",
"@crowlog/pretty": "^1.1.1",
"@total-typescript/ts-reset": "^0.6.1",
"@types/backblaze-b2": "^1.5.6",
"@types/lodash-es": "^4.17.12",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.10.2",

View File

@@ -7,6 +7,7 @@ import { organizationSubscriptionsTable } from '../../subscriptions/subscription
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
import { usersTable } from '../../users/users.table';
import { webhookDeliveriesTable, webhookEventsTable, webhooksTable } from '../../webhooks/webhooks.tables';
import { setupDatabase } from './database';
import { runMigrations } from './database.services';
@@ -38,6 +39,9 @@ const seedTables = {
taggingRuleActions: taggingRuleActionsTable,
apiKeys: apiKeysTable,
apiKeyOrganizations: apiKeyOrganizationsTable,
webhooks: webhooksTable,
webhookEvents: webhookEventsTable,
webhookDeliveries: webhookDeliveriesTable,
} as const;
type SeedTablesRows = {

View File

@@ -11,8 +11,8 @@ function setValidParams(path: string) {
.replaceAll(':tagId', 'tag_444444444444444444444444')
.replaceAll(':taggingRuleId', 'rule_555555555555555555555555')
.replaceAll(':intakeEmailId', 'email_666666666666666666666666')
.replaceAll(':apiKeyId', 'api_key_777777777777777777777777');
.replaceAll(':apiKeyId', 'api_key_777777777777777777777777')
.replaceAll(':webhookId', 'wbh_888888888888888888888888');
// throw if there are any remaining params
if (newPath.match(/:(\w+)/g)) {
throw new Error(`Add a dummy value for the params in ${path}`);

View File

@@ -8,6 +8,7 @@ import { registerSubscriptionsRoutes } from '../subscriptions/subscriptions.rout
import { registerTaggingRulesRoutes } from '../tagging-rules/tagging-rules.routes';
import { registerTagsRoutes } from '../tags/tags.routes';
import { registerUsersRoutes } from '../users/users.routes';
import { registerWebhooksRoutes } from '../webhooks/webhook.routes';
import { registerAuthRoutes } from './auth/auth.routes';
import { registerHealthCheckRoutes } from './health-check/health-check.routes';
@@ -23,4 +24,5 @@ export function registerRoutes(context: RouteDefinitionContext) {
registerTagsRoutes(context);
registerTaggingRulesRoutes(context);
registerApiKeysRoutes(context);
registerWebhooksRoutes(context);
}

View File

@@ -29,7 +29,7 @@ export function createDocumentsRepository({ db }: { db: Database }) {
getExpiredDeletedDocuments,
getOrganizationStats,
getOrganizationDocumentBySha256Hash,
getAllOrganizationTrashDocumentIds,
getAllOrganizationTrashDocuments,
updateDocument,
},
{ db },
@@ -212,7 +212,7 @@ async function getDocumentById({ documentId, organizationId, db }: { documentId:
};
}
async function softDeleteDocument({ documentId, organizationId, userId, db, now = new Date() }: { documentId: string; organizationId: string;userId: string; db: Database; now?: Date }) {
async function softDeleteDocument({ documentId, organizationId, userId, db, now = new Date() }: { documentId: string; organizationId: string; userId: string; db: Database; now?: Date }) {
await db
.update(documentsTable)
.set({
@@ -228,7 +228,7 @@ async function softDeleteDocument({ documentId, organizationId, userId, db, now
);
}
async function restoreDocument({ documentId, organizationId, name, userId, db }: { documentId: string;organizationId: string; name?: string; userId?: string; db: Database }) {
async function restoreDocument({ documentId, organizationId, name, userId, db }: { documentId: string; organizationId: string; name?: string; userId?: string; db: Database }) {
const [document] = await db
.update(documentsTable)
.set({
@@ -258,6 +258,7 @@ async function getExpiredDeletedDocuments({ db, expirationDelayInDays, now = new
const documents = await db.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
}).from(documentsTable).where(
and(
eq(documentsTable.isDeleted, true),
@@ -266,7 +267,7 @@ async function getExpiredDeletedDocuments({ db, expirationDelayInDays, now = new
);
return {
documentIds: documents.map(document => document.id),
documents,
};
}
@@ -312,9 +313,10 @@ async function getOrganizationStats({ organizationId, db }: { organizationId: st
};
}
async function getAllOrganizationTrashDocumentIds({ organizationId, db }: { organizationId: string; db: Database }) {
async function getAllOrganizationTrashDocuments({ organizationId, db }: { organizationId: string; db: Database }) {
const documents = await db.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
}).from(documentsTable).where(
and(
eq(documentsTable.organizationId, organizationId),
@@ -323,7 +325,7 @@ async function getAllOrganizationTrashDocumentIds({ organizationId, db }: { orga
);
return {
documentIds: documents.map(document => document.id),
documents,
};
}

View File

@@ -12,6 +12,8 @@ import { validateFormData, validateJsonBody, validateParams, validateQuery } fro
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createTagsRepository } from '../tags/tags.repository';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { triggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
@@ -93,6 +95,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
const subscriptionsRepository = createSubscriptionsRepository({ db });
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const { document } = await createDocument({
file,
@@ -105,6 +108,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
return context.json({
@@ -240,12 +244,19 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
await triggerWebhooks({
webhookRepository,
organizationId,
event: 'document:deleted',
payload: { documentId, organizationId },
});
return context.json({
success: true,
});

View File

@@ -9,6 +9,7 @@ import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.rep
import { createTagsRepository } from '../tags/tags.repository';
import { documentsTagsTable } from '../tags/tags.table';
import { createDummyTrackingServices } from '../tracking/tracking.services';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { createDocumentAlreadyExistsError } from './documents.errors';
import { createDocumentsRepository } from './documents.repository';
import { documentsTable } from './documents.table';
@@ -31,6 +32,7 @@ describe('documents usecases', () => {
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const generateDocumentId = () => 'doc_1';
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
@@ -49,6 +51,7 @@ describe('documents usecases', () => {
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(document).to.include({
@@ -90,7 +93,7 @@ describe('documents usecases', () => {
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
let documentIdIndex = 1;
const generateDocumentId = () => `doc_${documentIdIndex++}`;
@@ -110,6 +113,7 @@ describe('documents usecases', () => {
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(document1).to.include({
@@ -138,6 +142,7 @@ describe('documents usecases', () => {
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
}),
).rejects.toThrow(
createDocumentAlreadyExistsError(),
@@ -204,7 +209,7 @@ describe('documents usecases', () => {
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
// 3. Re-create the document
const { document: documentRestored } = await createDocument({
file: new File(['hello world'], 'file-2.txt', { type: 'text/plain' }),
@@ -216,6 +221,7 @@ describe('documents usecases', () => {
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(documentRestored).to.deep.include({
@@ -256,6 +262,7 @@ describe('documents usecases', () => {
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const generateDocumentId = () => 'doc_1';
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
@@ -280,6 +287,7 @@ describe('documents usecases', () => {
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
}),
).rejects.toThrow(new Error('Macron, explosion!'));

View File

@@ -1,23 +1,33 @@
import type { Database } from '../app/database/database.types';
import type { Config } from '../config/config.types';
import type { PlansRepository } from '../plans/plans.repository';
import type { Logger } from '../shared/logger/logger';
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import type { TagsRepository } from '../tags/tags.repository';
import type { TrackingServices } from '../tracking/tracking.services';
import type { WebhookRepository } from '../webhooks/webhook.repository';
import type { DocumentsRepository } from './documents.repository';
import type { Document } from './documents.types';
import type { DocumentStorageService } from './storage/documents.storage.services';
import { safely } from '@corentinth/chisels';
import { extractTextFromFile } from '@papra/lecture';
import pLimit from 'p-limit';
import { checkIfOrganizationCanCreateNewDocument } from '../organizations/organizations.usecases';
import { createPlansRepository, type PlansRepository } from '../plans/plans.repository';
import { createPlansRepository } from '../plans/plans.repository';
import { createLogger } from '../shared/logger/logger';
import { createSubscriptionsRepository, type SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository, type TaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
import { createTagsRepository, type TagsRepository } from '../tags/tags.repository';
import { createTrackingServices, type TrackingServices } from '../tracking/tracking.services';
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 { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
import { createDocumentsRepository, type DocumentsRepository } from './documents.repository';
import { createDocumentsRepository } from './documents.repository';
import { getFileSha256Hash } from './documents.services';
import { createDocumentStorageService, type DocumentStorageService } from './storage/documents.storage.services';
import { createDocumentStorageService } from './storage/documents.storage.services';
const logger = createLogger({ namespace: 'documents:usecases' });
@@ -25,7 +35,7 @@ export async function extractDocumentText({ file }: { file: File }) {
const { textContent, error, extractorName } = await extractTextFromFile({ file });
if (error) {
logger.error({ error, extractorName }, 'Error while attempting to extract text from PDF');
logger.error({ error, extractorName }, 'Error while extracting text from document');
}
return {
@@ -45,6 +55,7 @@ export async function createDocument({
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
logger = createLogger({ namespace: 'documents:usecases' }),
}: {
file: File;
@@ -58,6 +69,7 @@ export async function createDocument({
trackingServices: TrackingServices;
taggingRulesRepository: TaggingRulesRepository;
tagsRepository: TagsRepository;
webhookRepository: WebhookRepository;
logger?: Logger;
}) {
const {
@@ -105,6 +117,19 @@ export async function createDocument({
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
await triggerWebhooks({
webhookRepository,
organizationId,
event: 'document:created',
payload: {
documentId: document.id,
organizationId,
name: document.name,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
},
});
return { document };
}
@@ -130,6 +155,7 @@ export async function createDocumentCreationUsecase({
trackingServices: createTrackingServices({ config }),
taggingRulesRepository: createTaggingRulesRepository({ db }),
tagsRepository: createTagsRepository({ db }),
webhookRepository: createWebhookRepository({ db }),
generateDocumentId,
logger,
};
@@ -279,17 +305,19 @@ export async function ensureDocumentExists({
}
export async function hardDeleteDocument({
documentId,
document,
documentsRepository,
documentsStorageService,
}: {
documentId: string;
document: Pick<Document, 'id' | 'originalStorageKey'>;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
}) {
await Promise.allSettled([
documentsRepository.hardDeleteDocument({ documentId }),
documentsStorageService.deleteFile({ storageKey: documentId }),
// TODO: use transaction
await Promise.all([
documentsRepository.hardDeleteDocument({ documentId: document.id }),
documentsStorageService.deleteFile({ storageKey: document.originalStorageKey }),
]);
}
@@ -306,23 +334,25 @@ export async function deleteExpiredDocuments({
now?: Date;
logger?: Logger;
}) {
const { documentIds } = await documentsRepository.getExpiredDeletedDocuments({
const { documents } = await documentsRepository.getExpiredDeletedDocuments({
expirationDelayInDays: config.documents.deletedDocumentsRetentionDays,
now,
});
const limit = pLimit(10);
await Promise.all(
documentIds.map(async (documentId) => {
const [, error] = await safely(hardDeleteDocument({ documentId, documentsRepository, documentsStorageService }));
documents.map(document => limit(async () => {
const [, error] = await safely(hardDeleteDocument({ document, documentsRepository, documentsStorageService }));
if (error) {
logger.error({ documentId, error }, 'Error while deleting expired document');
logger.error({ document, error }, 'Error while deleting expired document');
}
}),
})),
);
return {
deletedDocumentsCount: documentIds.length,
deletedDocumentsCount: documents.length,
};
}
@@ -347,7 +377,7 @@ export async function deleteTrashDocument({
throw createDocumentNotDeletedError();
}
await hardDeleteDocument({ documentId, documentsRepository, documentsStorageService });
await hardDeleteDocument({ document, documentsRepository, documentsStorageService });
}
export async function deleteAllTrashDocuments({
@@ -359,13 +389,13 @@ export async function deleteAllTrashDocuments({
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
}) {
const { documentIds } = await documentsRepository.getAllOrganizationTrashDocumentIds({ organizationId });
const { documents } = await documentsRepository.getAllOrganizationTrashDocuments({ organizationId });
// TODO: refactor to use batching and transaction
const limit = pLimit(10);
await Promise.all(
documentIds.map(documentId => limit(() => hardDeleteDocument({ documentId, documentsRepository, documentsStorageService }))),
documents.map(document => limit(() => hardDeleteDocument({ document, documentsRepository, documentsStorageService }))),
);
}

View File

@@ -1,5 +1,7 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { AZ_BLOB_STORAGE_DRIVER_NAME } from './drivers/az-blob/az-blob.storage-driver';
import { B2_STORAGE_DRIVER_NAME } from './drivers/b2/b2.storage-driver';
import { FS_STORAGE_DRIVER_NAME } from './drivers/fs/fs.storage-driver';
import { IN_MEMORY_STORAGE_DRIVER_NAME } from './drivers/memory/memory.storage-driver';
import { S3_STORAGE_DRIVER_NAME } from './drivers/s3/s3.storage-driver';
@@ -12,8 +14,8 @@ export const documentStorageConfig = {
env: 'DOCUMENT_STORAGE_MAX_UPLOAD_SIZE',
},
driver: {
doc: `The driver to use for document storage, values can be one of: ${[FS_STORAGE_DRIVER_NAME, S3_STORAGE_DRIVER_NAME, IN_MEMORY_STORAGE_DRIVER_NAME].map(x => `\`${x}\``).join(', ')}`,
schema: z.enum([FS_STORAGE_DRIVER_NAME, S3_STORAGE_DRIVER_NAME, IN_MEMORY_STORAGE_DRIVER_NAME]),
doc: `The driver to use for document storage, values can be one of: ${[FS_STORAGE_DRIVER_NAME, S3_STORAGE_DRIVER_NAME, IN_MEMORY_STORAGE_DRIVER_NAME, B2_STORAGE_DRIVER_NAME, AZ_BLOB_STORAGE_DRIVER_NAME].map(x => `\`${x}\``).join(', ')}`,
schema: z.enum([FS_STORAGE_DRIVER_NAME, S3_STORAGE_DRIVER_NAME, IN_MEMORY_STORAGE_DRIVER_NAME, B2_STORAGE_DRIVER_NAME, AZ_BLOB_STORAGE_DRIVER_NAME]),
default: FS_STORAGE_DRIVER_NAME,
env: 'DOCUMENT_STORAGE_DRIVER',
},
@@ -58,5 +60,51 @@ export const documentStorageConfig = {
env: 'DOCUMENT_STORAGE_S3_ENDPOINT',
},
},
b2: {
applicationKeyId: {
doc: 'The B2 application key ID',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_B2_APPLICATION_KEY_ID',
},
applicationKey: {
doc: 'The B2 application key',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_B2_APPLICATION_KEY',
},
bucketName: {
doc: 'The B2 bucket name',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_B2_BUCKET_NAME',
},
bucketId: {
doc: 'The B2 bucket ID',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_B2_BUCKET_ID',
},
},
azureBlob: {
accountName: {
doc: 'The Azure Blob Storage account name',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_AZURE_BLOB_ACCOUNT_NAME',
},
accountKey: {
doc: 'The Azure Blob Storage account key',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_AZURE_BLOB_ACCOUNT_KEY',
},
containerName: {
doc: 'The Azure Blob Storage container name',
schema: z.string(),
default: '',
env: 'DOCUMENT_STORAGE_AZURE_BLOB_CONTAINER_NAME',
},
},
},
} as const satisfies ConfigDefinition;

View File

@@ -0,0 +1,7 @@
import { createErrorFactory } from '../../shared/errors/errors';
export const createFileNotFoundError = createErrorFactory({
message: 'File not found',
code: 'documents.storage.file_not_found',
statusCode: 404,
});

View File

@@ -1,5 +1,7 @@
import type { Config } from '../../config/config.types';
import { createError } from '../../shared/errors/errors';
import { AZ_BLOB_STORAGE_DRIVER_NAME, azBlobStorageDriverFactory } from './drivers/az-blob/az-blob.storage-driver';
import { B2_STORAGE_DRIVER_NAME, b2StorageDriverFactory } from './drivers/b2/b2.storage-driver';
import { FS_STORAGE_DRIVER_NAME, fsStorageDriverFactory } from './drivers/fs/fs.storage-driver';
import { IN_MEMORY_STORAGE_DRIVER_NAME, inMemoryStorageDriverFactory } from './drivers/memory/memory.storage-driver';
import { S3_STORAGE_DRIVER_NAME, s3StorageDriverFactory } from './drivers/s3/s3.storage-driver';
@@ -8,6 +10,8 @@ const storageDriverFactories = {
[FS_STORAGE_DRIVER_NAME]: fsStorageDriverFactory,
[S3_STORAGE_DRIVER_NAME]: s3StorageDriverFactory,
[IN_MEMORY_STORAGE_DRIVER_NAME]: inMemoryStorageDriverFactory,
[AZ_BLOB_STORAGE_DRIVER_NAME]: azBlobStorageDriverFactory,
[B2_STORAGE_DRIVER_NAME]: b2StorageDriverFactory,
};
export type DocumentStorageService = Awaited<ReturnType<typeof createDocumentStorageService>>;

View File

@@ -0,0 +1,38 @@
import { Readable } from 'node:stream';
import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob';
import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models';
export const AZ_BLOB_STORAGE_DRIVER_NAME = 'azure-blob' as const;
export const azBlobStorageDriverFactory = defineStorageDriver(async ({ config }) => {
const { accountName, accountKey, containerName } = config.documentsStorage.drivers.azureBlob;
const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, new StorageSharedKeyCredential(accountName, accountKey));
return {
name: AZ_BLOB_STORAGE_DRIVER_NAME,
saveFile: async ({ file, storageKey }) => {
const containerClient = blobServiceClient.getContainerClient(containerName);
await containerClient.uploadBlockBlob(storageKey, Readable.fromWeb(file.stream()), file.size);
return { storageKey };
},
getFileStream: async ({ storageKey }) => {
const containerClient = blobServiceClient.getContainerClient(containerName);
const blockBlobClient = containerClient.getBlockBlobClient(storageKey);
const { readableStreamBody } = await blockBlobClient.download();
if (!readableStreamBody) {
throw createFileNotFoundError();
}
return { fileStream: Readable.toWeb(readableStreamBody as Readable) };
},
deleteFile: async ({ storageKey }) => {
const containerClient = blobServiceClient.getContainerClient(containerName);
const blockBlobClient = containerClient.getBlockBlobClient(storageKey);
await blockBlobClient.delete();
},
};
});

View File

@@ -0,0 +1,54 @@
import { Buffer } from 'node:buffer';
import B2 from 'backblaze-b2';
import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models';
export const B2_STORAGE_DRIVER_NAME = 'b2' as const;
export const b2StorageDriverFactory = defineStorageDriver(async ({ config }) => {
const { applicationKeyId, applicationKey, bucketId, bucketName } = config.documentsStorage.drivers.b2;
const b2Client = new B2({
applicationKey,
applicationKeyId,
});
return {
name: B2_STORAGE_DRIVER_NAME,
saveFile: async ({ file, storageKey }) => {
await b2Client.authorize();
const getUploadUrl = await b2Client.getUploadUrl({
bucketId,
});
const upload = await b2Client.uploadFile({
uploadUrl: getUploadUrl.data.uploadUrl,
uploadAuthToken: getUploadUrl.data.authorizationToken,
fileName: storageKey,
data: Buffer.from(await file.arrayBuffer()),
});
if (upload.status !== 200) {
throw createFileNotFoundError();
}
return { storageKey };
},
getFileStream: async ({ storageKey }) => {
await b2Client.authorize();
const response = await b2Client.downloadFileByName({
bucketName,
fileName: storageKey,
responseType: 'stream',
});
if (!response.data) {
throw createFileNotFoundError();
}
return { fileStream: response.data };
},
deleteFile: async ({ storageKey }) => {
await b2Client.hideFile({
bucketId,
fileName: storageKey,
});
},
};
});

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs';
import { tmpdir } from 'node:os';
import path, { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createFileNotFoundError } from '../../document-storage.errors';
import { fsStorageDriverFactory } from './fs.storage-driver';
import { createFileAlreadyExistsError } from './fs.storage-driver.errors';
@@ -114,7 +115,7 @@ describe('storage driver', () => {
const fsStorageDriver = await fsStorageDriverFactory({ config });
await expect(fsStorageDriver.getFileStream({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow('File not found');
await expect(fsStorageDriver.getFileStream({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow(createFileNotFoundError());
});
});
@@ -162,7 +163,7 @@ describe('storage driver', () => {
const fsStorageDriver = await fsStorageDriverFactory({ config });
await expect(fsStorageDriver.deleteFile({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow('File not found');
await expect(fsStorageDriver.deleteFile({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow(createFileNotFoundError());
});
});
});

View File

@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import stream from 'node:stream';
import { get } from 'lodash-es';
import { checkFileExists, deleteFile, ensureDirectoryExists } from '../../../../shared/fs/fs.services';
import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models';
import { createFileAlreadyExistsError } from './fs.storage-driver.errors';
@@ -47,7 +48,7 @@ export const fsStorageDriverFactory = defineStorageDriver(async ({ config }) =>
const fileExists = await checkFileExists({ path: storagePath });
if (!fileExists) {
throw new Error('File not found');
throw createFileNotFoundError();
}
const readStream = fs.createReadStream(storagePath);
@@ -62,7 +63,7 @@ export const fsStorageDriverFactory = defineStorageDriver(async ({ config }) =>
await deleteFile({ filePath: storagePath });
} catch (error) {
if (get(error, 'code') === 'ENOENT') {
throw new Error('File not found');
throw createFileNotFoundError();
}
throw error;

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from 'vitest';
import { createFileNotFoundError } from '../../document-storage.errors';
import { inMemoryStorageDriverFactory } from './memory.storage-driver';
describe('memory storage-driver', () => {
@@ -25,7 +26,7 @@ describe('memory storage-driver', () => {
await inMemoryStorageDriver.deleteFile({ storageKey: 'org_1/text-file.txt' });
await expect(inMemoryStorageDriver.getFileStream({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow('File not found');
await expect(inMemoryStorageDriver.getFileStream({ storageKey: 'org_1/text-file.txt' })).rejects.toThrow(createFileNotFoundError());
});
test('mainly for testing purposes, a _getStorage() method is available to access the internal storage map', async () => {

Some files were not shown because too many files have changed in this diff Show More