Compare commits

..

16 Commits

Author SHA1 Message Date
Corentin Thomasset
32b4129e2c wip 2025-10-07 23:42:44 +02:00
Corentin Thomasset
9a6e822e71 feat(docker): drop support for armv7 (#532) 2025-10-06 23:56:24 +02:00
Corentin Thomasset
e52bc261db feat(organizations): added max members count check for organization invitations (#536) 2025-10-05 15:11:08 +02:00
Corentin Thomasset
624ad62c53 feat(orgs): added usage page and related components (#534)
- Implemented a new page to view organization usage, including document storage, intake emails, and member counts.
- Added translations for the new usage features in multiple languages (DE, EN, ES, FR, IT, PL, PT-BR, RO).
- Created a `UsageWarningCard` to alert users when they are nearing their storage limits.
- Updated the sidebar and organization settings layout to include a link to the usage page.
- Added API endpoints to fetch organization usage data and handle limits.
- Introduced a `ProgressCircle` component for visual representation of usage statistics.
- Refactored utility functions to handle positive infinity values in usage calculations.
2025-10-05 02:45:21 +02:00
Corentin Thomasset
630f9cc328 feat(subscriptions): add billing interval options (#533) 2025-10-04 21:57:09 +02:00
Corentin Thomasset
9f5be458fe feat(subscriptions): added cta and subscription management features (#523) 2025-10-04 14:58:42 +02:00
Corentin Thomasset
1bfdb8aa66 chore(release): update versions (#525)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-04 11:47:47 +02:00
Corentin Thomasset
2e2bb6fbbd chore(changeset): added changeset for ip env variable (#531) 2025-10-02 22:48:38 +00:00
Corentin Thomasset
d09b9ed70d feat(auth): add IP address header configuration and logging support (#530) 2025-10-03 00:41:42 +02:00
Corentin Thomasset
e1571d2b87 fix(auth): enhance logging to include additional arguments in log messages (#529) 2025-10-02 22:36:06 +00:00
Corentin Thomasset
c9a66e4aa8 fix(docs): update env variable name for OwlRelay configuration (#528) 2025-10-02 20:29:55 +00:00
Corentin Thomasset
9fa2df4235 feat(package): add module type to root package.json (#526) 2025-10-01 14:15:23 +00:00
Corentin Thomasset
c84a921988 feat(tags): update tag color validation to allow uppercase letters (#524)
* feat(tags): update tag color validation to allow uppercase letters

* Update .changeset/quiet-peas-mate.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-01 14:09:49 +00:00
Corentin Thomasset
9b5f3993c3 chore(release): update versions (#518)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 11:51:10 +02:00
Corentin Thomasset
b28772317c fix(file-upload): set default parameter charset to utf8 (#521) 2025-09-29 21:20:43 +02:00
Corentin Thomasset
a3f9f05c66 feat(organizations): restrict organization deletion to owners only (#517) 2025-09-26 01:49:59 +02:00
84 changed files with 3508 additions and 174 deletions

View File

@@ -0,0 +1,7 @@
---
type: feature
isBreaking: false
version: 25.10.2
date: '2025-10-07'
---
Switched to calver versioning

1
.changelog/version Normal file
View File

@@ -0,0 +1 @@
25.10.2

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Drop docker armv7 support

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Added a page to view organization usage

View File

@@ -0,0 +1,81 @@
name: Changelog Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
changelog-release:
name: Changelog Release
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # Need full history for git log in changelog
- 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: Check for pending changelogs
id: check_pending
run: |
if [ -d ".changelog/pending" ] && [ "$(ls -A .changelog/pending/*.md 2>/dev/null)" ]; then
echo "has_pending=true" >> $GITHUB_OUTPUT
echo "Found pending changelog entries"
else
echo "has_pending=false" >> $GITHUB_OUTPUT
echo "No pending changelog entries"
fi
- name: Get next version
if: steps.check_pending.outputs.has_pending == 'true'
id: next_version
run: |
cd packages/changelog
NEXT_VERSION=$(pnpm --silent changelog:next-version)
echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
echo "Next version: $NEXT_VERSION"
- name: Release changelog
if: steps.check_pending.outputs.has_pending == 'true'
run: |
cd packages/changelog
pnpm changelog:release -v ${{ steps.next_version.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit changelog and version
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .changelog
git commit -m "chore(release): ${{ steps.next_version.outputs.version }}"
git push
- name: Create git tag
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git tag "v${{ steps.next_version.outputs.version }}"
git push origin "v${{ steps.next_version.outputs.version }}"
- name: Trigger Docker build
if: steps.check_pending.outputs.has_pending == 'true'
run: |
gh workflow run release-docker.yaml -f version="${{ steps.next_version.outputs.version }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -44,7 +44,7 @@ jobs:
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: |
corentinth/papra:latest-root
@@ -57,7 +57,7 @@ jobs:
with:
context: .
file: ./docker/Dockerfile.rootless
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: |
corentinth/papra:latest

206
CLAUDE.md Normal file
View File

@@ -0,0 +1,206 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Papra is a minimalistic document management and archiving platform built as a monorepo using PNPM workspaces. The project includes a SolidJS frontend, HonoJS backend, CLI tools, and supporting packages.
It's open-source and designed for easy self-hosting using Docker, and also offers a cloud-hosted SaaS version.
## Architecture
### Monorepo Structure
- **apps/papra-server**: Backend API server (HonoJS + Drizzle ORM + Better Auth)
- **apps/papra-client**: Frontend application (SolidJS + UnoCSS + Shadcn Solid)
- **apps/docs**: Documentation site (Astro + Starlight)
- **packages/lecture**: Text extraction library for documents
- **packages/api-sdk**: API client SDK
- **packages/cli**: Command-line interface
- **packages/webhooks**: Webhook types and utilities
### Backend Architecture (apps/papra-server)
The backend follows a modular architecture with feature-based modules:
- **Module pattern**: Each feature lives in `src/modules/<feature>/` with:
- `*.repository.ts`: Database access layer (Drizzle ORM queries)
- `*.usecases.ts`: Business logic and orchestration
- `*.routes.ts`: HTTP route handlers (Hono)
- `*.services.ts`: Service layer for external integrations
- `*.table.ts`: Drizzle schema definitions
- `*.types.ts`: TypeScript type definitions
- `*.errors.ts`: Error definitions
- **Core modules**: `app`, `shared`, `config`, `tasks`
- **Feature modules**: `documents`, `organizations`, `users`, `tags`, `tagging-rules`, `intake-emails`, `ingestion-folders`, `webhooks`, `api-keys`, `subscriptions`, etc.
- **Database**: Uses Drizzle ORM with SQLite/Turso (libsql). Schema is in `*.table.ts` files, migrations in `src/migrations/`
- **Authentication**: Better Auth library for user auth
- **Task system**: Background job processing using Cadence MQ, a custom made queue system (papra-hq/cadence-mq)
- **Document storage**: Abstracted storage supporting local filesystem, S3, and Azure Blob
### Frontend Architecture (apps/papra-client)
- **SolidJS** for reactivity with router (`@solidjs/router`)
- **Module pattern**: Features in `src/modules/<feature>/` with:
- `components/`: UI components
- `pages/`: Route components
- `*.services.ts`: API client calls
- `*.provider.tsx`: Context providers
- `*.types.ts`: Type definitions
- **Routing**: Defined in `src/routes.tsx`
- **Styling**: UnoCSS for atomic CSS with Shadcn Solid components
- **State**: TanStack Query for server state, local storage for client state
- **i18n**: TypeScript-based translations in `src/locales/*.dictionary.ts`
### Dependency Injection Pattern
The server uses a dependency injection pattern with `@corentinth/chisels/injectArguments` to create testable services that accept dependencies as parameters.
## Development Commands
### Initial Setup
```bash
# Install dependencies
pnpm install
# Build all packages (required before running apps)
pnpm build:packages
```
### Backend Development
```bash
cd apps/papra-server
# Run database migrations
pnpm migrate:up
# Start development server (localhost:1221)
pnpm dev
# Run tests
pnpm test # All tests
pnpm test:watch # Watch mode
pnpm test:unit # Unit tests only
pnpm test:int # Integration tests only
# Lint and typecheck
pnpm lint
pnpm typecheck
# Database management
pnpm db:studio # Open Drizzle Studio
pnpm migrate:create "migration_name" # Create new migration
```
### Frontend Development
```bash
cd apps/papra-client
# Start development server (localhost:3000)
pnpm dev
# Run tests
pnpm test
pnpm test:watch
pnpm test:e2e # Playwright E2E tests
# Lint and typecheck
pnpm lint
pnpm typecheck
# i18n key synchronization
pnpm script:sync-i18n-key-order
```
### Package Development
```bash
cd packages/<package-name>
# Build package
pnpm build
pnpm build:watch # Watch mode (or pnpm dev)
# Run tests
pnpm test
pnpm test:watch
```
### Root-level Commands
```bash
# Run tests across all packages
pnpm test
pnpm test:watch
# Build all packages
pnpm build:packages
# Version management (changesets)
pnpm changeset # Create changeset
pnpm version # Apply changesets and bump versions
# Docker builds
pnpm docker:build:root
pnpm docker:build:root:amd64
pnpm docker:build:root:arm64
```
### Documentation Development
```bash
cd apps/docs
pnpm dev # localhost:4321
```
## Testing Guidelines
- Use **Vitest** for all testing
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
- Use business-oriented test names (avoid `it('should return true')`)
- Integration tests may use Testcontainers (Azurite, LocalStack)
- All new features require test coverage
## Code Style
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
- **Conventions**:
- Use functional programming where possible
- Prefer clarity and maintainability over performance
- Use meaningful names for variables, functions, and components
- Follow Conventional Commits for commit messages
- **Type safety**: Strict TypeScript throughout
## i18n
- Language files in `apps/papra-client/src/locales/*.dictionary.ts`
- Reference `en.dictionary.ts` for all keys (English is fallback)
- Fully type-safe with TypeScript
- Update `i18n.constants.ts` when adding new languages
- Use `pnpm script:sync-i18n-key-order` to sync key order
## Contributing Flow
1. Open an issue before submitting PRs for features/bugs
2. Target the `main` branch (continuously deployed to production)
3. Keep PRs small and atomic
4. Ensure CI is green (linting, type checking, testing, building)
5. PRs are squashed on merge
## Key Technologies
- **Frontend**: SolidJS, UnoCSS, Shadcn Solid, TanStack Query, Vite
- **Backend**: HonoJS, Drizzle ORM, Better Auth, Zod, Cadence MQ
- **Database**: SQLite/Turso (libsql)
- **Testing**: Vitest, Playwright, Testcontainers
- **Monorepo**: PNPM workspaces with catalog for shared dependencies
- **Build**: esbuild (backend), Vite (frontend), tsdown (packages)

View File

@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
3. **Configure your Papra instance**
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
```bash
# Enable intake emails

View File

@@ -1,5 +1,13 @@
# @papra/app-client
## 0.9.6
## 0.9.5
### Patch Changes
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.4",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",

View File

@@ -21,10 +21,10 @@
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 0%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--warning-foreground: 0 0% 0%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
@@ -59,7 +59,7 @@
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--warning-foreground: 0 0% 0%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Organisation löschen',
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
'organization.settings.delete.success': 'Organisation gelöscht',
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
'organization.usage.page.title': 'Nutzung',
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
'organization.usage.storage.title': 'Dokumentenspeicher',
'organization.usage.storage.description': 'Gesamtspeicher, der von Ihren Dokumenten verwendet wird',
'organization.usage.intake-emails.title': 'Eingangs-E-Mails',
'organization.usage.intake-emails.description': 'Anzahl der Eingangs-E-Mail-Adressen',
'organization.usage.members.title': 'Mitglieder',
'organization.usage.members.description': 'Anzahl der Mitglieder in der Organisation',
'organization.usage.unlimited': 'Unbegrenzt',
'organizations.members.title': 'Mitglieder',
'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Einstellungen',
'layout.menu.account': 'Konto',
'layout.menu.general-settings': 'Allgemeine Einstellungen',
'layout.menu.usage': 'Nutzung',
'layout.menu.intake-emails': 'E-Mail-Eingang',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Mitglieder',
'layout.menu.invitations': 'Einladungen',
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
'layout.upgrade-cta.button': 'Auf Plus upgraden',
'layout.theme.light': 'Heller Modus',
'layout.theme.dark': 'Dunkler Modus',
'layout.theme.system': 'Systemmodus',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Ein Tag mit diesem Namen existiert bereits für diese Organisation',
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Farbe auswählen',
'color-picker.select-a-color': 'Eine Farbe auswählen',
// Subscriptions
'subscriptions.checkout-success.title': 'Zahlung erfolgreich!',
'subscriptions.checkout-success.description': 'Ihr Abonnement wurde erfolgreich aktiviert.',
'subscriptions.checkout-success.thank-you': 'Vielen Dank für Ihr Upgrade auf Papra Plus. Sie haben jetzt Zugriff auf alle Premium-Funktionen.',
'subscriptions.checkout-success.go-to-organizations': 'Zu Organisationen',
'subscriptions.checkout-success.redirecting': 'Weiterleitung in {{ count }} Sekunde{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Zahlung abgebrochen',
'subscriptions.checkout-cancel.description': 'Ihr Abonnement-Upgrade wurde abgebrochen.',
'subscriptions.checkout-cancel.no-charges': 'Es wurden keine Gebühren von Ihrem Konto abgebucht. Sie können es jederzeit erneut versuchen.',
'subscriptions.checkout-cancel.back-to-organizations': 'Zurück zu Organisationen',
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
'subscriptions.upgrade-dialog.title': 'Auf Plus upgraden',
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
'subscriptions.upgrade-dialog.per-month': '/Monat',
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
'subscriptions.plan.free.name': 'Kostenloser Plan',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
'subscriptions.features.members': 'Organisationsmitglieder',
'subscriptions.features.members-count': '{{ count }} Mitglieder',
'subscriptions.features.email-intakes': 'E-Mail-Eingänge',
'subscriptions.features.email-intakes-count-singular': '{{ count }} Adresse',
'subscriptions.features.email-intakes-count-plural': '{{ count }} Adressen',
'subscriptions.features.max-upload-size': 'Maximale Upload-Dateigröße',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Community-Support',
'subscriptions.features.support-email': 'E-Mail-Support',
'subscriptions.billing-interval.monthly': 'Monatlich',
'subscriptions.billing-interval.annual': 'Jährlich',
'subscriptions.usage-warning.message': 'Sie haben {{ percent }}% Ihres Dokumentenspeichers verwendet. Erwägen Sie ein Upgrade Ihres Plans, um mehr Speicherplatz zu erhalten.',
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
};

View File

@@ -141,6 +141,17 @@ export const translations = {
'organization.settings.delete.confirm.confirm-button': 'Delete organization',
'organization.settings.delete.confirm.cancel-button': 'Cancel',
'organization.settings.delete.success': 'Organization deleted',
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
'organization.usage.page.title': 'Usage',
'organization.usage.page.description': 'View your organization\'s current usage and limits.',
'organization.usage.storage.title': 'Document storage',
'organization.usage.storage.description': 'Total storage used by your documents',
'organization.usage.intake-emails.title': 'Intake emails',
'organization.usage.intake-emails.description': 'Number of intake email addresses',
'organization.usage.members.title': 'Members',
'organization.usage.members.description': 'Number of members in the organization',
'organization.usage.unlimited': 'Unlimited',
'organizations.members.title': 'Members',
'organizations.members.description': 'Manage your organization members',
@@ -517,11 +528,16 @@ export const translations = {
'layout.menu.settings': 'Settings',
'layout.menu.account': 'Account',
'layout.menu.general-settings': 'General settings',
'layout.menu.usage': 'Usage',
'layout.menu.intake-emails': 'Intake emails',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Members',
'layout.menu.invitations': 'Invitations',
'layout.upgrade-cta.title': 'Need more space?',
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
'layout.upgrade-cta.button': 'Upgrade to Plus',
'layout.theme.light': 'Light mode',
'layout.theme.dark': 'Dark mode',
'layout.theme.system': 'System mode',
@@ -557,6 +573,7 @@ export const translations = {
'api-errors.tags.already_exists': 'A tag with this name already exists for this organization',
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
// Not found
@@ -580,4 +597,47 @@ export const translations = {
'color-picker.select-color': 'Select color',
'color-picker.select-a-color': 'Select a color',
// Subscriptions
'subscriptions.checkout-success.title': 'Payment Successful!',
'subscriptions.checkout-success.description': 'Your subscription has been activated successfully.',
'subscriptions.checkout-success.thank-you': 'Thank you for upgrading to Papra Plus. You now have access to all premium features.',
'subscriptions.checkout-success.go-to-organizations': 'Go to Organizations',
'subscriptions.checkout-success.redirecting': 'Redirecting in {{ count }} second{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Payment Canceled',
'subscriptions.checkout-cancel.description': 'Your subscription upgrade was canceled.',
'subscriptions.checkout-cancel.no-charges': 'No charges have been made to your account. You can try again anytime you\'re ready.',
'subscriptions.checkout-cancel.back-to-organizations': 'Back to Organizations',
'subscriptions.checkout-cancel.need-help': 'Need help?',
'subscriptions.checkout-cancel.contact-support': 'Contact support',
'subscriptions.upgrade-dialog.title': 'Upgrade to Plus',
'subscriptions.upgrade-dialog.description': 'Unlock powerful features for your organization',
'subscriptions.upgrade-dialog.contact-us': 'Contact us',
'subscriptions.upgrade-dialog.enterprise-plans': 'if you need custom enterprise plans.',
'subscriptions.upgrade-dialog.current-plan': 'Current Plan',
'subscriptions.upgrade-dialog.recommended': 'Recommended',
'subscriptions.upgrade-dialog.per-month': '/month',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
'subscriptions.plan.free.name': 'Free plan',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Document storage size',
'subscriptions.features.members': 'Organization Members',
'subscriptions.features.members-count': '{{ count }} members',
'subscriptions.features.email-intakes': 'Email Intakes',
'subscriptions.features.email-intakes-count-singular': '{{ count }} address',
'subscriptions.features.email-intakes-count-plural': '{{ count }} addresses',
'subscriptions.features.max-upload-size': 'Max upload file size',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Community support',
'subscriptions.features.support-email': 'Email support',
'subscriptions.billing-interval.monthly': 'Monthly',
'subscriptions.billing-interval.annual': 'Annual',
'subscriptions.usage-warning.message': 'You have used {{ percent }}% of your document storage. Consider upgrading your plan to get more space.',
'subscriptions.usage-warning.upgrade-button': 'Upgrade Plan',
} as const;

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organización',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organización eliminada',
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Ver el uso y los límites actuales de su organización.',
'organization.usage.storage.title': 'Almacenamiento de documentos',
'organization.usage.storage.description': 'Almacenamiento total usado por sus documentos',
'organization.usage.intake-emails.title': 'Correos de ingesta',
'organization.usage.intake-emails.description': 'Número de direcciones de correo de ingesta',
'organization.usage.members.title': 'Miembros',
'organization.usage.members.description': 'Número de miembros en la organización',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Miembros',
'organizations.members.description': 'Administra los miembros de tu organización',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Ajustes',
'layout.menu.account': 'Cuenta',
'layout.menu.general-settings': 'Ajustes generales',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'Correos de ingreso',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Miembros',
'layout.menu.invitations': 'Invitaciones',
'layout.upgrade-cta.title': '¿Necesitas más espacio?',
'layout.upgrade-cta.description': 'Obtén 10x más almacenamiento + colaboración en equipo',
'layout.upgrade-cta.button': 'Actualizar a Plus',
'layout.theme.light': 'Modo claro',
'layout.theme.dark': 'Modo oscuro',
'layout.theme.system': 'Modo del sistema',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Ya existe una etiqueta con este nombre en esta organización',
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Seleccionar color',
'color-picker.select-a-color': 'Selecciona un color',
// Subscriptions
'subscriptions.checkout-success.title': '¡Pago exitoso!',
'subscriptions.checkout-success.description': 'Tu suscripción ha sido activada exitosamente.',
'subscriptions.checkout-success.thank-you': 'Gracias por actualizar a Papra Plus. Ahora tienes acceso a todas las funciones premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir a Organizaciones',
'subscriptions.checkout-success.redirecting': 'Redirigiendo en {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pago cancelado',
'subscriptions.checkout-cancel.description': 'Tu actualización de suscripción fue cancelada.',
'subscriptions.checkout-cancel.no-charges': 'No se han realizado cargos a tu cuenta. Puedes intentarlo de nuevo cuando estés listo.',
'subscriptions.checkout-cancel.back-to-organizations': 'Volver a Organizaciones',
'subscriptions.checkout-cancel.need-help': '¿Necesitas ayuda?',
'subscriptions.checkout-cancel.contact-support': 'Contactar soporte',
'subscriptions.upgrade-dialog.title': 'Actualizar a Plus',
'subscriptions.upgrade-dialog.description': 'Desbloquea funciones poderosas para tu organización',
'subscriptions.upgrade-dialog.contact-us': 'Contáctanos',
'subscriptions.upgrade-dialog.enterprise-plans': 'si necesitas planes empresariales personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plan actual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mes',
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
'subscriptions.plan.free.name': 'Plan gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamaño de almacenamiento de documentos',
'subscriptions.features.members': 'Miembros de la organización',
'subscriptions.features.members-count': '{{ count }} miembros',
'subscriptions.features.email-intakes': 'Entradas de correo',
'subscriptions.features.email-intakes-count-singular': '{{ count }} dirección',
'subscriptions.features.email-intakes-count-plural': '{{ count }} direcciones',
'subscriptions.features.max-upload-size': 'Tamaño máximo de archivo de carga',
'subscriptions.features.support': 'Soporte',
'subscriptions.features.support-community': 'Soporte de la comunidad',
'subscriptions.features.support-email': 'Soporte por correo',
'subscriptions.billing-interval.monthly': 'Mensual',
'subscriptions.billing-interval.annual': 'Anual',
'subscriptions.usage-warning.message': 'Ha utilizado el {{ percent }}% de su almacenamiento de documentos. Considere actualizar su plan para obtener más espacio.',
'subscriptions.usage-warning.upgrade-button': 'Actualizar plan',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation',
'organization.settings.delete.confirm.cancel-button': 'Annuler',
'organization.settings.delete.success': 'Organisation supprimée',
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
'organization.usage.page.title': 'Utilisation',
'organization.usage.page.description': 'Consultez l\'utilisation actuelle et les limites de votre organisation.',
'organization.usage.storage.title': 'Stockage de documents',
'organization.usage.storage.description': 'Stockage total utilisé par vos documents',
'organization.usage.intake-emails.title': 'E-mails d\'ingestion',
'organization.usage.intake-emails.description': 'Nombre d\'adresses e-mail d\'ingestion',
'organization.usage.members.title': 'Membres',
'organization.usage.members.description': 'Nombre de membres dans l\'organisation',
'organization.usage.unlimited': 'Illimité',
'organizations.members.title': 'Membres',
'organizations.members.description': 'Gérez les membres de votre organisation.',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Paramètres',
'layout.menu.account': 'Compte',
'layout.menu.general-settings': 'Paramètres généraux',
'layout.menu.usage': 'Utilisation',
'layout.menu.intake-emails': 'Adresses de réception',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membres',
'layout.menu.invitations': 'Invitations',
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
'layout.upgrade-cta.button': 'Passer à Plus',
'layout.theme.light': 'Mode clair',
'layout.theme.dark': 'Mode sombre',
'layout.theme.system': 'Mode système',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Un tag avec ce nom existe déjà pour cette organisation',
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Sélectionner la couleur',
'color-picker.select-a-color': 'Sélectionner une couleur',
// Subscriptions
'subscriptions.checkout-success.title': 'Paiement réussi !',
'subscriptions.checkout-success.description': 'Votre abonnement a été activé avec succès.',
'subscriptions.checkout-success.thank-you': 'Merci d\'avoir mis à niveau vers Papra Plus. Vous avez maintenant accès à toutes les fonctionnalités premium.',
'subscriptions.checkout-success.go-to-organizations': 'Aller aux Organisations',
'subscriptions.checkout-success.redirecting': 'Redirection dans {{ count }} seconde{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Paiement annulé',
'subscriptions.checkout-cancel.description': 'Votre mise à niveau d\'abonnement a été annulée.',
'subscriptions.checkout-cancel.no-charges': 'Aucun frais n\'a été prélevé sur votre compte. Vous pouvez réessayer à tout moment.',
'subscriptions.checkout-cancel.back-to-organizations': 'Retour aux Organisations',
'subscriptions.checkout-cancel.need-help': 'Besoin d\'aide ?',
'subscriptions.checkout-cancel.contact-support': 'Contacter le support',
'subscriptions.upgrade-dialog.title': 'Passer à Plus',
'subscriptions.upgrade-dialog.description': 'Débloquez des fonctionnalités puissantes pour votre organisation',
'subscriptions.upgrade-dialog.contact-us': 'Contactez-nous',
'subscriptions.upgrade-dialog.enterprise-plans': 'si vous avez besoin de plans d\'entreprise personnalisés.',
'subscriptions.upgrade-dialog.current-plan': 'Plan actuel',
'subscriptions.upgrade-dialog.recommended': 'Recommandé',
'subscriptions.upgrade-dialog.per-month': '/mois',
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Taille de stockage de documents',
'subscriptions.features.members': 'Membres de l\'organisation',
'subscriptions.features.members-count': '{{ count }} membres',
'subscriptions.features.email-intakes': 'Emails de réception',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresse',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresses',
'subscriptions.features.max-upload-size': 'Taille maximale de téléchargement',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Support communautaire',
'subscriptions.features.support-email': 'Support par email',
'subscriptions.billing-interval.monthly': 'Mensuel',
'subscriptions.billing-interval.annual': 'Annuel',
'subscriptions.usage-warning.message': 'Vous avez utilisé {{ percent }}% de votre stockage de documents. Envisagez de mettre à niveau votre plan pour obtenir plus d\'espace.',
'subscriptions.usage-warning.upgrade-button': 'Mettre à niveau',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione',
'organization.settings.delete.confirm.cancel-button': 'Annulla',
'organization.settings.delete.success': 'Organizzazione eliminata',
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
'organization.usage.page.title': 'Utilizzo',
'organization.usage.page.description': 'Visualizza l\'utilizzo attuale e i limiti della tua organizzazione.',
'organization.usage.storage.title': 'Archiviazione documenti',
'organization.usage.storage.description': 'Archiviazione totale utilizzata dai tuoi documenti',
'organization.usage.intake-emails.title': 'Email di acquisizione',
'organization.usage.intake-emails.description': 'Numero di indirizzi email di acquisizione',
'organization.usage.members.title': 'Membri',
'organization.usage.members.description': 'Numero di membri nell\'organizzazione',
'organization.usage.unlimited': 'Illimitato',
'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestisci i membri della tua organizzazione',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Impostazioni',
'layout.menu.account': 'Account',
'layout.menu.general-settings': 'Impostazioni generali',
'layout.menu.usage': 'Utilizzo',
'layout.menu.intake-emails': 'Email di acquisizione',
'layout.menu.webhooks': 'Webhook',
'layout.menu.members': 'Membri',
'layout.menu.invitations': 'Inviti',
'layout.upgrade-cta.title': 'Serve più spazio?',
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
'layout.upgrade-cta.button': 'Aggiorna a Plus',
'layout.theme.light': 'Modalità chiara',
'layout.theme.dark': 'Modalità scura',
'layout.theme.system': 'Modalità sistema',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Esiste già un tag con questo nome per questa organizzazione',
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Seleziona colore',
'color-picker.select-a-color': 'Seleziona un colore',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento riuscito!',
'subscriptions.checkout-success.description': 'Il tuo abbonamento è stato attivato con successo.',
'subscriptions.checkout-success.thank-you': 'Grazie per l\'upgrade a Papra Plus. Ora hai accesso a tutte le funzionalità premium.',
'subscriptions.checkout-success.go-to-organizations': 'Vai alle Organizzazioni',
'subscriptions.checkout-success.redirecting': 'Reindirizzamento tra {{ count }} secondo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento annullato',
'subscriptions.checkout-cancel.description': 'L\'upgrade del tuo abbonamento è stato annullato.',
'subscriptions.checkout-cancel.no-charges': 'Non sono stati effettuati addebiti sul tuo account. Puoi riprovare quando sei pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Torna alle Organizzazioni',
'subscriptions.checkout-cancel.need-help': 'Hai bisogno di aiuto?',
'subscriptions.checkout-cancel.contact-support': 'Contatta il supporto',
'subscriptions.upgrade-dialog.title': 'Passa a Plus',
'subscriptions.upgrade-dialog.description': 'Sblocca funzionalità potenti per la tua organizzazione',
'subscriptions.upgrade-dialog.contact-us': 'Contattaci',
'subscriptions.upgrade-dialog.enterprise-plans': 'se hai bisogno di piani aziendali personalizzati.',
'subscriptions.upgrade-dialog.current-plan': 'Piano attuale',
'subscriptions.upgrade-dialog.recommended': 'Consigliato',
'subscriptions.upgrade-dialog.per-month': '/mese',
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
'subscriptions.plan.free.name': 'Piano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dimensione archiviazione documenti',
'subscriptions.features.members': 'Membri dell\'organizzazione',
'subscriptions.features.members-count': '{{ count }} membri',
'subscriptions.features.email-intakes': 'Email di acquisizione',
'subscriptions.features.email-intakes-count-singular': '{{ count }} indirizzo',
'subscriptions.features.email-intakes-count-plural': '{{ count }} indirizzi',
'subscriptions.features.max-upload-size': 'Dimensione massima file caricamento',
'subscriptions.features.support': 'Supporto',
'subscriptions.features.support-community': 'Supporto della comunità',
'subscriptions.features.support-email': 'Supporto via email',
'subscriptions.billing-interval.monthly': 'Mensile',
'subscriptions.billing-interval.annual': 'Annuale',
'subscriptions.usage-warning.message': 'Hai utilizzato il {{ percent }}% dello spazio di archiviazione dei documenti. Considera l\'aggiornamento del piano per ottenere più spazio.',
'subscriptions.usage-warning.upgrade-button': 'Aggiorna piano',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Usuń organizację',
'organization.settings.delete.confirm.cancel-button': 'Anuluj',
'organization.settings.delete.success': 'Organizacja została usunięta',
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
'organization.usage.page.title': 'Użycie',
'organization.usage.page.description': 'Zobacz aktualne użycie i limity Twojej organizacji.',
'organization.usage.storage.title': 'Przechowywanie dokumentów',
'organization.usage.storage.description': 'Całkowite miejsce używane przez Twoje dokumenty',
'organization.usage.intake-emails.title': 'E-maile przychodzące',
'organization.usage.intake-emails.description': 'Liczba adresów e-mail przychodzących',
'organization.usage.members.title': 'Członkowie',
'organization.usage.members.description': 'Liczba członków w organizacji',
'organization.usage.unlimited': 'Nieograniczone',
'organizations.members.title': 'Członkowie',
'organizations.members.description': 'Zarządzaj członkami swojej organizacji',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Ustawienia',
'layout.menu.account': 'Konto',
'layout.menu.general-settings': 'Ustawienia ogólne',
'layout.menu.usage': 'Użycie',
'layout.menu.intake-emails': 'Adresy przyjęć',
'layout.menu.webhooks': 'Webhooki',
'layout.menu.members': 'Członkowie',
'layout.menu.invitations': 'Zaproszenia',
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
'layout.upgrade-cta.button': 'Przejdź na Plus',
'layout.theme.light': 'Tryb jasny',
'layout.theme.dark': 'Tryb ciemny',
'layout.theme.system': 'Tryb systemowy',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Tag o tej nazwie już istnieje w tej organizacji',
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Wybierz kolor',
'color-picker.select-a-color': 'Wybierz kolor',
// Subscriptions
'subscriptions.checkout-success.title': 'Płatność zakończona sukcesem!',
'subscriptions.checkout-success.description': 'Twoja subskrypcja została pomyślnie aktywowana.',
'subscriptions.checkout-success.thank-you': 'Dziękujemy za przejście na Papra Plus. Teraz masz dostęp do wszystkich funkcji premium.',
'subscriptions.checkout-success.go-to-organizations': 'Przejdź do Organizacji',
'subscriptions.checkout-success.redirecting': 'Przekierowanie za {{ count }} sekund{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Płatność anulowana',
'subscriptions.checkout-cancel.description': 'Twoja aktualizacja subskrypcji została anulowana.',
'subscriptions.checkout-cancel.no-charges': 'Nie pobrano żadnych opłat z Twojego konta. Możesz spróbować ponownie w dowolnym momencie.',
'subscriptions.checkout-cancel.back-to-organizations': 'Powrót do Organizacji',
'subscriptions.checkout-cancel.need-help': 'Potrzebujesz pomocy?',
'subscriptions.checkout-cancel.contact-support': 'Skontaktuj się z pomocą techniczną',
'subscriptions.upgrade-dialog.title': 'Przejdź na Plus',
'subscriptions.upgrade-dialog.description': 'Odblokuj zaawansowane funkcje dla swojej organizacji',
'subscriptions.upgrade-dialog.contact-us': 'Skontaktuj się z nami',
'subscriptions.upgrade-dialog.enterprise-plans': 'jeśli potrzebujesz niestandardowych planów biznesowych.',
'subscriptions.upgrade-dialog.current-plan': 'Obecny plan',
'subscriptions.upgrade-dialog.recommended': 'Polecane',
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
'subscriptions.plan.free.name': 'Plan darmowy',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Rozmiar przechowywania dokumentów',
'subscriptions.features.members': 'Członkowie organizacji',
'subscriptions.features.members-count': '{{ count }} członków',
'subscriptions.features.email-intakes': 'Adresy e-mail do przyjęć',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adres',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresy',
'subscriptions.features.max-upload-size': 'Maksymalny rozmiar pliku',
'subscriptions.features.support': 'Wsparcie',
'subscriptions.features.support-community': 'Wsparcie społeczności',
'subscriptions.features.support-email': 'Wsparcie e-mail',
'subscriptions.billing-interval.monthly': 'Miesięcznie',
'subscriptions.billing-interval.annual': 'Rocznie',
'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.',
'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Excluir organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização excluída',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
'organization.usage.storage.title': 'Armazenamento de documentos',
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
'organization.usage.intake-emails.title': 'E-mails de entrada',
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
'organization.usage.members.title': 'Membros',
'organization.usage.members.description': 'Número de membros na organização',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Membros',
'organizations.members.description': 'Gerencie os membros da sua organização',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Configurações',
'layout.menu.account': 'Conta',
'layout.menu.general-settings': 'Configurações gerais',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'E-mails de entrada',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membros',
'layout.menu.invitations': 'Convites',
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
'layout.upgrade-cta.button': 'Atualizar para Plus',
'layout.theme.light': 'Tema claro',
'layout.theme.dark': 'Tema escuro',
'layout.theme.system': 'Tema do sistema',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Já existe uma tag com este nome nesta organização',
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
'subscriptions.checkout-success.description': 'Sua assinatura foi ativada com sucesso.',
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora você tem acesso a todos os recursos premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
'subscriptions.checkout-success.redirecting': 'Redirecionando em {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
'subscriptions.checkout-cancel.description': 'Seu upgrade de assinatura foi cancelado.',
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita em sua conta. Você pode tentar novamente quando estiver pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
'subscriptions.checkout-cancel.contact-support': 'Contatar suporte',
'subscriptions.upgrade-dialog.title': 'Fazer upgrade para Plus',
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para sua organização',
'subscriptions.upgrade-dialog.contact-us': 'Entre em contato',
'subscriptions.upgrade-dialog.enterprise-plans': 'se você precisar de planos empresariais personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
'subscriptions.features.members': 'Membros da organização',
'subscriptions.features.members-count': '{{ count }} membros',
'subscriptions.features.email-intakes': 'E-mails de entrada',
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
'subscriptions.features.support': 'Suporte',
'subscriptions.features.support-community': 'Suporte da comunidade',
'subscriptions.features.support-email': 'Suporte por e-mail',
'subscriptions.billing-interval.monthly': 'Mensal',
'subscriptions.billing-interval.annual': 'Anual',
'subscriptions.usage-warning.message': 'Você usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar seu plano para obter mais espaço.',
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização eliminada',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
'organization.usage.storage.title': 'Armazenamento de documentos',
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
'organization.usage.intake-emails.title': 'E-mails de entrada',
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
'organization.usage.members.title': 'Membros',
'organization.usage.members.description': 'Número de membros na organização',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Membros',
'organizations.members.description': 'Gira os membros da sua organização',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Definições',
'layout.menu.account': 'Conta',
'layout.menu.general-settings': 'Definições gerais',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'E-mails de entrada',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membros',
'layout.menu.invitations': 'Convites',
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
'layout.upgrade-cta.button': 'Actualizar para Plus',
'layout.theme.light': 'Tema claro',
'layout.theme.dark': 'Tema escuro',
'layout.theme.system': 'Tema do sistema',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Já existe uma etiqueta com este nome nesta organização',
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
'subscriptions.checkout-success.description': 'A sua subscrição foi ativada com sucesso.',
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora tem acesso a todos os recursos premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
'subscriptions.checkout-success.redirecting': 'A redirecionar em {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
'subscriptions.checkout-cancel.description': 'O seu upgrade de subscrição foi cancelado.',
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita na sua conta. Pode tentar novamente quando estiver pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
'subscriptions.checkout-cancel.contact-support': 'Contactar suporte',
'subscriptions.upgrade-dialog.title': 'Atualizar para Plus',
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para a sua organização',
'subscriptions.upgrade-dialog.contact-us': 'Contacte-nos',
'subscriptions.upgrade-dialog.enterprise-plans': 'se precisar de planos empresariais personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
'subscriptions.features.members': 'Membros da organização',
'subscriptions.features.members-count': '{{ count }} membros',
'subscriptions.features.email-intakes': 'E-mails de entrada',
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
'subscriptions.features.support': 'Suporte',
'subscriptions.features.support-community': 'Suporte da comunidade',
'subscriptions.features.support-email': 'Suporte por e-mail',
'subscriptions.billing-interval.monthly': 'Mensal',
'subscriptions.billing-interval.annual': 'Anual',
'subscriptions.usage-warning.message': 'Usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar o seu plano para obter mais espaço.',
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Șterge organizație',
'organization.settings.delete.confirm.cancel-button': 'Anulează',
'organization.settings.delete.success': 'Organizație ștearsă cu succes',
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
'organization.usage.page.title': 'Utilizare',
'organization.usage.page.description': 'Vizualizează utilizarea curentă și limitele organizației tale.',
'organization.usage.storage.title': 'Stocare documente',
'organization.usage.storage.description': 'Spațiul total folosit de documentele tale',
'organization.usage.intake-emails.title': 'E-mailuri de intrare',
'organization.usage.intake-emails.description': 'Număr de adrese de e-mail de intrare',
'organization.usage.members.title': 'Membri',
'organization.usage.members.description': 'Număr de membri în organizație',
'organization.usage.unlimited': 'Nelimitat',
'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestionează membrii organizației tale',
@@ -519,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Setări',
'layout.menu.account': 'Cont',
'layout.menu.general-settings': 'Setări generale',
'layout.menu.usage': 'Utilizare',
'layout.menu.intake-emails': 'Email-uri de primire',
'layout.menu.webhooks': 'Webhook-uri',
'layout.menu.members': 'Membri',
'layout.menu.invitations': 'Invitații',
'layout.upgrade-cta.title': 'Ai nevoie de mai mult spațiu?',
'layout.upgrade-cta.description': 'Obține de 10x mai mult spațiu de stocare + colaborare în echipă',
'layout.upgrade-cta.button': 'Actualizează la Plus',
'layout.theme.light': 'Mod luminos',
'layout.theme.dark': 'Mod intunecat',
'layout.theme.system': 'Modul sistemului',
@@ -559,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'O etichetă cu acest nume există deja pentru aceasta organizație',
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
// Not found
@@ -582,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selectează culoarea',
'color-picker.select-a-color': 'Selectează o culoare',
// Subscriptions
'subscriptions.checkout-success.title': 'Plată reușită!',
'subscriptions.checkout-success.description': 'Abonamentul tău a fost activat cu succes.',
'subscriptions.checkout-success.thank-you': 'Mulțumim pentru că ai făcut upgrade la Papra Plus. Acum ai acces la toate funcționalitățile premium.',
'subscriptions.checkout-success.go-to-organizations': 'Mergi la Organizații',
'subscriptions.checkout-success.redirecting': 'Redirecționare în {{ count }} secundă{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Plată anulată',
'subscriptions.checkout-cancel.description': 'Upgrade-ul abonamentului tău a fost anulat.',
'subscriptions.checkout-cancel.no-charges': 'Nu au fost efectuate taxe pe contul tău. Poți încerca din nou oricând ești gata.',
'subscriptions.checkout-cancel.back-to-organizations': 'Înapoi la Organizații',
'subscriptions.checkout-cancel.need-help': 'Ai nevoie de ajutor?',
'subscriptions.checkout-cancel.contact-support': 'Contactează asistența',
'subscriptions.upgrade-dialog.title': 'Upgrade la Plus',
'subscriptions.upgrade-dialog.description': 'Deblochează funcționalități puternice pentru organizația ta',
'subscriptions.upgrade-dialog.contact-us': 'Contactează-ne',
'subscriptions.upgrade-dialog.enterprise-plans': 'dacă ai nevoie de planuri enterprise personalizate.',
'subscriptions.upgrade-dialog.current-plan': 'Plan curent',
'subscriptions.upgrade-dialog.recommended': 'Recomandat',
'subscriptions.upgrade-dialog.per-month': '/lună',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dimensiune stocare documente',
'subscriptions.features.members': 'Membri ai organizației',
'subscriptions.features.members-count': '{{ count }} membri',
'subscriptions.features.email-intakes': 'Email-uri de primire',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresă',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adrese',
'subscriptions.features.max-upload-size': 'Dimensiune maximă fișier upload',
'subscriptions.features.support': 'Asistență',
'subscriptions.features.support-community': 'Asistență comunitate',
'subscriptions.features.support-email': 'Asistență email',
'subscriptions.billing-interval.monthly': 'Lunar',
'subscriptions.billing-interval.annual': 'Anual',
'subscriptions.usage-warning.message': 'Ai folosit {{ percent }}% din spațiul de stocare pentru documente. Ia în considerare actualizarea planului pentru a obține mai mult spațiu.',
'subscriptions.usage-warning.upgrade-button': 'Actualizează planul',
};

View File

@@ -27,6 +27,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const query = useQuery(() => ({
queryKey: ['config'],
queryFn: fetchPublicConfig,
refetchOnWindowFocus: false,
}));
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {

View File

@@ -0,0 +1,102 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { Show, Suspense } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganizationUsage } from '@/modules/subscriptions/subscriptions.services';
import { Card, CardContent } from '@/modules/ui/components/card';
import { ProgressCircle } from '@/modules/ui/components/progress-circle';
import { Separator } from '@/modules/ui/components/separator';
const UsageCardLine: Component<{
title: string;
description: string;
used: number;
limit: number | null;
formatValue?: (value: number) => string;
}> = (props) => {
const { t } = useI18n();
const percentage = () => {
if (props.limit === null) {
return 0;
}
return Math.min((props.used / props.limit) * 100, 100);
};
const formatValue = (value: number) => {
return props.formatValue ? props.formatValue(value) : value.toString();
};
return (
<div class="flex gap-4 items-center ">
<ProgressCircle value={percentage()} size="xs" class="flex-shrink-0" />
<div class="flex-1">
<div class="font-medium leading-none">{props.title}</div>
<div class="text-sm text-muted-foreground">{props.description}</div>
</div>
<div class="text-muted-foreground leading-none">{ `${formatValue(props.used)} / ${props.limit === null ? t('organization.usage.unlimited') : formatValue(props.limit)}${props.limit ? ` - ${percentage().toFixed(2)}%` : ''}`}</div>
</div>
);
};
export const OrganizationUsagePage: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<Suspense>
<Show when={query.data}>
{getData => (
<>
<h1 class="text-xl font-semibold mb-2">
{t('organization.usage.page.title')}
</h1>
<p class="text-muted-foreground mb-6">
{t('organization.usage.page.description')}
</p>
<Card>
<CardContent class="pt-6 flex flex-col gap-4">
<UsageCardLine
title={t('organization.usage.storage.title')}
description={t('organization.usage.storage.description')}
used={getData().usage.documentsStorage.used}
limit={getData().usage.documentsStorage.limit}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.intake-emails.title')}
description={t('organization.usage.intake-emails.description')}
used={getData().usage.intakeEmailsCount.used}
limit={getData().usage.intakeEmailsCount.limit}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.members.title')}
description={t('organization.usage.members.description')}
used={getData().usage.membersCount.used}
limit={getData().usage.membersCount.limit}
/>
</CardContent>
</Card>
</>
)}
</Show>
</Suspense>
</div>
);
};

View File

@@ -15,7 +15,7 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { useCurrentUserRole, useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
import { fetchOrganization } from '../organizations.services';
@@ -24,6 +24,8 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { confirm } = useConfirmModal();
const { t } = useI18n();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
const handleDelete = async () => {
const confirmed = await confirm({
title: t('organization.settings.delete.confirm.title'),
@@ -54,10 +56,16 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription>
</CardHeader>
<CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive">
<CardFooter class="pt-6 gap-4">
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
<Show when={query.isSuccess && !getIsOwner()}>
<span class="text-sm text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Show>
</CardFooter>
</Card>
</div>

View File

@@ -0,0 +1,2 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';

View File

@@ -0,0 +1,220 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import { safely } from '@corentinth/chisels';
import { createSignal } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { PLUS_PLAN_ID } from '@/modules/plans/plans.constants';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
import { getCheckoutUrl } from '../subscriptions.services';
type PlanCardProps = {
name: string;
features: {
storageSize: number;
members: number;
emailIntakes: number;
maxUploadSize: number;
support: string;
};
isRecommended?: boolean;
isCurrent?: boolean;
price: number;
onUpgrade?: () => Promise<void>;
};
const PlanCard: Component<PlanCardProps> = (props) => {
const { t } = useI18n();
const [getIsUpgradeLoading, setIsUpgradeLoading] = createSignal(false);
const featureItems = [
{
icon: 'i-tabler-database',
title: t('subscriptions.features.storage-size'),
value: `${props.features.storageSize}GB`,
},
{
icon: 'i-tabler-users',
title: t('subscriptions.features.members'),
value: t('subscriptions.features.members-count', { count: props.features.members }),
},
{
icon: 'i-tabler-mail',
title: t('subscriptions.features.email-intakes'),
value: props.features.emailIntakes === 1
? t('subscriptions.features.email-intakes-count-singular', { count: props.features.emailIntakes })
: t('subscriptions.features.email-intakes-count-plural', { count: props.features.emailIntakes }),
},
{
icon: 'i-tabler-file-upload',
title: t('subscriptions.features.max-upload-size'),
value: `${props.features.maxUploadSize}MB`,
},
{
icon: 'i-tabler-headset',
title: t('subscriptions.features.support'),
value: props.features.support,
},
];
const upgrade = async () => {
if (!props.onUpgrade) {
return;
}
setIsUpgradeLoading(true);
await safely(props.onUpgrade());
setIsUpgradeLoading(false);
};
return (
<div class="border rounded-xl">
<div class="p-4">
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between">
<span>{props.name}</span>
{props.isCurrent && <span class="text-xs font-medium text-muted-foreground bg-muted rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.current-plan')}</span>}
{props.isRecommended && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.recommended')}</div>}
</div>
<div class="text-xl font-semibold flex items-center gap-2">
$
{props.price}
<span class="text-sm font-normal text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
</div>
<hr class="my-4" />
<div class="flex flex-col gap-3 ">
{featureItems.map(feature => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class={`p-1.5 rounded-lg ${props.isCurrent ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary'}`}>
<div class={`size-5 ${feature.icon}`}></div>
</div>
<div>
<div class="font-medium text-sm">{feature.value}</div>
<div class="text-xs text-muted-foreground">{feature.title}</div>
</div>
</div>
</div>
))}
</div>
{ props.onUpgrade && (
<>
<hr class="my-4" />
<Button onClick={upgrade} class="w-full" autofocus isLoading={getIsUpgradeLoading()}>
{t('subscriptions.upgrade-dialog.upgrade-now')}
<div class="i-tabler-arrow-right size-5 ml-2"></div>
</Button>
</>
)}
</div>
</div>
);
};
type UpgradeDialogProps = {
children: (props: DialogTriggerProps) => JSX.Element;
organizationId: string;
};
export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
const { t } = useI18n();
const [getIsOpen, setIsOpen] = createSignal(false);
const defaultBillingInterval: 'monthly' | 'annual' = 'annual';
const [getBillingInterval, setBillingInterval] = createSignal<'monthly' | 'annual'>(defaultBillingInterval);
const onUpgrade = async () => {
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId: PLUS_PLAN_ID, billingInterval: getBillingInterval() });
window.location.href = checkoutUrl;
};
// Simplified plan configuration - only the values
const currentPlan = {
name: t('subscriptions.plan.free.name'),
monthlyPrice: 0,
annualPrice: 0,
features: {
storageSize: 0.5, // 500MB = 0.5GB
members: 3,
emailIntakes: 1,
maxUploadSize: 25,
support: t('subscriptions.features.support-community'),
},
isCurrent: true,
};
const plusPlan = {
name: t('subscriptions.plan.plus.name'),
monthlyPrice: 9,
annualPrice: 90,
features: {
storageSize: 5,
members: 10,
emailIntakes: 10,
maxUploadSize: 100,
support: t('subscriptions.features.support-email'),
},
isRecommended: true,
};
const getPlanPrice = (plan: { monthlyPrice: number; annualPrice: number }) => {
return getBillingInterval() === 'monthly' ? plan.monthlyPrice : Math.round(100 * plan.annualPrice / 12) / 100;
};
return (
<Dialog open={getIsOpen()} onOpenChange={setIsOpen}>
<DialogTrigger as={props.children} />
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="i-tabler-sparkles size-7 text-primary"></div>
</div>
<div>
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{t('subscriptions.upgrade-dialog.description')}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div class="mt-2 flex flex-col items-center">
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
onClick={() => setBillingInterval('monthly')}
>
{t('subscriptions.billing-interval.monthly')}
</Button>
<Button
size="sm"
variant="ghost"
class={cn('text-sm pr-1.5', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
onClick={() => setBillingInterval('annual')}
>
{t('subscriptions.billing-interval.annual')}
<span class="ml-2 text-xs text-muted-foreground rounded bg-primary/10 text-primary px-1 py-0.5">-20%</span>
</Button>
</div>
</div>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 ">
<div>
<PlanCard {...currentPlan} price={getPlanPrice(currentPlan)} />
<p class="text-muted-foreground text-xs p-4 ml-1">
<a href="https://papra.app/contact" class="underline" target="_blank" rel="noreferrer">{t('subscriptions.upgrade-dialog.contact-us')}</a>
{' '}
{t('subscriptions.upgrade-dialog.enterprise-plans')}
</p>
</div>
<PlanCard {...plusPlan} onUpgrade={onUpgrade} price={getPlanPrice(plusPlan)} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import type { Component } from 'solid-js';
import { makePersisted } from '@solid-primitives/storage';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { fetchOrganizationUsage } from '../subscriptions.services';
import { UpgradeDialog } from './upgrade-dialog.component';
const ONE_DAY_IN_MS = 24/* hours */ * 60/* minutes */ * 60/* seconds */ * 1000/* milliseconds */;
export const UsageWarningCard: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const getOrganizationId = () => props.organizationId;
// TODO: mutualize the creation of the storage key
const [getDismissedDate, setDismissedDate] = makePersisted(createSignal<number | null>(null), { name: `papra:${getOrganizationId()}:usage-warning-dismissed`, storage: localStorage });
const query = useQuery(() => ({
queryKey: ['organizations', getOrganizationId(), 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: getOrganizationId() }),
refetchOnWindowFocus: false,
}));
const getStorageSizeUsedPercent = () => {
const { data: usageData } = query;
if (!usageData || usageData.limits.maxDocumentsSize === null) {
return 0;
}
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
};
const shouldShow = () => {
const { data: usageData } = query;
if (!usageData) {
return false;
}
const dismissedAt = getDismissedDate();
const storagePercent = getStorageSizeUsedPercent();
const isOver80Percent = storagePercent >= 80;
if (!isOver80Percent) {
return false;
}
if (dismissedAt) {
const now = Date.now();
// Show the warning if the banner was dismissed more than 24h ago
return dismissedAt + ONE_DAY_IN_MS < now;
}
return isOver80Percent;
};
return (
<Show when={shouldShow()}>
<div class="bg-destructive/10 border-b border-b-destructive text-red-500 px-6 py-3 flex items-center gap-4 ">
<div class="max-w-5xl mx-auto flex sm:items-center gap-2 flex-col sm:flex-row">
<span class="text-sm">
<span class="i-tabler-alert-triangle size-5 inline-block mb--1 mr-2" />
{t('subscriptions.usage-warning.message', { percent: getStorageSizeUsedPercent().toFixed(2) })}
</span>
<UpgradeDialog organizationId={getOrganizationId()}>
{triggerProps => (
<Button
variant="outline"
size="sm"
class="flex-shrink-0"
{...triggerProps}
>
{t('subscriptions.usage-warning.upgrade-button')}
</Button>
)}
</UpgradeDialog>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto op-50 hover:op-100 transition flex-shrink-0 hidden sm:flex"
onClick={() => setDismissedDate(Date.now())}
>
<span class="i-tabler-x size-5" />
</Button>
</div>
</Show>
);
};

View File

@@ -0,0 +1,47 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutCancelPage: Component = () => {
const { t } = useI18n();
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-muted rounded-full">
<div class="i-tabler-x size-16 text-muted-foreground" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-cancel.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-cancel.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-cancel.no-charges')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-cancel.back-to-organizations')}
<div class="i-tabler-arrow-left size-5 mr-2 order-first" />
</Button>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-cancel.need-help')}
{' '}
<a href="https://papra.app/contact" class="underline hover:no-underline" target="_blank" rel="noreferrer">
{t('subscriptions.checkout-cancel.contact-support')}
</a>
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import type { Component } from 'solid-js';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutSuccessPage: Component = () => {
const { t } = useI18n();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [countdown, setCountdown] = createSignal(5);
createEffect(() => {
const sessionId = searchParams.sessionId;
// If no session ID, redirect immediately
if (!sessionId) {
navigate('/');
return;
}
// Start countdown
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(interval);
navigate('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
});
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-primary/10 rounded-full">
<div class="i-tabler-check size-16 text-primary" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-success.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-success.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-success.thank-you')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-success.go-to-organizations')}
<div class="i-tabler-arrow-right size-5 ml-2" />
</Button>
<Show when={countdown() > 0}>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-success.redirecting', {
count: countdown(),
plural: countdown() !== 1 ? 's' : '',
})}
</p>
</Show>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,13 @@
import type { OrganizationSubscription } from './subscriptions.types';
import { apiClient } from '../shared/http/api-client';
export async function getCheckoutUrl({ organizationId, planId }: { organizationId: string; planId: string }) {
export async function getCheckoutUrl({ organizationId, planId, billingInterval }: { organizationId: string; planId: string; billingInterval: 'monthly' | 'annual' }) {
const { checkoutUrl } = await apiClient<{ checkoutUrl: string }>({
method: 'POST',
path: `/api/organizations/${organizationId}/checkout-session`,
body: {
planId,
billingInterval,
},
});
@@ -22,11 +23,27 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
return { customerPortalUrl };
}
export async function getOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription } = await apiClient<{ subscription: OrganizationSubscription }>({
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription, plan } = await apiClient<{ subscription: OrganizationSubscription; plan: { id: string; name: string } }>({
method: 'GET',
path: `/api/organizations/${organizationId}/subscription`,
});
return { subscription };
return { subscription, plan };
}
export async function fetchOrganizationUsage({ organizationId }: { organizationId: string }) {
const { usage, limits } = await apiClient<{
usage: {
documentsStorage: { used: number; limit: number | null };
intakeEmailsCount: { used: number; limit: number | null };
membersCount: { used: number; limit: number | null };
};
limits: { maxDocumentsSize: number | null; maxIntakeEmailsCount: number | null; maxOrganizationsMembersCount: number | null };
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/usage`,
});
return { usage, limits };
}

View File

@@ -34,7 +34,7 @@ export function DialogContent<T extends ValidComponent = 'div'>(props: Polymorph
/>
<DialogPrimitive.Content
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
local.class,
)}
{...rest}

View File

@@ -0,0 +1,96 @@
import type { Component, ComponentProps } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizes: Record<Size, { radius: number; strokeWidth: number }> = {
xs: { radius: 15, strokeWidth: 4 },
sm: { radius: 19, strokeWidth: 4 },
md: { radius: 32, strokeWidth: 6 },
lg: { radius: 52, strokeWidth: 8 },
xl: { radius: 80, strokeWidth: 10 },
};
type ProgressCircleProps = ComponentProps<'div'> & {
value?: number;
size?: Size;
radius?: number;
strokeWidth?: number;
showAnimation?: boolean;
};
const ProgressCircle: Component<ProgressCircleProps> = (rawProps) => {
const props = mergeProps({ size: 'md' as Size, showAnimation: true }, rawProps);
const [local, others] = splitProps(props, [
'class',
'children',
'value',
'size',
'radius',
'strokeWidth',
'showAnimation',
]);
const value = () => getLimitedValue(local.value);
const radius = () => local.radius ?? sizes[local.size].radius;
const strokeWidth = () => local.strokeWidth ?? sizes[local.size].strokeWidth;
const normalizedRadius = () => radius() - strokeWidth() / 2;
const circumference = () => normalizedRadius() * 2 * Math.PI;
const strokeDashoffset = () => (value() / 100) * circumference();
const offset = () => circumference() - strokeDashoffset();
return (
<div class={cn('flex flex-col items-center justify-center', local.class)} {...others}>
<svg
width={radius() * 2}
height={radius() * 2}
viewBox={`0 0 ${radius() * 2} ${radius() * 2}`}
class="-rotate-90"
aria-hidden="true"
>
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn('stroke-secondary transition-colors ease-linear')}
/>
{value() >= 0
? (
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
stroke-dasharray={`${circumference()} ${circumference()}`}
stroke-dashoffset={offset()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn(
'stroke-primary transition-colors ease-linear',
local.showAnimation ? 'transition-all duration-300 ease-in-out' : '',
)}
/>
)
: null}
</svg>
<div class={cn('absolute flex')}>{local.children}</div>
</div>
);
};
function getLimitedValue(input: number | undefined) {
if (input === undefined) {
return 0;
} else if (input > 100) {
return 100;
}
return input;
}
export { ProgressCircle };

View File

@@ -14,6 +14,11 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
href: `/organizations/${params.organizationId}/settings`,
icon: 'i-tabler-settings',
},
{
label: t('layout.menu.usage'),
href: `/organizations/${params.organizationId}/settings/usage`,
icon: 'i-tabler-chart-bar',
},
{
label: t('layout.menu.intake-emails'),
href: `/organizations/${params.organizationId}/settings/intake-emails`,

View File

@@ -5,10 +5,14 @@ import type { Organization } from '@/modules/organizations/organizations.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, useQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { createEffect, on } from 'solid-js';
import { createEffect, on, Show } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
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 { UpgradeDialog } from '@/modules/subscriptions/components/upgrade-dialog.component';
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '../components/button';
import {
Select,
SelectContent,
@@ -18,6 +22,48 @@ import {
} from '../components/select';
import { SideNav, SidenavLayout } from './sidenav.layout';
const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const { config } = useConfig();
const query = useQuery(() => ({
queryKey: ['organizations', 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
}));
const shouldShowUpgradeCTA = () => {
if (!config.isSubscriptionsEnabled) {
return false;
}
return query.data && query.data.plan.id === 'free';
};
return (
<Show when={shouldShowUpgradeCTA()}>
<div class="p-4 mx-4 mt-4 bg-background bg-gradient-to-br from-primary/15 to-transparent rounded-lg">
<div class="flex items-center gap-2 text-sm font-medium">
<div class="i-tabler-sparkles size-4 text-primary"></div>
{t('layout.upgrade-cta.title')}
</div>
<div class="text-xs mt-1 mb-3 text-muted-foreground">
{t('layout.upgrade-cta.description')}
</div>
<UpgradeDialog organizationId={props.organizationId}>
{dialogProps => (
<Button size="sm" class="w-full font-semibold" {...dialogProps}>
{t('layout.upgrade-cta.button')}
<div class="i-tabler-arrow-right size-4 ml-1"></div>
</Button>
)}
</UpgradeDialog>
</div>
</Show>
);
};
const OrganizationLayoutSideNav: Component = () => {
const navigate = useNavigate();
const params = useParams();
@@ -98,6 +144,7 @@ const OrganizationLayoutSideNav: Component = () => {
<SideNav
mainMenu={getMainMenuItems()}
footerMenu={getFooterMenuItems()}
footer={() => <UpgradeCTAFooter organizationId={params.organizationId} />}
header={() =>
(
<div class="px-6 pt-4 max-w-285px min-w-0">

View File

@@ -12,6 +12,7 @@ import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.
import { useI18n } from '@/modules/i18n/i18n.provider';
import { usePendingInvitationsCount } from '@/modules/invitations/composables/usePendingInvitationsCount';
import { cn } from '@/modules/shared/style/cn';
import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
@@ -47,6 +48,7 @@ export const SideNav: Component<{
footerMenu?: MenuItem[];
header?: Component;
footer?: Component;
preFooter?: Component;
}> = (props) => {
const { config } = useConfig();
@@ -106,7 +108,7 @@ export const SideNav: Component<{
</a>
</div>
{(props.header || props.mainMenu || props.footerMenu || props.footer) && (
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
<div class="h-full flex flex-col pb-6 flex-1">
{props.header && <props.header />}
@@ -118,6 +120,8 @@ export const SideNav: Component<{
<div class="flex-1"></div>
{props.preFooter && <props.preFooter />}
{props.footerMenu && (
<nav class="flex flex-col gap-0.5 px-4">
{props.footerMenu.map(menuItem => <MenuItemButton {...menuItem} />)}
@@ -199,7 +203,10 @@ export const SidenavLayout: ParentComponent<{
<props.sideNav />
</div>
<div class="flex-1 min-h-0 flex flex-col">
<UsageWarningCard organizationId={params.organizationId} />
<div class="flex justify-between px-6 pt-4">
<div class="flex items-center">
@@ -301,6 +308,7 @@ export const SidenavLayout: ParentComponent<{
<div class="flex-1 overflow-auto max-w-screen">
<Suspense>
{props.children}
</Suspense>
</div>
</div>

View File

@@ -21,10 +21,13 @@ import { CreateOrganizationPage } from './modules/organizations/pages/create-org
import { InvitationsListPage } from './modules/organizations/pages/invitations-list.page';
import { InviteMemberPage } from './modules/organizations/pages/invite-member.page';
import { MembersPage } from './modules/organizations/pages/members.page';
import { OrganizationUsagePage } from './modules/organizations/pages/organization-usage.page';
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 { NotFoundPage } from './modules/shared/pages/not-found.page';
import { CheckoutCancelPage } from './modules/subscriptions/pages/checkout-cancel.page';
import { CheckoutSuccessPage } from './modules/subscriptions/pages/checkout-success.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';
@@ -155,6 +158,10 @@ export const routes: RouteDefinition[] = [
path: '/',
component: OrganizationsSettingsPage,
},
{
path: '/usage',
component: OrganizationUsagePage,
},
{
path: '/webhooks/create',
component: CreateWebhookPage,
@@ -227,6 +234,14 @@ export const routes: RouteDefinition[] = [
path: '/email-validation-required',
component: createProtectedPage({ authType: 'public-only', component: EmailValidationRequiredPage }),
},
{
path: '/checkout-success',
component: CheckoutSuccessPage,
},
{
path: '/checkout-cancel',
component: CheckoutCancelPage,
},
{
path: '*404',
component: NotFoundPage,

View File

@@ -1,5 +1,21 @@
# @papra/app-server
## 0.9.6
### Patch Changes
- [#531](https://github.com/papra-hq/papra/pull/531) [`2e2bb6f`](https://github.com/papra-hq/papra/commit/2e2bb6fbbdd02f6b8352ef2653bef0447948c1f0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added env variable to configure ip header for rate limit
- [#524](https://github.com/papra-hq/papra/pull/524) [`c84a921`](https://github.com/papra-hq/papra/commit/c84a9219886ecb2a77c67d904cf8c8d15b50747b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the api validation of tag colors to make it case incensitive
## 0.9.5
### Patch Changes
- [#521](https://github.com/papra-hq/papra/pull/521) [`b287723`](https://github.com/papra-hq/papra/commit/b28772317c3662555e598755b85597d6cd5aeea1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names encoding (utf8 instead of latin1) to support non-ASCII characters.
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.9.4",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",

View File

@@ -55,6 +55,17 @@ export const authConfig = {
default: false,
env: 'AUTH_SHOW_LEGAL_LINKS',
},
ipAddressHeaders: {
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
- If behind Cloudflare, you might want to set this to "cf-connecting-ip".`,
schema: z.union([
z.string(),
z.array(z.string()),
]).transform(value => (typeof value === 'string' ? value.split(',').map(v => v.trim()) : value)),
default: ['x-forwarded-for'],
env: 'AUTH_IP_ADDRESS_HEADERS',
},
providers: {
email: {
isEnabled: {

View File

@@ -37,8 +37,8 @@ export function getAuth({
trustedOrigins,
logger: {
disabled: false,
log: (baseLevel, message) => {
logger[baseLevel ?? 'info'](message);
log: (baseLevel, message, ...args: unknown[]) => {
logger[baseLevel ?? 'info']({ ...args }, message);
},
},
emailAndPassword: {
@@ -85,6 +85,9 @@ export function getAuth({
advanced: {
// Drizzle tables handle the id generation
database: { generateId: false },
ipAddress: {
ipAddressHeaders: config.auth.ipAddressHeaders,
},
},
socialProviders: {
github: {

View File

@@ -46,7 +46,7 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
const app = new Hono<ServerInstanceGenerics>({ strict: true });
app.use(createLoggerMiddleware());
app.use(createLoggerMiddleware({ config }));
app.use(createCorsMiddleware({ config }));
app.use(createTimeoutMiddleware({ config }));
app.use(secureHeaders());

View File

@@ -127,5 +127,71 @@ describe('documents e2e', () => {
// Ensure no file is saved in the storage
expect(documentsStorageService._getStorage().size).to.eql(0);
});
// https://github.com/papra-hq/papra/issues/519
test('uploading documents with various UTF-8 characters in filenames', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
});
// Various UTF-8 characters that cause encoding issues
const testCases = [
{ filename: 'ΒΕΒΑΙΩΣΗ ΧΑΡΕΣ.txt', content: 'Filename with Greek characters' },
{ filename: 'résumé français.txt', content: 'French document' },
{ filename: 'documento español.txt', content: 'Spanish document' },
{ filename: '日本語ファイル.txt', content: 'Japanese document' },
{ filename: 'файл на русском.txt', content: 'Russian document' },
{ filename: 'émojis 🎉📄.txt', content: 'Document with emojis' },
];
for (const testCase of testCases) {
const formData = new FormData();
formData.append('file', new File([testCase.content], testCase.filename, { type: 'text/plain' }));
const body = new Response(formData);
const createDocumentResponse = await app.request(
'/api/organizations/org_222222222222222222222222/documents',
{
method: 'POST',
headers: {
...Object.fromEntries(body.headers.entries()),
},
body: await body.arrayBuffer(),
},
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(createDocumentResponse.status).to.eql(200);
const { document } = (await createDocumentResponse.json()) as { document: Document };
// Each filename should be preserved correctly
expect(document.name).to.eql(testCase.filename);
expect(document.originalName).to.eql(testCase.filename);
// Retrieve the document
const getDocumentResponse = await app.request(
`/api/organizations/org_222222222222222222222222/documents/${document.id}`,
{ method: 'GET' },
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(getDocumentResponse.status).to.eql(200);
const { document: retrievedDocument } = (await getDocumentResponse.json()) as { document: Document };
expect(retrievedDocument).to.eql({ ...document, tags: [] });
}
});
});
});

View File

@@ -230,7 +230,7 @@ describe('intake-emails usecases', () => {
id: 'os-1',
organizationId: 'org-1',
status: 'active',
seatsCount: 1,
seatsCount: 10,
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
customerId: 'sc_123',

View File

@@ -47,3 +47,9 @@ export const createUserAlreadyInOrganizationError = createErrorFactory({
code: 'user.already_in_organization',
statusCode: 400,
});
export const createMaxOrganizationMembersCountReachedError = createErrorFactory({
message: 'You have reached the maximum number of members in this organization.',
code: 'organization.max_members_count_reached',
statusCode: 403,
});

View File

@@ -42,6 +42,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
getOrganizationMemberByEmail,
getOrganizationInvitations,
updateExpiredPendingInvitationsStatus,
getOrganizationPendingInvitationsCount,
},
{ db },
);
@@ -444,3 +445,27 @@ async function updateExpiredPendingInvitationsStatus({ db, now = new Date() }: {
),
);
}
async function getOrganizationPendingInvitationsCount({ organizationId, db }: { organizationId: string; db: Database }) {
const [record] = await db
.select({
pendingInvitationsCount: count(organizationInvitationsTable.id),
})
.from(organizationInvitationsTable)
.where(
and(
eq(organizationInvitationsTable.organizationId, organizationId),
eq(organizationInvitationsTable.status, ORGANIZATION_INVITATION_STATUS.PENDING),
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { pendingInvitationsCount } = record;
return {
pendingInvitationsCount,
};
}

View File

@@ -3,12 +3,14 @@ import { z } from 'zod';
import { createForbiddenError } from '../app/auth/auth.errors';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createPlansRepository } from '../plans/plans.repository';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createUsersRepository } from '../users/users.repository';
import { memberIdSchema, organizationIdSchema } from './organization.schemas';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationsRepository } from './organizations.repository';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
setupGetOrganizationsRoute(context);
@@ -130,7 +132,9 @@ function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
const organizationsRepository = createOrganizationsRepository({ db });
// No Promise.all as we want to ensure consistency in error handling
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureUserIsOwnerOfOrganization({ userId, organizationId, organizationsRepository });
await organizationsRepository.deleteOrganization({ organizationId });
@@ -252,6 +256,8 @@ function setupInviteOrganizationMemberRoute({ app, db, config, emailsServices }:
const { email, role } = context.req.valid('json');
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const plansRepository = createPlansRepository({ config });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
@@ -260,6 +266,8 @@ function setupInviteOrganizationMemberRoute({ app, db, config, emailsServices }:
role,
organizationId,
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: userId,
expirationDelayDays: config.organizations.invitationExpirationDelayDays,
maxInvitationsPerDay: config.organizations.maxUserInvitationsPerDay,

View File

@@ -1,3 +1,4 @@
import type { EmailsServices } from '../emails/emails.services';
import type { PlansRepository } from '../plans/plans.repository';
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
import { describe, expect, test } from 'vitest';
@@ -10,10 +11,10 @@ import { createTestLogger } from '../shared/logger/logger.test-utils';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createUsersRepository } from '../users/users.repository';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationDocumentStorageLimitReachedError, createOrganizationNotFoundError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError } from './organizations.errors';
import { createMaxOrganizationMembersCountReachedError, createOrganizationDocumentStorageLimitReachedError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
import { createOrganizationsRepository } from './organizations.repository';
import { organizationMembersTable, organizationsTable } from './organizations.table';
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, removeMemberFromOrganization } from './organizations.usecases';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, removeMemberFromOrganization } from './organizations.usecases';
describe('organizations usecases', () => {
describe('ensureUserIsInOrganization', () => {
@@ -166,7 +167,7 @@ describe('organizations usecases', () => {
id: 'org_sub_1',
organizationId: 'organization-1',
planId: PLUS_PLAN_ID,
seatsCount: 1,
seatsCount: 10,
customerId: 'cus_123',
status: 'active',
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
@@ -463,4 +464,565 @@ describe('organizations usecases', () => {
]);
});
});
describe('inviteMemberToOrganization', () => {
test('only organization owners and admins can invite members, regular members cannot send invitations', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'admin@example.com' },
{ id: 'user-3', email: 'member@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
{ organizationId: 'organization-1', userId: 'user-2', role: ORGANIZATION_ROLES.ADMIN },
{ organizationId: 'organization-1', userId: 'user-3', role: ORGANIZATION_ROLES.MEMBER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const sentEmails: unknown[] = [];
const emailsServices = {
sendEmail: async (args: unknown) => sentEmails.push(args),
} as unknown as EmailsServices;
// Owner can invite
const { organizationInvitation: ownerInvitation } = await inviteMemberToOrganization({
email: 'new-member-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
emailsServices,
config,
});
expect(ownerInvitation?.email).toBe('new-member-1@example.com');
// Admin can invite
const { organizationInvitation: adminInvitation } = await inviteMemberToOrganization({
email: 'new-member-2@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-2',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
emailsServices,
config,
});
expect(adminInvitation?.email).toBe('new-member-2@example.com');
// Member cannot invite
await expect(
inviteMemberToOrganization({
email: 'new-member-3@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-3',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createForbiddenError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Inviter does not have permission to invite members to organization',
namespace: 'test',
data: {
inviterId: 'user-3',
organizationId: 'organization-1',
},
},
]);
});
test('it is not possible to create an invitation for the owner role to prevent multiple owners in an organization', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-owner@example.com',
role: ORGANIZATION_ROLES.OWNER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createForbiddenError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Cannot create another owner in organization',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
},
},
]);
});
test('cannot invite a user who is already a member of the organization to prevent duplicate memberships', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'existing-member@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
{ id: 'member-2', organizationId: 'organization-1', userId: 'user-2', role: ORGANIZATION_ROLES.MEMBER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'existing-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createUserAlreadyInOrganizationError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'User already in organization',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
email: 'existing-member@example.com',
memberId: 'member-2',
memberUserId: 'user-2',
},
},
]);
});
test('cannot create multiple invitations for the same email address to the same organization to prevent spam and confusion', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
id: 'invitation-1',
organizationId: 'organization-1',
email: 'invited@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'invited@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createOrganizationInvitationAlreadyExistsError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Invitation already exists',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
email: 'invited@example.com',
invitationId: 'invitation-1',
},
},
]);
});
test('cannot invite new members when the organization has reached its maximum member count (including pending invitations) defined by the plan to enforce subscription limits', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
organizationId: 'organization-1',
email: 'pending-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 2,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createMaxOrganizationMembersCountReachedError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Organization has reached its maximum number of members',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
membersCount: 1,
maxMembers: 2,
},
},
]);
});
test('users have a daily invitation limit to prevent spam and abuse of the invitation system', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
organizationId: 'organization-1',
email: 'invited-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
createdAt: new Date('2025-10-05T10:00:00Z'),
},
{
organizationId: 'organization-1',
email: 'invited-2@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
createdAt: new Date('2025-10-05T14:00:00Z'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 2 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 2,
now: new Date('2025-10-05T18:00:00Z'),
emailsServices,
config,
}),
).rejects.toThrow(createUserOrganizationInvitationLimitReachedError());
});
test('invitations are created with the correct expiration date and an email notification is sent to the invited user', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({
organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 },
client: { baseUrl: 'https://app.example.com' },
});
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const sentEmails: unknown[] = [];
const emailsServices = {
sendEmail: async (args: unknown) => sentEmails.push(args),
} as unknown as EmailsServices;
const now = new Date('2025-10-05T12:00:00Z');
const { organizationInvitation } = await inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
now,
emailsServices,
config,
});
expect(organizationInvitation).toMatchObject({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-10-12T12:00:00Z'),
});
// Verify email was sent
expect(sentEmails).toHaveLength(1);
expect(sentEmails[0]).toMatchObject({
to: 'new-member@example.com',
subject: 'You are invited to join an organization',
});
// Verify invitation was saved in database
const invitations = await db.select().from(organizationInvitationsTable);
expect(invitations).toHaveLength(1);
expect(invitations[0]).toMatchObject({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
inviterId: 'user-1',
status: 'pending',
});
});
test('users who are not members of the organization cannot send invitations to prevent unauthorized access', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'outsider@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-2',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createUserNotInOrganizationError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Inviter not found in organization',
namespace: 'test',
data: {
inviterId: 'user-2',
organizationId: 'organization-1',
},
},
]);
});
});
});

View File

@@ -18,6 +18,7 @@ import { createLogger } from '../shared/logger/logger';
import { isDefined } from '../shared/utils';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import {
createMaxOrganizationMembersCountReachedError,
createOrganizationDocumentStorageLimitReachedError,
createOrganizationInvitationAlreadyExistsError,
createOrganizationNotFoundError,
@@ -212,6 +213,8 @@ export async function inviteMemberToOrganization({
role,
organizationId,
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId,
expirationDelayDays,
maxInvitationsPerDay,
@@ -224,6 +227,8 @@ export async function inviteMemberToOrganization({
role: OrganizationRole;
organizationId: string;
organizationsRepository: OrganizationsRepository;
subscriptionsRepository: SubscriptionsRepository;
plansRepository: PlansRepository;
inviterId: string;
expirationDelayDays: number;
maxInvitationsPerDay: number;
@@ -263,6 +268,15 @@ export async function inviteMemberToOrganization({
throw createOrganizationInvitationAlreadyExistsError();
}
const { membersCount } = await organizationsRepository.getOrganizationMembersCount({ organizationId });
const { pendingInvitationsCount } = await organizationsRepository.getOrganizationPendingInvitationsCount({ organizationId });
const { organizationPlan } = await getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository });
if ((membersCount + pendingInvitationsCount) >= organizationPlan.limits.maxOrganizationsMembersCount) {
logger.error({ inviterId, organizationId, membersCount, maxMembers: organizationPlan.limits.maxOrganizationsMembersCount }, 'Organization has reached its maximum number of members');
throw createMaxOrganizationMembersCountReachedError();
}
await checkIfUserHasReachedOrganizationInvitationLimit({
userId: inviterId,
maxInvitationsPerDay,

View File

@@ -9,16 +9,16 @@ export const organizationPlansConfig = {
default: true,
env: 'IS_FREE_PLAN_UNLIMITED',
},
plusPlanPriceId: {
doc: 'The price id of the plus plan (useless for self-hosting)',
plusPlanMonthlyPriceId: {
doc: 'The monthly price id of the plus plan (useless for self-hosting)',
schema: z.string(),
default: 'change-me',
env: 'PLANS_PLUS_PLAN_PRICE_ID',
env: 'PLANS_PLUS_PLAN_MONTHLY_PRICE_ID',
},
familyPlanPriceId: {
doc: 'The price id of the family plan (useless for self-hosting)',
plusPlanAnnualPriceId: {
doc: 'The annual price id of the plus plan (useless for self-hosting)',
schema: z.string(),
default: 'change-me',
env: 'PLANS_FAMILY_PLAN_PRICE_ID',
env: 'PLANS_PLUS_PLAN_ANNUAL_PRICE_ID',
},
} as const satisfies ConfigDefinition;

View File

@@ -1,3 +1,2 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';
export const FAMILY_PLAN_ID = 'family';

View File

@@ -5,3 +5,10 @@ export const createPlanNotFoundError = createErrorFactory({
message: 'Plan not found',
statusCode: 404,
});
export const createOrganizationPlanPriceIdNotSetError = createErrorFactory({
code: 'plans.organization_plan_price_id_not_set',
message: 'Organization plan price ID is not set',
statusCode: 500,
isInternal: true,
});

View File

@@ -0,0 +1,20 @@
import { isNil } from '../shared/utils';
import { createOrganizationPlanPriceIdNotSetError } from './plans.errors';
export function getPriceIdForBillingInterval({
plan,
billingInterval,
}: {
plan: { monthlyPriceId?: string; annualPriceId?: string };
billingInterval: 'monthly' | 'annual';
}) {
const priceId = billingInterval === 'annual' ? plan.annualPriceId : plan.monthlyPriceId;
if (isNil(priceId)) {
// Very unlikely to happen, as only the free plan does not have a price ID, and we check for the plans in the route validation
// but for type safety, we assert that the price ID is set
throw createOrganizationPlanPriceIdNotSetError();
}
return { priceId };
}

View File

@@ -2,7 +2,7 @@ import type { Config } from '../config/config.types';
import type { OrganizationPlanRecord } from './plans.types';
import { injectArguments } from '@corentinth/chisels';
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
import { FAMILY_PLAN_ID, FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
import { FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
import { createPlanNotFoundError } from './plans.errors';
export type PlansRepository = ReturnType<typeof createPlansRepository>;
@@ -29,36 +29,22 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
[FREE_PLAN_ID]: {
id: FREE_PLAN_ID,
name: 'Free',
isPerSeat: true,
limits: {
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 10,
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
},
},
[PLUS_PLAN_ID]: {
id: PLUS_PLAN_ID,
name: 'Plus',
priceId: config.organizationPlans.plusPlanPriceId,
isPerSeat: true,
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
maxIntakeEmailsCount: 10,
maxOrganizationsMembersCount: 100,
maxFileSize: 1024 * 1024 * 100, // 100 MiB
},
},
[FAMILY_PLAN_ID]: {
id: FAMILY_PLAN_ID,
name: 'Family',
priceId: config.organizationPlans.familyPlanPriceId,
isPerSeat: false,
defaultSeatsCount: 6,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
maxIntakeEmailsCount: 10,
maxOrganizationsMembersCount: 6,
maxOrganizationsMembersCount: 10,
maxFileSize: 1024 * 1024 * 100, // 100 MiB
},
},
@@ -78,7 +64,7 @@ async function getOrganizationPlanById({ planId, organizationPlans }: { planId:
}
async function getOrganizationPlanByPriceId({ priceId, organizationPlans }: { priceId: string; organizationPlans: Record<string, OrganizationPlanRecord> }) {
const organizationPlan = Object.values(organizationPlans).find(plan => plan.priceId === priceId);
const organizationPlan = Object.values(organizationPlans).find(plan => plan.monthlyPriceId === priceId || plan.annualPriceId === priceId);
if (!organizationPlan) {
throw createPlanNotFoundError();

View File

@@ -1,9 +1,8 @@
export type OrganizationPlanRecord = {
id: string;
name: string;
priceId?: string;
defaultSeatsCount?: number;
isPerSeat: boolean;
monthlyPriceId?: string;
annualPriceId?: string;
limits: {
maxDocumentStorageBytes: number;
maxFileSize: number;

View File

@@ -19,7 +19,7 @@ describe('plans usecases', () => {
organizationId: 'organization-1',
planId: PLUS_PLAN_ID,
customerId: 'cus_123',
seatsCount: 1,
seatsCount: 10,
status: 'active',
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
@@ -29,7 +29,8 @@ describe('plans usecases', () => {
const config = overrideConfig({
organizationPlans: {
plusPlanPriceId: 'price_123',
plusPlanAnnualPriceId: 'price_123',
plusPlanMonthlyPriceId: 'price_456',
},
});
@@ -50,7 +51,8 @@ describe('plans usecases', () => {
const config = overrideConfig({
organizationPlans: {
plusPlanPriceId: 'price_123',
plusPlanAnnualPriceId: 'price_123',
plusPlanMonthlyPriceId: 'price_456',
},
});

View File

@@ -26,3 +26,15 @@ export function getContentLengthHeader({ headers }: { headers: Record<string, st
return Number(contentLengthHeaderValue);
}
export function getIpFromHeaders({ context, headerNames }: { context: Context; headerNames: string[] }): string | undefined {
for (const headerName of headerNames) {
const headerValue = getHeader({ context, name: headerName });
if (!isNil(headerValue)) {
return headerValue;
}
}
return undefined;
}

View File

@@ -1,18 +1,21 @@
import type { Context } from '../../app/server.types';
import type { Config } from '../../config/config.types';
import { createMiddleware } from 'hono/factory';
import { getHeader } from '../headers/headers.models';
import { routePath } from 'hono/route';
import { getHeader, getIpFromHeaders } from '../headers/headers.models';
import { generateId } from '../random/ids';
import { createLogger, wrapWithLoggerContext } from './logger';
const logger = createLogger({ namespace: 'app' });
export function createLoggerMiddleware() {
export function createLoggerMiddleware({ config }: { config: Config }) {
return createMiddleware(async (context: Context, next) => {
const requestId = getHeader({ context, name: 'x-request-id' });
const requestId = getHeader({ context, name: 'x-request-id' }) ?? generateId({ prefix: 'req' });
const ip = getIpFromHeaders({ context, headerNames: config.auth.ipAddressHeaders });
await wrapWithLoggerContext(
{
requestId: requestId ?? generateId({ prefix: 'req' }),
requestId,
},
async () => {
const requestedAt = new Date();
@@ -26,9 +29,10 @@ export function createLoggerMiddleware() {
status: context.res.status,
method: context.req.method,
path: context.req.path,
routePath: context.req.routePath,
routePath: routePath(context),
userAgent: getHeader({ context, name: 'User-Agent' }),
durationMs,
ip,
},
'Request completed',
);

View File

@@ -61,6 +61,7 @@ export async function getFileStreamFromMultipartForm({
files: 1, // Only allow one file
fileSize: maxFileSize,
},
defParamCharset: 'utf8',
})
.on('file', (formFieldname, fileStream, info) => {
if (formFieldname !== fieldName) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { isDefined, isNil, isNonEmptyString, isString, omitUndefined } from './utils';
import { isDefined, isNil, isNonEmptyString, isString, nullifyPositiveInfinity, omitUndefined } from './utils';
describe('utils', () => {
describe('omitUndefined', () => {
@@ -81,4 +81,14 @@ describe('utils', () => {
expect(isNonEmptyString([])).toBe(false);
});
});
describe('nullifyPositiveInfinity', () => {
test('returns null if the value is positive infinity', () => {
expect(nullifyPositiveInfinity(Number.POSITIVE_INFINITY)).to.eql(null);
expect(nullifyPositiveInfinity(42)).to.eql(42);
expect(nullifyPositiveInfinity(0)).to.eql(0);
expect(nullifyPositiveInfinity(-42)).to.eql(-42);
expect(nullifyPositiveInfinity(Number.NEGATIVE_INFINITY)).to.eql(Number.NEGATIVE_INFINITY);
});
});
});

View File

@@ -23,3 +23,7 @@ export function isString(value: unknown): value is string {
export function isNonEmptyString(value: unknown): value is string {
return isString(value) && value.length > 0;
}
export function nullifyPositiveInfinity(value: number): number | null {
return value === Number.POSITIVE_INFINITY ? null : value;
}

View File

@@ -3,17 +3,19 @@ import { get, pick } from 'lodash-es';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createDocumentsRepository } from '../documents/documents.repository';
import { createIntakeEmailsRepository } from '../intake-emails/intake-emails.repository';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId } from '../organizations/organizations.usecases';
import { FREE_PLAN_ID, PLUS_PLAN_ID } from '../plans/plans.constants';
import { getPriceIdForBillingInterval } from '../plans/plans.models';
import { createPlansRepository } from '../plans/plans.repository';
import { getOrganizationPlan } from '../plans/plans.usecases';
import { createError } from '../shared/errors/errors';
import { getHeader } from '../shared/headers/headers.models';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { nullifyPositiveInfinity } from '../shared/utils';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createInvalidWebhookPayloadError, createOrganizationAlreadyHasSubscriptionError } from './subscriptions.errors';
import { isSignatureHeaderFormatValid } from './subscriptions.models';
@@ -26,7 +28,8 @@ export function registerSubscriptionsRoutes(context: RouteDefinitionContext) {
setupStripeWebhookRoute(context);
setupCreateCheckoutSessionRoute(context);
setupGetCustomerPortalRoute(context);
getOrganizationSubscriptionRoute(context);
setupGetOrganizationSubscriptionRoute(context);
setupGetOrganizationSubscriptionUsageRoute(context);
}
function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) {
@@ -67,6 +70,7 @@ function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsService
requireAuthentication(),
validateJsonBody(z.object({
planId: z.enum([PLUS_PLAN_ID]),
billingInterval: z.enum(['monthly', 'annual']).default('monthly'),
})),
validateParams(z.object({
organizationId: organizationIdSchema,
@@ -78,7 +82,7 @@ function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsService
const plansRepository = createPlansRepository ({ config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const { planId } = context.req.valid('json');
const { planId, billingInterval } = context.req.valid('json');
const { organizationId } = context.req.valid('param');
await ensureUserIsOwnerOfOrganization({
@@ -101,24 +105,17 @@ function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsService
const { organizationPlan: organizationPlanToSubscribeTo } = await plansRepository.getOrganizationPlanById({ planId });
if (isNil(organizationPlanToSubscribeTo.priceId)) {
// Very unlikely to happen, as only the free plan does not have a price ID, and we check for the plans in the route validation
// but for type safety, we assert that the price ID is set
throw createError({
message: 'Organization plan price ID is not set',
code: 'plans.organization_plan_price_id_not_set',
statusCode: 500,
isInternal: true,
});
}
const { priceId } = getPriceIdForBillingInterval({
plan: organizationPlanToSubscribeTo,
billingInterval,
});
const { customerId } = await getOrCreateOrganizationCustomerId({ organizationId, subscriptionsServices, organizationsRepository });
const { membersCount } = organizationPlanToSubscribeTo.isPerSeat ? await organizationsRepository.getOrganizationMembersCount({ organizationId }) : ({ membersCount: 1 });
const { checkoutUrl } = await subscriptionsServices.createCheckoutUrl({
customerId,
priceId: organizationPlanToSubscribeTo.priceId,
seatsCount: membersCount,
priceId,
organizationId,
});
return context.json({ checkoutUrl });
@@ -154,7 +151,7 @@ function setupGetCustomerPortalRoute({ app, db, subscriptionsServices }: RouteDe
);
}
function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) {
function setupGetOrganizationSubscriptionRoute({ app, db, config }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/subscription',
requireAuthentication(),
@@ -167,6 +164,7 @@ function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) {
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const plansRepository = createPlansRepository({ config });
await ensureUserIsInOrganization({
userId,
@@ -178,6 +176,8 @@ function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) {
organizationId,
});
const { organizationPlan } = await plansRepository.getOrganizationPlanById({ planId: subscription?.planId ?? FREE_PLAN_ID });
return context.json({
subscription: pick(subscription, [
'status',
@@ -187,6 +187,68 @@ function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) {
'planId',
'seatsCount',
]),
plan: organizationPlan,
});
},
);
}
function setupGetOrganizationSubscriptionUsageRoute({ app, db, config }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/usage',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
async (context) => {
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const documentsRepository = createDocumentsRepository({ db });
const plansRepository = createPlansRepository({ config });
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
await ensureUserIsInOrganization({
userId,
organizationId,
organizationsRepository,
});
const [
{ organizationPlan },
{ documentsSize },
{ intakeEmailCount },
{ membersCount },
] = await Promise.all([
getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository }),
documentsRepository.getOrganizationStats({ organizationId }),
intakeEmailsRepository.getOrganizationIntakeEmailsCount({ organizationId }),
organizationsRepository.getOrganizationMembersCount({ organizationId }),
]);
const nullifiedLimits = {
maxDocumentsSize: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
maxIntakeEmailsCount: nullifyPositiveInfinity(organizationPlan.limits.maxIntakeEmailsCount),
maxOrganizationsMembersCount: nullifyPositiveInfinity(organizationPlan.limits.maxOrganizationsMembersCount),
};
return context.json({
usage: {
documentsStorage: {
used: documentsSize,
limit: nullifiedLimits.maxDocumentsSize,
},
intakeEmailsCount: {
used: intakeEmailCount,
limit: nullifiedLimits.maxIntakeEmailsCount,
},
membersCount: {
used: membersCount,
limit: nullifiedLimits.maxOrganizationsMembersCount,
},
},
limits: nullifiedLimits,
});
},
);

View File

@@ -39,13 +39,13 @@ export async function createCheckoutUrl({
stripeClient,
customerId,
priceId,
seatsCount,
organizationId,
config,
}: {
stripeClient: Stripe;
customerId: string;
priceId: string;
seatsCount: number;
organizationId: string;
config: Config;
}) {
const { clientBaseUrl } = getClientBaseUrl({ config });
@@ -59,13 +59,21 @@ export async function createCheckoutUrl({
line_items: [
{
price: priceId,
quantity: seatsCount,
quantity: 1,
adjustable_quantity: {
enabled: false,
},
},
],
mode: 'subscription',
allow_promotion_codes: true,
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
organizationId,
},
},
});
return { checkoutUrl: session.url };

View File

@@ -1,6 +1,6 @@
import { createPrefixedIdRegex } from '../shared/random/ids';
export const TagColorRegex = /^#[0-9a-f]{6}$/;
export const TagColorRegex = /^#[0-9A-F]{6}$/;
export const tagIdPrefix = 'tag';
export const tagIdRegex = createPrefixedIdRegex({ prefix: tagIdPrefix });

View File

@@ -14,10 +14,9 @@ import { ensureUserIsInOrganization } from '../organizations/organizations.useca
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
import { TagColorRegex } from './tags.constants';
import { createTagNotFoundError } from './tags.errors';
import { createTagsRepository } from './tags.repository';
import { tagIdSchema } from './tags.schemas';
import { tagColorSchema, tagIdSchema } from './tags.schemas';
export function registerTagsRoutes(context: RouteDefinitionContext) {
setupCreateNewTagRoute(context);
@@ -38,7 +37,7 @@ function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) {
validateJsonBody(z.object({
name: z.string().min(1).max(50),
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000'),
color: tagColorSchema,
description: z.string().max(256).optional(),
})),
@@ -95,7 +94,7 @@ function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) {
validateJsonBody(z.object({
name: z.string().min(1).max(64).optional(),
color: z.string().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000').optional(),
color: tagColorSchema.optional(),
description: z.string().max(256).optional(),
})),

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { tagColorSchema } from './tags.schemas';
describe('tags schemas', () => {
describe('tagColorSchema', () => {
test('the color of a tag is a 6 digits hex color code', () => {
expect(() => tagColorSchema.parse('#FFFFFF')).not.toThrow();
expect(() => tagColorSchema.parse('#000000')).not.toThrow();
expect(() => tagColorSchema.parse('#123ABC')).not.toThrow();
expect(() => tagColorSchema.parse('#abcdef')).not.toThrow();
expect(() => tagColorSchema.parse('FFFFFF')).toThrow();
expect(() => tagColorSchema.parse('#FFF')).toThrow();
expect(() => tagColorSchema.parse('#123ABCG')).toThrow();
expect(() => tagColorSchema.parse('#123AB')).toThrow();
expect(() => tagColorSchema.parse('blue')).toThrow();
});
test('the color of a tag is always uppercased', () => {
expect(tagColorSchema.parse('#abcdef')).toBe('#ABCDEF');
expect(tagColorSchema.parse('#abCdEf')).toBe('#ABCDEF');
expect(tagColorSchema.parse('#123abc')).toBe('#123ABC');
});
});
});

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import { tagIdRegex } from './tags.constants';
import { TagColorRegex, tagIdRegex } from './tags.constants';
export const tagIdSchema = z.string().regex(tagIdRegex);
export const tagColorSchema = z.string().toUpperCase().regex(TagColorRegex, 'Invalid Color format, must be a hex color code like #000000');

View File

@@ -11,10 +11,6 @@ FROM base AS build
WORKDIR /app
# --- add build deps for sharp/node-gyp, needed to be explicitly installed for armv7 ---
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
ENV npm_config_python=/usr/bin/python3
COPY pnpm-lock.yaml ./
COPY pnpm-workspace.yaml ./
COPY apps/papra-client/package.json apps/papra-client/package.json

View File

@@ -13,10 +13,6 @@ FROM base AS build
WORKDIR /app
# --- add build deps for sharp/node-gyp, needed to be explicitly installed for armv7 ---
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
ENV npm_config_python=/usr/bin/python3
COPY pnpm-lock.yaml ./
COPY pnpm-workspace.yaml ./
COPY apps/papra-client/package.json apps/papra-client/package.json

View File

@@ -1,5 +1,6 @@
{
"name": "@papra/root",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra document management monorepo root",
@@ -8,7 +9,6 @@
"keywords": [],
"scripts": {
"docker:build:root": "docker build -t papra -f docker/Dockerfile .",
"docker:build:root:armv7": "docker buildx build --platform linux/arm/v7 -t papra -f docker/Dockerfile --load .",
"docker:build:root:amd64": "docker buildx build --platform linux/amd64 -t papra -f docker/Dockerfile --load .",
"docker:build:root:arm64": "docker buildx build --platform linux/arm64 -t papra -f docker/Dockerfile --load .",
"docker:build:rootless": "docker build -t papra-rootless -f docker/Dockerfile.rootless .",

4
packages/changelog/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.log

View File

@@ -0,0 +1,342 @@
# @papra/changelog
Internal tooling for managing changelog entries and versioning using calendar-based versioning (calver).
## Overview
This package provides CLI tools and utilities to manage changelog entries for Papra's continuous deployment workflow. It replaces the traditional changesets workflow with a calver-based approach that better suits a web application with continuous deployment.
## Versioning Format
Papra uses **calendar versioning** with the format `YY.MM.N`:
- `YY` - Last two digits of the year (e.g., `25` for 2025)
- `MM` - Zero-padded month (e.g., `04` for April)
- `N` - Sequential release number for that month, starting at `1`
**Examples:**
- `25.04.1` - First release in April 2025
- `25.04.2` - Second release in April 2025
- `25.05.1` - First release in May 2025
This format:
- Looks like semantic versioning (two dots)
- Clearly indicates when the release was made
- Supports multiple releases per month
- Resets the sequence counter each month
- Is sortable and human-readable
## Workflow
### During Development (PR Phase)
When working on a feature or fix that should appear in the changelog:
1. Run `pnpm changelog add` to create a new changelog entry
2. Answer the interactive prompts about your change
3. Entry is saved to `apps/docs/src/content/changelog/.pending/`
4. Commit the pending entry with your PR
**Multiple entries per PR:** A single PR can contain multiple changelog entries if it impacts multiple user-facing features or fixes.
### During Release (CD Pipeline)
When code is merged to `main` and passes CI:
1. CD pipeline runs `pnpm changelog release`
2. The tool determines the next version number (`YY.MM.N`)
3. All pending entries are moved to a versioned folder
4. Git tag is created with the version
5. Docker images are built and published with the version tag
## CLI Commands
### `pnpm changelog add`
Interactively create a new changelog entry.
**Prompts:**
- **Type** - `feature` | `fix` | `improvement` | `breaking`
- **Title** - Short, user-facing description (e.g., "Add calendar versioning support")
- **Description** - Detailed markdown explanation of the change
- **Breaking change?** - Whether this change breaks existing functionality
- **PR number** (optional) - Associated pull request number
**Output:**
Creates a markdown file in `apps/docs/src/content/changelog/.pending/` with a unique filename (e.g., `537-add-calver.md`).
**Example entry:**
```markdown
---
type: feature
title: Add calendar versioning support
breaking: false
pr: 537
---
Papra now uses calendar versioning (YY.MM.N) instead of semantic versioning. This provides better clarity about when releases were made and supports continuous deployment.
Each version indicates the year, month, and sequential release number for that month.
```
### `pnpm changelog release`
Process all pending changelog entries and determine the next version number.
**What it does:**
1. Reads all files from `.pending/` folder
2. Looks at existing git tags to find the latest version for current month
3. Determines next version (`YY.MM.N` where N increments)
4. Creates a folder for the new version: `apps/docs/src/content/changelog/{version}/`
5. Moves all pending entries to the versioned folder
6. Adds version and date to each entry's frontmatter
7. Outputs the new version number for use by CD pipeline
**Example:**
```bash
$ pnpm changelog release
📦 Found 3 pending changelog entries
🔍 Latest version for 2025-04: 25.04.1
🚀 Next version: 25.04.2
✅ Moved 3 entries to changelog/25.04.2/
📌 Version: 25.04.2
```
**Usage in CD:**
```bash
VERSION=$(pnpm changelog release --output version)
git tag $VERSION
docker build -t papra:$VERSION .
```
### `pnpm changelog validate`
Validates all pending changelog entries for correct schema and formatting.
Useful in CI to ensure PR contributors have properly formatted their changelog entries.
## File Structure
```
apps/docs/src/content/changelog/
├── .pending/ # Pending entries (pre-release)
│ ├── 537-add-calver.md
│ ├── 538-fix-docker-build.md
│ └── 539-improve-ui.md
├── 25.04.2/ # Released version
│ ├── add-calver.md
│ ├── fix-docker-build.md
│ └── improve-ui.md
├── 25.04.1/
│ ├── usage-page.md
│ └── org-invites.md
└── 25.03.1/
└── subscription-management.md
```
## Changelog Entry Schema
Each changelog entry (both pending and released) follows this schema:
```typescript
{
type: 'feature' | 'fix' | 'improvement' | 'breaking'
title: string // Short, user-facing title
breaking: boolean // Is this a breaking change?
pr?: number // Associated PR number
version?: string // Added during release (e.g., "25.04.2")
date?: string // Added during release (ISO format)
}
```
**Frontmatter example:**
```yaml
---
type: feature
title: Add calendar versioning support
breaking: false
pr: 537
version: 25.04.2
date: 2025-04-07
---
```
## Integration with Astro
The changelog entries are consumed by the Astro documentation site:
```typescript
// apps/docs/src/content/config.ts
import { defineCollection, z } from 'astro:content';
const changelog = defineCollection({
schema: z.object({
type: z.enum(['feature', 'fix', 'improvement', 'breaking']),
title: z.string(),
breaking: z.boolean(),
pr: z.number().optional(),
version: z.string().optional(),
date: z.string().optional(),
}),
});
export const collections = { changelog };
```
The changelog page can then query and display entries:
```astro
---
import { getCollection } from 'astro:content';
const entries = await getCollection('changelog');
const byVersion = entries.reduce((acc, entry) => {
const version = entry.data.version || 'pending';
if (!acc[version]) acc[version] = [];
acc[version].push(entry);
return acc;
}, {});
---
<div class="changelog">
{Object.entries(byVersion).map(([version, entries]) => (
<section class="version">
<h2>{version}</h2>
{entries.map(entry => (
<article class={entry.data.breaking ? 'breaking' : ''}>
<h3>{entry.data.title}</h3>
<div>{entry.body}</div>
</article>
))}
</section>
))}
</div>
```
## CD Pipeline Integration
Example GitHub Actions workflow:
```yaml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm build:packages
- name: Run tests
run: pnpm test
- name: Process changelog and release
run: |
VERSION=$(pnpm changelog release --output version)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Create git tag
run: |
git tag ${{ env.VERSION }}
git push origin ${{ env.VERSION }}
- name: Build and push Docker image
run: |
docker build -t papra/papra:${{ env.VERSION }} .
docker tag papra/papra:${{ env.VERSION }} papra/papra:latest
docker push papra/papra:${{ env.VERSION }}
docker push papra/papra:latest
```
## Development
### Setup
```bash
cd packages/changelog
pnpm install
pnpm build
```
### Testing
```bash
pnpm test
pnpm test:watch
```
### Local development
```bash
pnpm dev # Watch mode
```
## Migration from Changesets
When migrating from changesets to this system:
1. Process any pending changesets: `pnpm changeset version`
2. Create a final semver release and tag
3. Remove `.changeset/` folder and configuration
4. Install this package
5. Update CI/CD pipeline to use `pnpm changelog release`
6. Create a migration guide changelog entry explaining the new versioning scheme to users
## Breaking Changes and Self-Hosters
Since Papra supports self-hosting and users may have custom integrations:
- **Always mark breaking changes** with `breaking: true`
- **Provide migration guides** in the changelog entry body
- **Consider creating a dedicated migration guide** for complex breaking changes
- **Link to migration documentation** from the changelog entry
**Example breaking change entry:**
```markdown
---
type: breaking
title: Change API authentication to use Bearer tokens
breaking: true
pr: 540
---
⚠️ **Breaking Change**
API authentication has been updated to use Bearer tokens instead of API key headers.
**Migration steps:**
1. Update your API client to send `Authorization: Bearer YOUR_API_KEY` header
2. Remove the legacy `X-API-Key` header from your requests
3. Test your integration before deploying
See the [API Authentication Migration Guide](/docs/migrations/bearer-tokens) for detailed examples.
```
## Why Not Semver?
Semantic versioning (major.minor.patch) is designed for libraries and packages where:
- Consumers need to know about breaking changes (major)
- New features are additive (minor)
- Bug fixes are backwards compatible (patch)
For Papra:
- The SaaS version is always on the latest release (users can't choose versions)
- Self-hosters typically want the latest stable version
- Calendar versioning makes it clear when the release was made
- Breaking changes are communicated through changelog entries, not version numbers
- A "0.x" version doesn't make sense for a stable, production-ready application
Calendar versioning provides better clarity and aligns with continuous deployment practices.

View File

@@ -0,0 +1,25 @@
import antfu from '@antfu/eslint-config';
export default antfu({
stylistic: {
semi: true,
},
ignores: ['README.md'],
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
// To allow console for CLI logs
'no-console': 'off',
'curly': ['error', 'all'],
'vitest/consistent-test-it': ['error', { fn: 'test' }],
'ts/consistent-type-definitions': ['error', 'type'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'unused-imports/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
});

View File

@@ -0,0 +1,36 @@
{
"name": "@papra/changelog",
"type": "module",
"version": "1.0.0",
"packageManager": "pnpm@10.12.3",
"description": "Changelog management and versioning tooling for Papra",
"author": "",
"license": "MIT",
"keywords": [],
"scripts": {
"changelog:add": "tsx src/scripts/add.ts",
"changelog:release": "tsx src/scripts/release.ts",
"changelog:next-version": "tsx src/scripts/next-version.ts",
"changelog:validate": "tsx src/scripts/validate.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint --fix ."
},
"dependencies": {
"@clack/prompts": "^0.10.1",
"@corentinth/friendly-ids": "^0.0.1",
"gray-matter": "^4.0.3",
"picocolors": "^1.1.1",
"zod": "^3.25.67"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@types/node": "catalog:",
"eslint": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,46 @@
import type { ChangelogEntry } from '../types';
import path from 'node:path';
import process from 'node:process';
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { generateFilename, PENDING_DIR, writeEntry } from '../utils';
export async function addCommand(): Promise<void> {
p.intro(pc.bold('Changelog Entry'));
const entry = await p.group(
{
type: () => p.select<ChangelogEntry['type']>({
message: 'What type of change is this?',
options: [
{ value: 'feature', label: 'Feature - New functionality' },
{ value: 'improvement', label: 'Improvement - Enhancement to existing feature' },
{ value: 'fix', label: 'Fix - Bug fix' },
{ value: 'technical', label: 'Technical - Non-user facing change' },
],
}),
content: () => p.text({
message: 'Enter a detailed description (markdown supported):',
placeholder: 'Papra now uses calver for versioning',
}),
isBreaking: () => p.confirm({
message: 'Is this a breaking change?',
initialValue: false,
}),
},
{
onCancel: () => {
p.cancel('Changelog entry creation aborted.');
process.exit(0);
},
},
);
// Generate filename and write
const filename = generateFilename();
const filePath = path.join(PENDING_DIR, filename);
writeEntry({ filePath, entry });
p.outro(pc.green(`Changelog entry created: ${pc.dim(filename)}`));
}

View File

@@ -0,0 +1,9 @@
import process from 'node:process';
import { getNextVersion } from '../utils';
export async function nextVersionCommand(): Promise<void> {
const nextVersion = getNextVersion();
console.log(nextVersion);
process.exit(0);
}

View File

@@ -0,0 +1,41 @@
import process from 'node:process';
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { getPendingEntries, movePendingEntriesToVersion, setCurrentVersion } from '../utils';
export async function releaseCommand({ version }: { version?: string }): Promise<void> {
if (!version) {
p.outro(pc.red('Version is required. Use --version or -v to specify the next version.'));
process.exit(1);
}
// ensure version match YY.MM.N
if (!/^\d{2}\.\d{1,2}\.\d+$/.test(version)) {
p.outro(pc.red('Invalid version format. Use YY.MM.N format, e.g., 24.6.0'));
process.exit(1);
}
const pending = getPendingEntries();
if (pending.length === 0) {
p.outro(pc.yellow('No pending changelog entries found'));
process.exit(0);
}
const date = new Date().toISOString().split('T')[0]!;
p.intro(pc.bold('Release Changelog'));
console.log(pc.dim(`Found ${pending.length} pending changelog ${pending.length === 1 ? 'entry' : 'entries'}`));
console.log(pc.dim(`Next version: ${pc.bold(version)}`));
console.log();
const spinner = p.spinner();
spinner.start('Moving entries to releases folder');
movePendingEntriesToVersion(version, date);
setCurrentVersion(version);
spinner.stop(pc.green(`Moved ${pending.length} ${pending.length === 1 ? 'entry' : 'entries'} to .changelog/releases/${version}/`));
p.outro(pc.green(`Released version: ${pc.bold(version)}`));
}

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
import process from 'node:process';
import { addCommand } from '../commands/add';
addCommand().catch((error) => {
console.error('Failed to add changelog entry:', error);
process.exit(1);
});

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
import process from 'node:process';
import { nextVersionCommand } from '../commands/next-version';
nextVersionCommand().catch((error) => {
console.error('Failed to get next version:', error);
process.exit(1);
});

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env node
import process from 'node:process';
import { releaseCommand } from '../commands/release';
import { getNextVersion } from '../utils';
releaseCommand({ version: getNextVersion() }).catch((error) => {
console.error('Failed to release changelog:', error);
process.exit(1);
});

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env node
import process from 'node:process';
import { parseArgs } from 'node:util';
import { releaseCommand } from '../commands/release';
const { values: { version } } = parseArgs({
options: {
version: {
type: 'string',
short: 'v',
},
},
});
releaseCommand({ version }).catch((error) => {
console.error('Failed to release changelog:', error);
process.exit(1);
});

View File

@@ -0,0 +1,12 @@
export type PendingChangelogEntry = {
type: string;
content: string;
isBreaking: boolean;
};
export type ChangelogEntry = PendingChangelogEntry & {
version: string;
createdAt: string;
pr?: number;
author?: string;
};

View File

@@ -0,0 +1,234 @@
import type { ChangelogEntry, PendingChangelogEntry } from './types';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { adjectives, animals, createIdGenerator } from '@corentinth/friendly-ids';
import matter from 'gray-matter';
const generateId = createIdGenerator({
separator: '-',
chunks: [
({ getRandomItem }) => getRandomItem(adjectives),
({ getRandomItem }) => getRandomItem(adjectives),
({ getRandomItem }) => getRandomItem(animals),
],
});
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '../../..');
export const CHANGELOG_DIR = path.join(REPO_ROOT, '.changelog');
export const PENDING_DIR = path.join(CHANGELOG_DIR, 'pending');
export const RELEASES_DIR = path.join(CHANGELOG_DIR, 'releases');
export const VERSION_FILE = path.join(CHANGELOG_DIR, 'version');
/**
* Ensure the pending directory exists
*/
export function ensureChangelogPendingDirectoryExists(): void {
if (!fs.existsSync(PENDING_DIR)) {
fs.mkdirSync(PENDING_DIR, { recursive: true });
}
}
/**
* Generate a unique filename for a changelog entry
*/
export function generateFilename(): string {
const slug = generateId();
return `${slug}.md`;
}
/**
* Read a changelog entry from a file
*/
export function readPendingEntry(filePath: string): PendingChangelogEntry {
const { data, content } = matter(fs.readFileSync(filePath, 'utf-8'));
return {
...data as Omit<PendingChangelogEntry, 'content'>,
content,
};
}
/**
* Write a changelog entry to a file
*/
export function writeEntry({ filePath, entry: { content, ...rest } }: { filePath: string; entry: ChangelogEntry | PendingChangelogEntry }): void {
ensureChangelogPendingDirectoryExists();
const frontmatter = matter.stringify(content, rest);
fs.writeFileSync(filePath, frontmatter, 'utf-8');
}
/**
* Get all pending changelog entries
*/
export function getPendingEntries(): { path: string; entry: PendingChangelogEntry }[] {
if (!fs.existsSync(PENDING_DIR)) {
return [];
}
const files = fs.readdirSync(PENDING_DIR)
.filter(file => file.endsWith('.md'))
.map(file => path.join(PENDING_DIR, file));
return files.map(filePath => ({
path: filePath,
entry: readPendingEntry(filePath),
}));
}
/**
* Get the commit hash that last modified a file
*/
export function getFileCommitHash(filePath: string): string | null {
try {
const hash = execSync(`git log -1 --format=%H -- "${filePath}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
return hash || null;
}
catch {
return null;
}
}
/**
* Get PR number and author from a commit hash using gh CLI
*/
export function getPRInfoFromCommit(commitHash: string): { pr: number; author: string } | null {
try {
const result = execSync(`gh pr list --search "${commitHash}" --json number,author --state merged --limit 1`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
});
const prs = JSON.parse(result) as Array<{ number: number; author: { login: string } }>;
if (prs.length === 0) {
return null;
}
const pr = prs[0];
return {
pr: pr!.number,
author: pr!.author.login,
};
}
catch {
return null;
}
}
/**
* Move pending entries to a versioned folder
*/
export function movePendingEntriesToVersion(version: string, date: string): void {
const pending = getPendingEntries();
if (pending.length === 0) {
return;
}
const versionDir = path.join(RELEASES_DIR, version);
if (!fs.existsSync(versionDir)) {
fs.mkdirSync(versionDir, { recursive: true });
}
for (const { path: filePath, entry } of pending) {
const filename = path.basename(filePath);
const newPath = path.join(versionDir, filename);
// Get commit hash and PR info
const commitHash = getFileCommitHash(filePath);
const prInfo = commitHash ? getPRInfoFromCommit(commitHash) : null;
// Update entry with version, date, and PR info
const updatedEntry: ChangelogEntry = {
...entry,
version,
createdAt: date,
...(prInfo && {
pr: prInfo.pr,
author: prInfo.author,
}),
};
writeEntry({ filePath: newPath, entry: updatedEntry });
// Remove from pending
fs.unlinkSync(filePath);
}
}
/**
* Read the current version from the version file
*/
export function getCurrentVersion(): string | null {
try {
if (!fs.existsSync(VERSION_FILE)) {
return null;
}
return fs.readFileSync(VERSION_FILE, 'utf-8').trim();
} catch {
return null;
}
}
/**
* Write the version to the version file
*/
export function setCurrentVersion(version: string): void {
if (!fs.existsSync(CHANGELOG_DIR)) {
fs.mkdirSync(CHANGELOG_DIR, { recursive: true });
}
fs.writeFileSync(VERSION_FILE, version, 'utf-8');
}
/**
* Parse a version string (with or without 'v' prefix) into components
*/
export function parseVersion(version: string): { year: number; month: number; release: number } | null {
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return null;
}
return {
year: Number(match[1]!),
month: Number(match[2]!),
release: Number(match[3]!),
};
}
/**
* Calculate the next version based on CalVer (YY.M.N) or SemVer
*/
export function getNextVersion({ now = new Date() }: { now?: Date } = {}): string {
const currentYearMod100 = now.getFullYear() % 100;
const currentMonth = now.getMonth() + 1;
const currentVersion = getCurrentVersion();
if (!currentVersion) {
// No version found, start with CalVer format based on current date
return `${currentYearMod100}.${currentMonth}.0`;
}
const parsed = parseVersion(currentVersion);
if (!parsed) {
// Invalid version format, start fresh
return `${currentYearMod100}.${currentMonth}.0`;
}
const { year, month, release } = parsed;
if (year === currentYearMod100 && month === currentMonth) {
// Same month and year, increment release number
return `${year}.${month}.${release + 1}`;
} else {
// New month or year, reset release number
return `${currentYearMod100}.${currentMonth}.0`;
}
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"moduleDetection": "force",
"module": "preserve",
"resolveJsonModule": true,
"types": ["node"],
"allowJs": true,
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
env: {
TZ: 'UTC',
},
},
});

219
pnpm-lock.yaml generated
View File

@@ -24,6 +24,9 @@ catalogs:
tsdown:
specifier: ^0.13.4
version: 0.13.4
tsx:
specifier: ^4.17.0
version: 4.20.3
typescript:
specifier: ^5.6.2
version: 5.8.3
@@ -457,6 +460,43 @@ importers:
specifier: 'catalog:'
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jsdom@26.0.0)
packages/changelog:
dependencies:
'@clack/prompts':
specifier: ^0.10.1
version: 0.10.1
'@corentinth/friendly-ids':
specifier: ^0.0.1
version: 0.0.1
gray-matter:
specifier: ^4.0.3
version: 4.0.3
picocolors:
specifier: ^1.1.1
version: 1.1.1
zod:
specifier: ^3.25.67
version: 3.25.67
devDependencies:
'@antfu/eslint-config':
specifier: 'catalog:'
version: 4.16.2(@vue/compiler-sfc@3.5.13)(astro-eslint-parser@1.1.0(typescript@5.8.3))(eslint-plugin-astro@1.3.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.16.0)(jsdom@26.0.0))
'@types/node':
specifier: 'catalog:'
version: 22.16.0
eslint:
specifier: 'catalog:'
version: 9.30.1(jiti@2.4.2)
tsx:
specifier: 'catalog:'
version: 4.20.3
typescript:
specifier: 'catalog:'
version: 5.8.3
vitest:
specifier: 'catalog:'
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.0)(jsdom@26.0.0)
packages/cli:
dependencies:
'@clack/prompts':
@@ -2462,9 +2502,6 @@ packages:
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -5347,6 +5384,10 @@ packages:
exsolve@1.0.5:
resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==}
extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -5619,6 +5660,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
@@ -5871,6 +5916,10 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -6059,6 +6108,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -6923,9 +6976,6 @@ packages:
quansync@0.2.10:
resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==}
quansync@0.2.8:
resolution: {integrity: sha512-4+saucphJMazjt7iOM27mbFCk+D9dd/zmgMDCzRZ8MEoBfYp7lAvoN38et/phRQF6wOPMy/OROBGgoWeSKyluA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7194,6 +7244,10 @@ packages:
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
engines: {node: ^14.0.0 || >=16.0.0}
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -7450,6 +7504,10 @@ packages:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
strip-bom-string@1.0.0:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -8783,7 +8841,7 @@ snapshots:
'@astrojs/telemetry@3.3.0':
dependencies:
ci-info: 4.2.0
debug: 4.4.1
debug: 4.4.3
dlv: 1.1.3
dset: 3.1.4
is-docker: 3.0.0
@@ -9379,7 +9437,7 @@ snapshots:
'@babel/traverse': 7.26.4
'@babel/types': 7.28.0
convert-source-map: 2.0.0
debug: 4.4.1
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -9475,7 +9533,7 @@ snapshots:
'@babel/parser': 7.28.0
'@babel/template': 7.25.9
'@babel/types': 7.28.0
debug: 4.4.1
debug: 4.4.3
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -9849,7 +9907,7 @@ snapshots:
'@esbuild-kit/esm-loader@2.6.5':
dependencies:
'@esbuild-kit/core-utils': 3.3.2
get-tsconfig: 4.10.0
get-tsconfig: 4.10.1
'@esbuild/aix-ppc64@0.19.12':
optional: true
@@ -10323,7 +10381,7 @@ snapshots:
'@eslint/config-array@0.20.0':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.1
debug: 4.4.3
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -10331,7 +10389,7 @@ snapshots:
'@eslint/config-array@0.21.0':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.1
debug: 4.4.3
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -10355,7 +10413,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.1
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -10491,7 +10549,7 @@ snapshots:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.1
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.1
@@ -10595,13 +10653,13 @@ snapshots:
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
@@ -10610,19 +10668,17 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
@@ -11925,7 +11981,7 @@ snapshots:
'@typescript-eslint/types': 8.32.1
'@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.32.1
debug: 4.4.1
debug: 4.4.3
eslint: 9.27.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
@@ -11937,7 +11993,7 @@ snapshots:
'@typescript-eslint/types': 8.35.1
'@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.35.1
debug: 4.4.1
debug: 4.4.3
eslint: 9.30.1(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
@@ -11947,7 +12003,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3)
'@typescript-eslint/types': 8.35.1
debug: 4.4.1
debug: 4.4.3
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -11980,7 +12036,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3)
'@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.3
eslint: 9.27.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
@@ -11991,7 +12047,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3)
'@typescript-eslint/utils': 8.35.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.3
eslint: 9.30.1(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
@@ -12012,7 +12068,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.19.1
'@typescript-eslint/visitor-keys': 8.19.1
debug: 4.4.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -12026,7 +12082,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.21.0
'@typescript-eslint/visitor-keys': 8.21.0
debug: 4.4.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -12040,7 +12096,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.32.1
'@typescript-eslint/visitor-keys': 8.32.1
debug: 4.4.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -12056,7 +12112,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.8.3)
'@typescript-eslint/types': 8.35.1
'@typescript-eslint/visitor-keys': 8.35.1
debug: 4.4.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -12178,7 +12234,7 @@ snapshots:
chokidar: 3.6.0
colorette: 2.0.20
consola: 3.4.0
magic-string: 0.30.17
magic-string: 0.30.19
pathe: 1.1.2
perfect-debounce: 1.0.0
tinyglobby: 0.2.14
@@ -12272,7 +12328,7 @@ snapshots:
'@unocss/rule-utils@0.65.0-beta.2':
dependencies:
'@unocss/core': 0.65.0-beta.2
magic-string: 0.30.17
magic-string: 0.30.19
'@unocss/transformer-attributify-jsx@0.65.0-beta.2':
dependencies:
@@ -12300,7 +12356,7 @@ snapshots:
'@unocss/core': 0.65.0-beta.2
'@unocss/inspector': 0.65.0-beta.2(vue@3.5.13(typescript@5.8.3))
chokidar: 3.6.0
magic-string: 0.30.17
magic-string: 0.30.19
tinyglobby: 0.2.14
vite: 5.4.19(@types/node@22.16.0)
transitivePeerDependencies:
@@ -12316,7 +12372,7 @@ snapshots:
'@unocss/core': 0.65.0-beta.2
'@unocss/inspector': 0.65.0-beta.2(vue@3.5.13(typescript@5.8.3))
chokidar: 3.6.0
magic-string: 0.30.17
magic-string: 0.30.19
tinyglobby: 0.2.14
vite: 6.3.4(@types/node@24.0.10)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0)
transitivePeerDependencies:
@@ -12393,7 +12449,7 @@ snapshots:
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
magic-string: 0.30.19
optionalDependencies:
vite: 5.4.19(@types/node@22.16.0)
@@ -12401,7 +12457,7 @@ snapshots:
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
magic-string: 0.30.19
optionalDependencies:
vite: 5.4.19(@types/node@24.0.10)
@@ -12418,7 +12474,7 @@ snapshots:
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.17
magic-string: 0.30.19
pathe: 2.0.3
'@vitest/spy@3.2.4':
@@ -12631,7 +12687,7 @@ snapshots:
'@typescript-eslint/types': 8.35.1
'@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3)
astrojs-compiler-sync: 1.0.1(@astrojs/compiler@2.11.0)
debug: 4.4.1
debug: 4.4.3
entities: 4.5.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -13255,7 +13311,6 @@ snapshots:
debug@4.4.3:
dependencies:
ms: 2.1.3
optional: true
decimal.js@10.5.0: {}
@@ -13339,7 +13394,7 @@ snapshots:
docker-modem@5.0.6:
dependencies:
debug: 4.4.1
debug: 4.4.3
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.16.0
@@ -13494,7 +13549,7 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.19.12):
dependencies:
debug: 4.4.1
debug: 4.4.3
esbuild: 0.19.12
transitivePeerDependencies:
- supports-color
@@ -13816,12 +13871,12 @@ snapshots:
'@types/doctrine': 0.0.9
'@typescript-eslint/scope-manager': 8.19.1
'@typescript-eslint/utils': 8.19.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.3
doctrine: 3.0.0
enhanced-resolve: 5.18.0
eslint: 9.27.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
get-tsconfig: 4.10.0
get-tsconfig: 4.10.1
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.2
@@ -13836,7 +13891,7 @@ snapshots:
'@es-joy/jsdoccomment': 0.50.1
are-docs-informative: 0.0.2
comment-parser: 1.4.1
debug: 4.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 9.27.0(jiti@2.4.2)
espree: 10.4.0
@@ -13852,7 +13907,7 @@ snapshots:
'@es-joy/jsdoccomment': 0.52.0
are-docs-informative: 0.0.2
comment-parser: 1.4.1
debug: 4.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 9.30.1(jiti@2.4.2)
espree: 10.4.0
@@ -13897,7 +13952,7 @@ snapshots:
enhanced-resolve: 5.18.0
eslint: 9.27.0(jiti@2.4.2)
eslint-plugin-es-x: 7.8.0(eslint@9.27.0(jiti@2.4.2))
get-tsconfig: 4.10.0
get-tsconfig: 4.10.1
globals: 15.14.0
ignore: 5.3.2
minimatch: 9.0.5
@@ -13974,7 +14029,7 @@ snapshots:
eslint-plugin-toml@0.12.0(eslint@9.27.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
eslint: 9.27.0(jiti@2.4.2)
eslint-compat-utils: 0.6.4(eslint@9.27.0(jiti@2.4.2))
lodash: 4.17.21
@@ -13984,7 +14039,7 @@ snapshots:
eslint-plugin-toml@0.12.0(eslint@9.30.1(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
eslint: 9.30.1(jiti@2.4.2)
eslint-compat-utils: 0.6.4(eslint@9.30.1(jiti@2.4.2))
lodash: 4.17.21
@@ -14074,7 +14129,7 @@ snapshots:
eslint-plugin-yml@1.16.0(eslint@9.27.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
eslint: 9.27.0(jiti@2.4.2)
eslint-compat-utils: 0.6.4(eslint@9.27.0(jiti@2.4.2))
lodash: 4.17.21
@@ -14085,7 +14140,7 @@ snapshots:
eslint-plugin-yml@1.18.0(eslint@9.30.1(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 9.30.1(jiti@2.4.2)
eslint-compat-utils: 0.6.5(eslint@9.30.1(jiti@2.4.2))
@@ -14185,7 +14240,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -14295,6 +14350,10 @@ snapshots:
exsolve@1.0.5: {}
extend-shallow@2.0.1:
dependencies:
is-extendable: 0.1.1
extend@3.0.2: {}
extendable-error@0.1.7: {}
@@ -14481,7 +14540,7 @@ snapshots:
gel@2.0.1:
dependencies:
'@petamoriken/float16': 3.9.1
debug: 4.4.1
debug: 4.4.3
env-paths: 3.0.0
semver: 7.7.2
shell-quote: 1.8.2
@@ -14592,6 +14651,13 @@ snapshots:
graphemer@1.4.0: {}
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.1
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
gzip-size@6.0.0:
dependencies:
duplexer: 0.1.2
@@ -14852,7 +14918,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -14867,7 +14933,7 @@ snapshots:
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -14907,7 +14973,7 @@ snapshots:
importx@0.4.4:
dependencies:
bundle-require: 5.1.0(esbuild@0.23.1)
debug: 4.4.1
debug: 4.4.3
esbuild: 0.23.1
jiti: 2.0.0-beta.3
jiti-v1: jiti@1.21.7
@@ -14969,6 +15035,8 @@ snapshots:
is-docker@3.0.0: {}
is-extendable@0.1.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -15024,7 +15092,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.29
debug: 4.4.1
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -15159,6 +15227,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kind-of@6.0.3: {}
kleur@3.0.3: {}
kleur@4.1.5: {}
@@ -15206,7 +15276,7 @@ snapshots:
dependencies:
mlly: 1.7.4
pkg-types: 2.1.0
quansync: 0.2.8
quansync: 0.2.10
locate-path@5.0.0:
dependencies:
@@ -15796,7 +15866,7 @@ snapshots:
micromark@4.0.1:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.1
debug: 4.4.3
decode-named-character-reference: 1.0.2
devlop: 1.1.0
micromark-core-commonmark: 2.0.2
@@ -16330,8 +16400,6 @@ snapshots:
quansync@0.2.10: {}
quansync@0.2.8: {}
queue-microtask@1.2.3: {}
radix3@1.1.2: {}
@@ -16643,7 +16711,7 @@ snapshots:
'@babel/types': 7.28.2
ast-kit: 2.1.1
birpc: 2.5.0
debug: 4.4.1
debug: 4.4.3
dts-resolver: 2.1.1
get-tsconfig: 4.10.1
rolldown: 1.0.0-beta.31
@@ -16740,6 +16808,11 @@ snapshots:
refa: 0.12.1
regexp-ast-analysis: 0.7.1
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1
kind-of: 6.0.3
selderee@0.11.0:
dependencies:
parseley: 0.12.1
@@ -17063,6 +17136,8 @@ snapshots:
dependencies:
ansi-regex: 6.1.0
strip-bom-string@1.0.0: {}
strip-bom@3.0.0: {}
strip-indent@3.0.0:
@@ -17352,7 +17427,7 @@ snapshots:
tsx@4.20.3:
dependencies:
esbuild: 0.25.2
get-tsconfig: 4.10.0
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
@@ -17620,7 +17695,7 @@ snapshots:
vite-node@3.2.4(@types/node@22.16.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.19(@types/node@22.16.0)
@@ -17638,7 +17713,7 @@ snapshots:
vite-node@3.2.4(@types/node@24.0.10):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.19(@types/node@24.0.10)
@@ -17750,9 +17825,9 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.3
expect-type: 1.2.1
magic-string: 0.30.17
magic-string: 0.30.19
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
@@ -17783,16 +17858,16 @@ snapshots:
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@24.0.10))
'@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@22.16.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.3
expect-type: 1.2.1
magic-string: 0.30.17
magic-string: 0.30.19
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
@@ -17830,9 +17905,9 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.3
expect-type: 1.2.1
magic-string: 0.30.17
magic-string: 0.30.19
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
@@ -17861,7 +17936,7 @@ snapshots:
vue-eslint-parser@10.2.0(eslint@9.30.1(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
eslint: 9.30.1(jiti@2.4.2)
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -17873,7 +17948,7 @@ snapshots:
vue-eslint-parser@9.4.3(eslint@9.27.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.3
eslint: 9.27.0(jiti@2.4.2)
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3