mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-21 12:09:39 -06:00
Compare commits
6 Commits
@papra/app
...
calver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b4129e2c | ||
|
|
9a6e822e71 | ||
|
|
e52bc261db | ||
|
|
624ad62c53 | ||
|
|
630f9cc328 | ||
|
|
9f5be458fe |
7
.changelog/releases/25.10.2/dangerous-spicy-bird.md
Normal file
7
.changelog/releases/25.10.2/dangerous-spicy-bird.md
Normal 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
1
.changelog/version
Normal file
@@ -0,0 +1 @@
|
||||
25.10.2
|
||||
6
.changeset/big-beans-win.md
Normal file
6
.changeset/big-beans-win.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
---
|
||||
|
||||
Drop docker armv7 support
|
||||
6
.changeset/fancy-days-fly.md
Normal file
6
.changeset/fancy-days-fly.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
---
|
||||
|
||||
Added a page to view organization usage
|
||||
81
.github/workflows/changelog-release.yaml
vendored
Normal file
81
.github/workflows/changelog-release.yaml
vendored
Normal 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 }}
|
||||
4
.github/workflows/release-docker.yaml
vendored
4
.github/workflows/release-docker.yaml
vendored
@@ -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
206
CLAUDE.md
Normal 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)
|
||||
@@ -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%;
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Mitglied einladen',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -143,6 +143,16 @@ export const translations = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Invite member',
|
||||
@@ -518,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',
|
||||
@@ -558,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
|
||||
|
||||
@@ -581,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;
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Invitar miembro',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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.',
|
||||
'organizations.members.invite-member': 'Inviter un membre',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Invita membro',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Zaproś członka',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Convidar membro',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Convidar membro',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -145,6 +145,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
'organizations.members.invite-member': 'Invită membru',
|
||||
@@ -520,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',
|
||||
@@ -560,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
|
||||
|
||||
@@ -583,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',
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
2
apps/papra-client/src/modules/plans/plans.constants.ts
Normal file
2
apps/papra-client/src/modules/plans/plans.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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';
|
||||
@@ -254,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 });
|
||||
|
||||
@@ -262,6 +266,8 @@ function setupInviteOrganizationMemberRoute({ app, db, config, emailsServices }:
|
||||
role,
|
||||
organizationId,
|
||||
organizationsRepository,
|
||||
subscriptionsRepository,
|
||||
plansRepository,
|
||||
inviterId: userId,
|
||||
expirationDelayDays: config.organizations.invitationExpirationDelayDays,
|
||||
maxInvitationsPerDay: config.organizations.maxUserInvitationsPerDay,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
export const FAMILY_PLAN_ID = 'family';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
20
apps/papra-server/src/modules/plans/plans.models.ts
Normal file
20
apps/papra-server/src/modules/plans/plans.models.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,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
4
packages/changelog/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
342
packages/changelog/README.md
Normal file
342
packages/changelog/README.md
Normal 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.
|
||||
25
packages/changelog/eslint.config.js
Normal file
25
packages/changelog/eslint.config.js
Normal 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: '^_',
|
||||
}],
|
||||
},
|
||||
});
|
||||
36
packages/changelog/package.json
Normal file
36
packages/changelog/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
46
packages/changelog/src/commands/add.ts
Normal file
46
packages/changelog/src/commands/add.ts
Normal 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)}`));
|
||||
}
|
||||
9
packages/changelog/src/commands/next-version.ts
Normal file
9
packages/changelog/src/commands/next-version.ts
Normal 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);
|
||||
}
|
||||
41
packages/changelog/src/commands/release.ts
Normal file
41
packages/changelog/src/commands/release.ts
Normal 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)}`));
|
||||
}
|
||||
8
packages/changelog/src/scripts/add.ts
Normal file
8
packages/changelog/src/scripts/add.ts
Normal 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);
|
||||
});
|
||||
8
packages/changelog/src/scripts/next-version.ts
Normal file
8
packages/changelog/src/scripts/next-version.ts
Normal 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);
|
||||
});
|
||||
9
packages/changelog/src/scripts/release-next-version.ts
Normal file
9
packages/changelog/src/scripts/release-next-version.ts
Normal 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);
|
||||
});
|
||||
19
packages/changelog/src/scripts/release.ts
Normal file
19
packages/changelog/src/scripts/release.ts
Normal 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);
|
||||
});
|
||||
|
||||
12
packages/changelog/src/types.ts
Normal file
12
packages/changelog/src/types.ts
Normal 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;
|
||||
};
|
||||
234
packages/changelog/src/utils.ts
Normal file
234
packages/changelog/src/utils.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
20
packages/changelog/tsconfig.json
Normal file
20
packages/changelog/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
10
packages/changelog/vitest.config.ts
Normal file
10
packages/changelog/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
219
pnpm-lock.yaml
generated
219
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user