mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 03:52:20 -06:00
Compare commits
20 Commits
dev-tools
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249b3bcfd2 | ||
|
|
d7838b5d57 | ||
|
|
f170ddd817 | ||
|
|
4f53c70854 | ||
|
|
85fa5c4342 | ||
|
|
c5d984a3a0 | ||
|
|
565bd8d7fd | ||
|
|
9b72aa886c | ||
|
|
7410455093 | ||
|
|
dd8f194fd0 | ||
|
|
803c39cbc8 | ||
|
|
096331a4ee | ||
|
|
59ba9465f6 | ||
|
|
a1056702af | ||
|
|
fd44897bca | ||
|
|
332d836d11 | ||
|
|
f613198cbd | ||
|
|
80491a5a58 | ||
|
|
605e21a410 | ||
|
|
dec589b6ed |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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
19
.changeset/config.json
Normal 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
|
||||
}
|
||||
}
|
||||
4
.github/workflows/ci-apps-papra-server.yaml
vendored
4
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -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
|
||||
|
||||
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal 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
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal 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
|
||||
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal 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
|
||||
9
.github/workflows/release-docker.yaml
vendored
9
.github/workflows/release-docker.yaml
vendored
@@ -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
43
.github/workflows/release.yml
vendored
Normal 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 }}
|
||||
@@ -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
7
apps/docs/CHANGELOG.md
Normal 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
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal file
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal 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).
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
23
apps/papra-client/CHANGELOG.md
Normal file
23
apps/papra-client/CHANGELOG.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -50,7 +50,7 @@ type TaskError = {
|
||||
|
||||
type Task = TaskSuccess | TaskError | {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' ;
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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'>({
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
157
apps/papra-client/src/modules/webhooks/pages/webhooks.page.tsx
Normal file
157
apps/papra-client/src/modules/webhooks/pages/webhooks.page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
apps/papra-client/src/modules/webhooks/webhooks.constants.ts
Normal file
12
apps/papra-client/src/modules/webhooks/webhooks.constants.ts
Normal 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);
|
||||
62
apps/papra-client/src/modules/webhooks/webhooks.services.ts
Normal file
62
apps/papra-client/src/modules/webhooks/webhooks.services.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
34
apps/papra-client/src/modules/webhooks/webhooks.types.ts
Normal file
34
apps/papra-client/src/modules/webhooks/webhooks.types.ts
Normal 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[];
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
30
apps/papra-server/CHANGELOG.md
Normal file
30
apps/papra-server/CHANGELOG.md
Normal 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
|
||||
35
apps/papra-server/migrations/0004_organizations-webhooks.sql
Normal file
35
apps/papra-server/migrations/0004_organizations-webhooks.sql
Normal 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
|
||||
);
|
||||
1861
apps/papra-server/migrations/meta/0004_snapshot.json
Normal file
1861
apps/papra-server/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1745131802627,
|
||||
"tag": "0003_api-keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1746297779495,
|
||||
"tag": "0004_organizations-webhooks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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!'));
|
||||
|
||||
|
||||
@@ -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 }))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user