diff --git a/.agents/plans/bright-emerald-flower-bullmq-background-jobs.md b/.agents/plans/bright-emerald-flower-bullmq-background-jobs.md new file mode 100644 index 000000000..04438bfc8 --- /dev/null +++ b/.agents/plans/bright-emerald-flower-bullmq-background-jobs.md @@ -0,0 +1,239 @@ +--- +date: 2026-03-26 +title: Bullmq Background Jobs +--- + +## Context + +The codebase has a well-designed background job provider abstraction (`BaseJobProvider`) with two existing implementations: + +- **InngestJobProvider** — cloud/SaaS provider, externally hosted +- **LocalJobProvider** — database-backed (Postgres via Prisma), uses HTTP self-calls to dispatch + +The goal is to add a third provider backed by a proper job queue library for self-hosted deployments that need more reliability than the Local provider offers. + +### Current Architecture + +All code lives in `packages/lib/jobs/`: + +- `client/base.ts` — Abstract `BaseJobProvider` with 4 methods: `defineJob()`, `triggerJob()`, `getApiHandler()`, `startCron()` +- `client/client.ts` — `JobClient` facade, selects provider via `NEXT_PRIVATE_JOBS_PROVIDER` env var +- `client/inngest.ts` — Inngest implementation +- `client/local.ts` — Local/Postgres implementation +- `client/_internal/job.ts` — Core types: `JobDefinition`, `JobRunIO`, `SimpleTriggerJobOptions` +- `definitions/` — 19 job definitions (15 event-triggered, 4 cron) + +The `JobRunIO` interface provided to handlers includes: + +- `runTask(cacheKey, callback)` — idempotent task execution (cached via `BackgroundJobTask` table) +- `triggerJob(cacheKey, options)` — chain jobs from within handlers +- `wait(cacheKey, ms)` — delay/sleep (not implemented in Local provider) +- `logger` — structured logging + +### Local Provider Limitations + +The current Local provider has several issues that motivate this work: + +1. `io.wait()` throws "Not implemented" +2. HTTP self-call with 150ms fire-and-forget `Promise.race` is fragile +3. No concurrency control — jobs run in the web server process +4. No real retry backoff (immediate re-dispatch) +5. No monitoring/visibility into job status +6. Jobs compete for resources with HTTP request handling + +--- + +## Provider Evaluation + +Three alternatives were evaluated against the existing provider interface and project requirements. + +### BullMQ (Redis-backed) — Recommended + +| Attribute | Detail | +| ------------------- | -------------------------- | +| Backend | Redis 7.x | +| npm downloads/month | ~15M | +| TypeScript | Native | +| Delayed jobs | Yes (ms precision) | +| Cron/repeatable | Yes (`upsertJobScheduler`) | +| Retries + backoff | Yes (exponential, custom) | +| Concurrency control | Yes (per-worker) | +| Rate limiting | Yes (per-queue, per-group) | +| Dashboard | Bull Board (mature) | +| New infrastructure | Yes — Redis required | + +**Why BullMQ**: Most mature and widely-adopted Node.js queue. Native delayed jobs solve the `io.wait()` gap. Redis is purpose-built for queue workloads and keeps Postgres clean for application data. Bull Board gives immediate operational visibility. The provider abstraction already exists so wrapping BullMQ is straightforward. + +**Trade-off**: Requires Redis, which is additional infrastructure. However, Redis is a single Docker Compose service or a free Upstash tier, and the operational benefit is significant. + +### pg-boss (PostgreSQL-backed) — Strong Alternative + +| Attribute | Detail | +| ------------------- | ----------------------------- | +| Backend | PostgreSQL (existing) | +| npm downloads/month | ~1.4M | +| TypeScript | Native | +| Delayed jobs | Yes (`startAfter`) | +| Cron/repeatable | Yes (`schedule()`) | +| New infrastructure | No — reuses existing Postgres | + +**Why it could work**: Zero new infrastructure since the project already uses Postgres. API maps well to existing patterns. + +**Why it's second choice**: Polling-based (no LISTEN/NOTIFY), adds write amplification to the primary database, smaller ecosystem, no dashboard. At scale, queue operations on the primary database become a concern. + +### Graphile Worker (PostgreSQL-backed) — Less Suitable + +Uses LISTEN/NOTIFY for instant pickup but has a file-based task convention and separate schema that don't mesh well with the existing Prisma-centric architecture. Would require more adapter work. + +### Improving the Local Provider — Not Recommended + +Fixing the Local provider's issues (wait support, replacing HTTP self-calls, adding concurrency control, backoff) essentially means rebuilding a queue library from scratch with less robustness and no community maintenance. + +--- + +## Recommendation + +**Proceed with BullMQ.** It's the most capable option, maps cleanly to the existing provider interface, and is the standard choice for production Node.js applications. Redis is lightweight infrastructure with managed options available at every cloud provider. + +**If Redis is a hard blocker**, pg-boss is the clear fallback — but the plan below assumes BullMQ. + +--- + +## Implementation Plan + +### Phase 1: BullMQ Provider Core + +**File: `packages/lib/jobs/client/bullmq.ts`** + +Create `BullMQJobProvider extends BaseJobProvider` with singleton pattern matching the existing providers. + +Key implementation details: + +1. **Constructor / `getInstance()`** + - Initialize a Redis `IORedis` connection using new env var: `NEXT_PRIVATE_REDIS_URL` + - Create a single `Queue` instance for dispatching jobs, using `NEXT_PRIVATE_REDIS_PREFIX` as the BullMQ `prefix` option (defaults to `documenso` if unset). This namespaces all Redis keys so multiple environments (worktrees, branches, developers) sharing the same Redis instance don't collide. + - Create a single `Worker` instance for processing jobs (in-process, same prefix) + - Store job definitions in a `_jobDefinitions` record (same pattern as Local provider) + +2. **`defineJob()`** + - Store definition in `_jobDefinitions` keyed by ID + - If the definition has a `trigger.cron`, register it via `queue.upsertJobScheduler()` with the cron expression + +3. **`triggerJob(options)`** + - Find eligible definitions by `trigger.name` (same lookup as Local provider) + - For each, call `queue.add(jobDefinitionId, payload)` with appropriate options + - Support `options.id` for deduplication via BullMQ's `jobId` option + +4. **`getApiHandler()`** + - Return a minimal health-check / queue-status handler. Unlike the Local provider, BullMQ workers don't need an HTTP endpoint to receive jobs — they pull from Redis directly. The API handler can return queue metrics for monitoring. + +5. **`startCron()`** + - No-op — cron is handled by BullMQ's `upsertJobScheduler` registered during `defineJob()` + +6. **Worker setup** + - Single worker processes all job types by dispatching to the correct handler from `_jobDefinitions` + - Configure concurrency with a default of 10 (overridable via `NEXT_PRIVATE_BULLMQ_CONCURRENCY` env var for those who need to tune it) + - Configure retry with exponential backoff: `backoff: { type: 'exponential', delay: 1000 }` + - Default 3 retries (matching current Local provider behavior) + +7. **`createJobRunIO(jobId)`** — Implement `JobRunIO`: + - `runTask()`: Reuse the existing `BackgroundJobTask` Prisma table for idempotent task tracking (same pattern as Local provider) + - `triggerJob()`: Delegate to `this.triggerJob()` + - `wait()`: Throw "Not implemented" (same as Local provider). No handler uses `io.wait()` so this has zero impact + - `logger`: Same console-based logger pattern as Local provider + +### Phase 2: Provider Registration + +**File: `packages/lib/jobs/client/client.ts`** + +Add `'bullmq'` case to the provider match: + +```typescript +this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER')) + .with('inngest', () => InngestJobProvider.getInstance()) + .with('bullmq', () => BullMQJobProvider.getInstance()) + .otherwise(() => LocalJobProvider.getInstance()); +``` + +**File: `packages/tsconfig/process-env.d.ts`** + +Add `'bullmq'` to the `NEXT_PRIVATE_JOBS_PROVIDER` type union and add Redis env var types: + +```typescript +NEXT_PRIVATE_JOBS_PROVIDER?: 'inngest' | 'local' | 'bullmq'; +NEXT_PRIVATE_REDIS_URL?: string; +NEXT_PRIVATE_REDIS_PREFIX?: string; +NEXT_PRIVATE_BULLMQ_CONCURRENCY?: string; +``` + +**File: `.env.example`** + +Add Redis configuration examples: + +```env +NEXT_PRIVATE_JOBS_PROVIDER="local" # Options: local, inngest, bullmq +NEXT_PRIVATE_REDIS_URL="redis://localhost:63790" +NEXT_PRIVATE_REDIS_PREFIX="documenso" # Namespace for Redis keys (useful when sharing a Redis instance) +``` + +**File: `turbo.json`** + +Add `NEXT_PRIVATE_REDIS_URL`, `NEXT_PRIVATE_REDIS_PREFIX`, and `NEXT_PRIVATE_BULLMQ_CONCURRENCY` to the env vars list for cache invalidation. + +### Phase 3: Infrastructure & Dependencies + +**File: `packages/lib/package.json`** + +Add dependencies: + +- `bullmq` — the queue library +- `ioredis` — Redis client (peer dependency of BullMQ, but explicit is better) + +**File: `docker-compose.yml` (or equivalent)** + +Add Redis service for local development: + +```yaml +redis: + image: redis:7-alpine + ports: + - '6379:6379' +``` + +### Phase 4: Optional Enhancements + +These are not required for the initial implementation but worth considering for follow-up: + +1. **Bull Board integration** — Add a `/api/jobs/dashboard` route that serves Bull Board UI for monitoring. Gate behind an admin auth check. + +2. **Separate worker process** — Add an `apps/worker` entry point that runs BullMQ workers without the web server, for deployments that want to isolate job processing from request handling. + +3. **Graceful shutdown** — Register `SIGTERM`/`SIGINT` handlers to call `worker.close()` and `queue.close()` for clean shutdown. + +4. **BackgroundJob table integration** — Optionally continue writing to the `BackgroundJob` Prisma table for audit/history, using BullMQ events (`completed`, `failed`) to update status. This preserves the existing database-level visibility. + +--- + +## Files to Create/Modify + +| File | Action | Description | +| ------------------------------------ | ---------- | ---------------------------------------- | +| `packages/lib/jobs/client/bullmq.ts` | **Create** | BullMQ provider implementation | +| `packages/lib/jobs/client/client.ts` | Modify | Add `'bullmq'` provider case | +| `packages/tsconfig/process-env.d.ts` | Modify | Add type for `'bullmq'` + Redis env vars | +| `.env.example` | Modify | Add Redis config example | +| `turbo.json` | Modify | Add Redis env var to cache keys | +| `packages/lib/package.json` | Modify | Add `bullmq` + `ioredis` dependencies | +| `docker-compose.yml` | Modify | Add Redis service | + +--- + +## Open Questions + +1. **Should the BullMQ provider also write to the `BackgroundJob` Prisma table?** This would maintain audit history and allow existing admin tooling to query job status. Trade-off is dual-write complexity. + +2. **Redis connection resilience**: Should the provider gracefully degrade if Redis is unavailable (e.g., fall back to Local provider), or fail hard? Failing hard is simpler and more predictable. + +## Resolved Questions + +- **`io.wait()`**: Not a concern. Only Inngest implements it (via `step.sleep`), the Local provider throws "Not implemented", and no job handler calls `io.wait()`. The BullMQ provider can throw "Not implemented" identically to the Local provider. diff --git a/.env.example b/.env.example index 69bd1961d..d9d430824 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,8 @@ NEXT_PRIVATE_OIDC_PROMPT="login" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" # URL used by the web app to request itself (e.g. local background jobs) NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" +# OPTIONAL: Comma-separated hostnames or IPs whose webhooks are allowed to resolve to private/loopback addresses. (e.g., internal.example.com,192.168.1.5). +NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS= # [[SERVER]] # OPTIONAL: The port the server will listen on. Defaults to 3000. @@ -143,8 +145,15 @@ NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= # [[BACKGROUND JOBS]] +# Available options: local (default) | inngest | bullmq NEXT_PRIVATE_JOBS_PROVIDER="local" NEXT_PRIVATE_INNGEST_EVENT_KEY= +# OPTIONAL: Redis URL for the BullMQ jobs provider. +NEXT_PRIVATE_REDIS_URL="redis://localhost:63790" +# OPTIONAL: Key prefix for Redis to namespace queues (useful when sharing a Redis instance). +NEXT_PRIVATE_REDIS_PREFIX="documenso" +# OPTIONAL: Number of concurrent jobs to process. Defaults to 10. +# NEXT_PRIVATE_BULLMQ_CONCURRENCY=10 # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/.github/labeler.yml b/.github/labeler.yml index 2fe8be5e2..2ef2b640a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,5 +1,8 @@ 'apps: web': - - apps/web/** + - apps/remix/** + +'type: documentation': + - apps/docs/** 'version bump 👀': - '**/package.json' diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 395f105ee..be9dbb555 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -233,6 +233,7 @@ Processes async jobs. | Provider | Description | Env Value | | -------- | --------------------- | ----------------- | | Local | Database-backed queue | `local` (default) | +| BullMQ | Redis-backed queue | `bullmq` | | Inngest | Managed cloud service | `inngest` | **Config**: `NEXT_PRIVATE_JOBS_PROVIDER` diff --git a/apps/docs/content/docs/developers/api/developer-mode.mdx b/apps/docs/content/docs/developers/api/developer-mode.mdx index f5bd2e871..92e3dbd48 100644 --- a/apps/docs/content/docs/developers/api/developer-mode.mdx +++ b/apps/docs/content/docs/developers/api/developer-mode.mdx @@ -1,17 +1,22 @@ --- title: Developer Mode -description: Advanced development tools for debugging field coordinates and integrating with the Documenso API. +description: Advanced development tools for debugging field IDs, recipient IDs, coordinates and integrating with the Documenso API. --- ## Overview Developer mode provides additional tools and features to help you integrate and debug Documenso. -## Field Coordinates +## Field Information -Field coordinates represent the position of a field in a document. They are returned in the `pageX`, `pageY`, `width` and `height` properties of the field. +When enabled, developer mode displays the following information for each field: -To enable field coordinates, add the `devmode=true` query parameter to the editor URL. +- **Field ID** - The unique identifier of the field +- **Recipient ID** - The ID of the recipient assigned to the field +- **Pos X / Pos Y** - The position of the field on the page +- **Width / Height** - The dimensions of the field + +To enable developer mode, add the `devmode=true` query parameter to the editor URL. ```bash # Legacy editor diff --git a/apps/docs/content/docs/self-hosting/configuration/background-jobs.mdx b/apps/docs/content/docs/self-hosting/configuration/background-jobs.mdx new file mode 100644 index 000000000..f449b4656 --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/background-jobs.mdx @@ -0,0 +1,187 @@ +--- +title: Background Jobs +description: Configure how Documenso processes background tasks like email delivery, document processing, and webhook dispatch. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Overview + +Documenso processes background jobs for email delivery, document sealing, webhook dispatch, and scheduled maintenance tasks. Three providers are available: + +| Provider | Backend | Best For | Infrastructure | +| -------- | ---------- | ----------------------------------------------- | -------------- | +| Inngest | Managed | Production with zero ops overhead | None | +| BullMQ | Redis | Self-hosted production with full control | Redis | +| Local | PostgreSQL | Development and small self-hosted deployments | None | + +Select a provider with the `NEXT_PRIVATE_JOBS_PROVIDER` environment variable: + +```bash +NEXT_PRIVATE_JOBS_PROVIDER=inngest # or bullmq, local +``` + + + The default provider is `local`. It requires no additional infrastructure and works well for development and small deployments, but is not recommended for production workloads. + + +--- + +## Inngest (Recommended) + +[Inngest](https://www.inngest.com/) is a managed background job service. It handles scheduling, retries, concurrency, and observability without any infrastructure to manage. This is the recommended provider for production deployments. + +### Setup + +{/* prettier-ignore */} +1. Create an account at [inngest.com](https://www.inngest.com/) +2. Create an app and obtain your event key and signing key +3. Configure the environment variables: + +```bash +NEXT_PRIVATE_JOBS_PROVIDER=inngest +NEXT_PRIVATE_INNGEST_EVENT_KEY=your-event-key +INNGEST_SIGNING_KEY=your-signing-key +``` + +### Environment Variables + +| Variable | Description | Required | +| -------------------------------- | -------------------------------------------- | -------- | +| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Inngest event key | Yes | +| `INNGEST_EVENT_KEY` | Alternative Inngest event key | No | +| `INNGEST_SIGNING_KEY` | Inngest signing key for webhook verification | Yes | +| `NEXT_PRIVATE_INNGEST_APP_ID` | Custom Inngest app ID | No | + +### Advantages + +- No infrastructure to manage +- Built-in monitoring dashboard +- Automatic retries with backoff +- Cron scheduling handled externally +- Scales automatically + +--- + +## BullMQ + +[BullMQ](https://docs.bullmq.io/) is a Redis-backed job queue that runs inside the Documenso process. It provides higher throughput than the local provider, configurable concurrency, and a built-in dashboard for monitoring jobs. + +### Requirements + +- **Redis 6.2+** - any Redis-compatible service works (Redis, KeyDB, Dragonfly, AWS ElastiCache, Upstash, etc.) + +### Setup + +```bash +NEXT_PRIVATE_JOBS_PROVIDER=bullmq +NEXT_PRIVATE_REDIS_URL=redis://localhost:6379 +``` + +### Environment Variables + +| Variable | Description | Default | +| ---------------------------------- | -------------------------------------------------------------------------- | ----------- | +| `NEXT_PRIVATE_REDIS_URL` | Redis connection URL | _(required)_ | +| `NEXT_PRIVATE_REDIS_PREFIX` | Key prefix for Redis queues (useful when sharing an instance) | `documenso` | +| `NEXT_PRIVATE_BULLMQ_CONCURRENCY` | Number of concurrent jobs to process | `10` | + +### Dashboard + +BullMQ includes a job monitoring dashboard at `/api/jobs/board`. In production, only admin users can access the dashboard. In development, it is open to all users. + +The dashboard provides visibility into queued, active, completed, and failed jobs. + +### Docker Compose with Redis + +If you're using Docker Compose, add a Redis service: + +```yaml +services: + redis: + image: redis:8-alpine + ports: + - '6379:6379' + volumes: + - redis_data:/data + +volumes: + redis_data: +``` + +Then set `NEXT_PRIVATE_REDIS_URL=redis://redis:6379` in your Documenso environment. + +### Advantages + +- Self-hosted with no external service dependencies beyond Redis +- Configurable concurrency +- Built-in job monitoring dashboard +- Reliable retries with exponential backoff +- Queue namespacing for shared Redis instances + +--- + +## Local + +The local provider uses your PostgreSQL database as a job queue. Jobs are stored in the `BackgroundJob` table and processed via internal HTTP requests that Documenso sends to itself. + +### Setup + +No configuration required. The local provider is the default when `NEXT_PRIVATE_JOBS_PROVIDER` is unset or set to `local`. + +```bash +# Optional - this is the default +NEXT_PRIVATE_JOBS_PROVIDER=local +``` + +### Internal URL + +Background jobs in the local provider work by Documenso sending HTTP requests to itself. If your reverse proxy or network setup causes issues with the app reaching its own public URL, set the internal URL: + +```bash +NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000 +``` + +This tells the job system to use the internal address instead of `NEXT_PUBLIC_WEBAPP_URL` for self-requests. + + + The local provider is suitable for development and small deployments. For production workloads, use Inngest or BullMQ. + + +### Limitations + +- No concurrency control - jobs are processed one at a time per request cycle +- No built-in monitoring +- Depends on the application being able to reach itself over HTTP +- Not suitable for high-throughput workloads + +--- + +## Choosing a Provider + + + + +Use **Inngest**. Zero infrastructure, automatic scaling, and built-in observability. The simplest path to reliable background jobs in production. + + + + +Use **BullMQ**. Add a Redis instance to your infrastructure and get reliable job processing with a monitoring dashboard. Good fit if you already run Redis or want to keep everything self-hosted. + + + + +Use **Local** (the default). No additional setup required. Works out of the box with just PostgreSQL. + + + + +--- + +## See Also + +- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference +- [Requirements](/docs/self-hosting/getting-started/requirements) - Infrastructure requirements +- [Docker Compose](/docs/self-hosting/deployment/docker-compose) - Deploy with Docker Compose diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 712551c14..29508a92e 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -268,20 +268,40 @@ AI features must also be enabled in organisation/team settings after configurati ## Background Jobs -Documenso uses a PostgreSQL-based job queue by default. Jobs (email delivery, document processing, webhook dispatch) are stored in the `BackgroundJob` table and processed via internal HTTP requests. No external queue service like Redis is required. +Documenso supports multiple background job providers for processing emails, documents, webhooks, and scheduled tasks. -| Variable | Description | Default | -| ---------------------------- | ------------------------------------------------------------------------------ | ------- | -| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (PostgreSQL-based queue) or `inngest` (managed service) | `local` | +### Provider Selection -### Inngest Configuration +| Variable | Description | Default | +| ---------------------------- | -------------------------------------------------------------------------------------- | ------- | +| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (PostgreSQL), `bullmq` (Redis), or `inngest` (managed service) | `local` | -| Variable | Description | -| -------------------------------- | -------------------------------------------- | -| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Inngest event key | -| `INNGEST_EVENT_KEY` | Alternative Inngest event key | -| `INNGEST_SIGNING_KEY` | Inngest signing key for webhook verification | -| `NEXT_PRIVATE_INNGEST_APP_ID` | Custom Inngest app ID | +### Local (local) + +No additional configuration required. Jobs are stored in PostgreSQL and processed via internal HTTP requests. + +| Variable | Description | Default | +| ---------------------------------- | ------------------------------------------------------------ | -------------------------------- | +| `NEXT_PRIVATE_INTERNAL_WEBAPP_URL` | Internal URL for the app to send job requests to itself | Same as `NEXT_PUBLIC_WEBAPP_URL` | + +### BullMQ (bullmq) + +| Variable | Required | Description | Default | +| ---------------------------------- | -------- | ------------------------------------------------------------- | ----------- | +| `NEXT_PRIVATE_REDIS_URL` | Yes | Redis connection URL (e.g., `redis://localhost:6379`) | | +| `NEXT_PRIVATE_REDIS_PREFIX` | No | Key prefix for Redis queues (useful when sharing an instance) | `documenso` | +| `NEXT_PRIVATE_BULLMQ_CONCURRENCY` | No | Number of concurrent jobs to process | `10` | + +### Inngest (inngest) + +| Variable | Required | Description | +| -------------------------------- | -------- | -------------------------------------------- | +| `NEXT_PRIVATE_INNGEST_EVENT_KEY` | Yes | Inngest event key | +| `INNGEST_EVENT_KEY` | No | Alternative Inngest event key | +| `INNGEST_SIGNING_KEY` | Yes | Inngest signing key for webhook verification | +| `NEXT_PRIVATE_INNGEST_APP_ID` | No | Custom Inngest app ID | + +For setup guides and provider recommendations, see [Background Jobs](/docs/self-hosting/configuration/background-jobs). --- diff --git a/apps/docs/content/docs/self-hosting/configuration/meta.json b/apps/docs/content/docs/self-hosting/configuration/meta.json index 88d922748..32b92f853 100644 --- a/apps/docs/content/docs/self-hosting/configuration/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/meta.json @@ -5,6 +5,7 @@ "database", "email", "storage", + "background-jobs", "signing-certificate", "telemetry", "advanced" diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index 202f739b3..e4ca2a71f 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -85,9 +85,13 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for setup ### Background Jobs -Documenso processes background jobs (email delivery, document processing) using a PostgreSQL-based queue. No additional services like Redis are required: the job queue is built into the application and uses your existing database. +Documenso processes background jobs (email delivery, document processing) using a PostgreSQL-based queue by default. No additional services are required: the job queue is built into the application and uses your existing database. -For high-throughput deployments, Documenso optionally supports [Inngest](https://www.inngest.com/) as an alternative job provider. Set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and configure `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`. Most self-hosted instances do not need this. +For production deployments that need higher throughput or more reliable job processing, Documenso supports [BullMQ](https://docs.bullmq.io/) as an alternative provider. BullMQ requires a **Redis** instance (v6.2+). Set `NEXT_PRIVATE_JOBS_PROVIDER=bullmq` and configure `NEXT_PRIVATE_REDIS_URL`. + +For managed/cloud deployments, [Inngest](https://www.inngest.com/) is also supported as a job provider. Set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and configure `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`. + +See [Background Jobs Configuration](/docs/self-hosting/configuration/background-jobs) for full details. --- diff --git a/apps/docs/content/docs/self-hosting/getting-started/tips.mdx b/apps/docs/content/docs/self-hosting/getting-started/tips.mdx index dc4f7e074..4673ae6aa 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/tips.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/tips.mdx @@ -144,19 +144,13 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for full s --- -## Background Jobs Don't Need Redis +## Background Jobs -Documenso uses a PostgreSQL-based job queue by default. No Redis, no external message broker. The job system uses your existing database to store and process background tasks like email delivery and document processing. +Documenso uses a PostgreSQL-based job queue by default (`local` provider). No Redis or external message broker is required for basic deployments. -For high-throughput deployments, Documenso optionally supports [Inngest](https://www.inngest.com/) as an alternative job provider: +For production workloads, consider switching to **Inngest** (managed) or **BullMQ** (self-hosted with Redis) for better reliability and throughput. -```bash -NEXT_PRIVATE_JOBS_PROVIDER=inngest -INNGEST_EVENT_KEY=your-event-key -INNGEST_SIGNING_KEY=your-signing-key -``` - -Most self-hosted instances do not need Inngest. +See [Background Jobs Configuration](/docs/self-hosting/configuration/background-jobs) for setup instructions and provider comparison. --- diff --git a/apps/docs/content/docs/users/documents/advanced/index.mdx b/apps/docs/content/docs/users/documents/advanced/index.mdx index 3f960cf99..6b4572242 100644 --- a/apps/docs/content/docs/users/documents/advanced/index.mdx +++ b/apps/docs/content/docs/users/documents/advanced/index.mdx @@ -24,4 +24,9 @@ description: Advanced document features including PDF placeholders, AI detection description="Control who can see documents within a team." href="/docs/users/documents/advanced/document-visibility" /> + diff --git a/apps/docs/content/docs/users/documents/advanced/meta.json b/apps/docs/content/docs/users/documents/advanced/meta.json index 20a5c44a8..88dda8485 100644 --- a/apps/docs/content/docs/users/documents/advanced/meta.json +++ b/apps/docs/content/docs/users/documents/advanced/meta.json @@ -5,6 +5,7 @@ "pdf-placeholders", "ai-detection", "default-recipients", - "document-visibility" + "document-visibility", + "recipient-expiration" ] } diff --git a/apps/docs/content/docs/users/documents/advanced/recipient-expiration.mdx b/apps/docs/content/docs/users/documents/advanced/recipient-expiration.mdx new file mode 100644 index 000000000..755c67ade --- /dev/null +++ b/apps/docs/content/docs/users/documents/advanced/recipient-expiration.mdx @@ -0,0 +1,183 @@ +--- +title: Recipient Expiration +description: Set a signing deadline for recipients so document links expire after a configurable period. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Overview + +Recipient expiration lets you set a deadline for how long recipients have to sign a document after it is sent. Once the deadline passes, the recipient can no longer access the signing link and the document owner is notified. + +This is useful when: + +- A business deal is contingent on being signed within a specific time frame +- A document is no longer relevant after a certain date +- You want to ensure recipients act promptly rather than leaving documents unsigned indefinitely + +Expiration is tracked **per recipient**, not per document. If one recipient's deadline passes, other recipients can still sign. The document stays in a pending state so the owner can decide whether to resend or cancel. + +## Default Behaviour + +Every organisation has a default expiration period of **3 months**. This means that when you send a document, each recipient has 3 months from the time the document is sent to complete their signing. + +You can change this default at the organisation or team level, or override it per document. + +## Settings Cascade + +Expiration settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it. + + + + +Sets the default for all teams in the organisation. Options are a **custom duration** or **never expires**. + +To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Envelope Expiration**. + + + + +Overrides the organisation default for documents created within this team. Options are a **custom duration**, **never expires**, or **inherit from organisation**. + +New teams default to **inherit from organisation**. + +To configure, navigate to **Team Settings > Preferences > Document** and find **Default Envelope Expiration**. + + + + +Overrides the team or organisation default for a single document. Options are a **custom duration** or **never expires**. + +If you do not change the expiration when editing a document, the team or organisation default applies. + + + + +## Set Expiration for a Document + +{/* prettier-ignore */} + + + +### Open the document settings + +In the document editor, open the **Settings** dialog and go to the **General** tab. + + + + +### Configure the expiration + +Find the **Expiration** field. Choose one of: + +- **Custom duration** — enter a number and select a unit (days, weeks, months, or years) +- **Never expires** — the recipient can sign at any time + +If you leave it unchanged, the team or organisation default applies. + +![Recipient Expiration Screenshot](/recipient-expiration/configure-expiration.webp) + + + + +### Send the document + +When you send the document, the expiration deadline is calculated from that moment. For example, if you set a 7-day expiration and send the document on March 1st, the recipient has until March 8th to sign. + + + + + + You cannot change the expiration period after the document has been sent. To extend a recipient's + deadline, resend the document to them — this resets the clock. + + +## Set a Default Expiration Period + +{/* prettier-ignore */} + + + +### Navigate to document preferences + +Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides). + + + + +### Configure the default + +Find **Default Envelope Expiration** and choose: + +- **Custom duration** — enter a number and unit +- **Never expires** — no deadline for recipients +- **Inherit from organisation** (team level only) — use whatever the organisation has configured + + + + +### Save + +Click **Save** to apply. New documents created after this change use the updated default. + + + + + + Changing the default expiration does not affect documents that have already been sent. Only new + documents use the updated setting. + + +## What Happens When a Recipient Expires + +When a recipient's signing deadline passes: + +1. The recipient can no longer access the signing link. They see a message explaining that the signing deadline has expired and to contact the document owner. +2. The document owner receives an email notification with a link to view the document. +3. An audit log entry is created recording the expiration. +4. The document remains in a **pending** state — other recipients who have not expired can still sign. + +![Recipient Expired Signing Page](/recipient-expiration/recipient-expired.webp) + +## Resending to Extend a Deadline + +If a recipient's deadline has passed (or is about to), you can resend the document to them. Resending recalculates the expiration from the current time, effectively extending the deadline. + +{/* prettier-ignore */} + + + +### Open the document + +Navigate to the document page and find the recipient whose deadline has expired. Expired recipients are marked with an **Expired** badge. + + + + +### Resend + +Click the resend option for the recipient. This sends a new signing link and resets the expiration clock based on the document's configured expiration period. + + + + +## Expiration Options Reference + +| Unit | Example | Description | +| ------ | --------------- | ------------------------------------------- | +| Days | 7 days | Recipient has 7 days from when the document is sent | +| Weeks | 2 weeks | Recipient has 2 weeks from when the document is sent | +| Months | 3 months | Recipient has 3 months from when the document is sent (default) | +| Years | 1 year | Recipient has 1 year from when the document is sent | + +You can also set expiration to **never expires**, which means the signing link remains valid indefinitely. + +--- + +## See Also + +- [Send Documents](/docs/users/documents/send) - Send documents for signing +- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings +- [Add Recipients](/docs/users/documents/add-recipients) - Add signers and other recipients to a document diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 1ed043d2a..4d6bab54b 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -32,6 +32,7 @@ To access the preferences, navigate to either the organisation or teams settings | **Include the Signing Certificate** | Whether the signing certificate is embedded in signed PDFs. The certificate is always available separately from the logs page. | | **Include the Audit Logs** | Whether the audit logs are embedded in the document when downloaded. The audit logs are always available separately from the logs page. | | **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. | +| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). | | **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. | | **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. | diff --git a/apps/docs/public/developer-mode/field-coordinates-legacy-editor.webp b/apps/docs/public/developer-mode/field-coordinates-legacy-editor.webp index 7cf99a3b9..7d03c1112 100644 Binary files a/apps/docs/public/developer-mode/field-coordinates-legacy-editor.webp and b/apps/docs/public/developer-mode/field-coordinates-legacy-editor.webp differ diff --git a/apps/docs/public/developer-mode/field-coordinates-new-editor.webp b/apps/docs/public/developer-mode/field-coordinates-new-editor.webp index ea6980da2..f37756b1e 100644 Binary files a/apps/docs/public/developer-mode/field-coordinates-new-editor.webp and b/apps/docs/public/developer-mode/field-coordinates-new-editor.webp differ diff --git a/apps/docs/public/recipient-expiration/configure-expiration.webp b/apps/docs/public/recipient-expiration/configure-expiration.webp new file mode 100644 index 000000000..ca9ed7d51 Binary files /dev/null and b/apps/docs/public/recipient-expiration/configure-expiration.webp differ diff --git a/apps/docs/public/recipient-expiration/recipient-expired.webp b/apps/docs/public/recipient-expiration/recipient-expired.webp new file mode 100644 index 000000000..d10ee85cf Binary files /dev/null and b/apps/docs/public/recipient-expiration/recipient-expired.webp differ diff --git a/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx b/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx index f148633e9..b6c38e4d0 100644 --- a/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx +++ b/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -5,6 +5,7 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { zEmail } from '@documenso/lib/utils/zod'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -43,7 +44,7 @@ type ConfirmationDialogProps = { const ZNextSignerFormSchema = z.object({ name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email address'), + email: zEmail('Invalid email address'), }); type TNextSignerFormSchema = z.infer; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx deleted file mode 100644 index a802387ef..000000000 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { DocumentStatus } from '@prisma/client'; -import { P, match } from 'ts-pattern'; - -import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { trpc as trpcReact } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type DocumentDeleteDialogProps = { - id: number; - open: boolean; - onOpenChange: (_open: boolean) => void; - onDelete?: () => Promise | void; - status: DocumentStatus; - documentTitle: string; - canManageDocument: boolean; -}; - -export const DocumentDeleteDialog = ({ - id, - open, - onOpenChange, - onDelete, - status, - documentTitle, - canManageDocument, -}: DocumentDeleteDialogProps) => { - const { toast } = useToast(); - const { refreshLimits } = useLimits(); - const { _ } = useLingui(); - - const deleteMessage = msg`delete`; - - const [inputValue, setInputValue] = useState(''); - const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); - - const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({ - onSuccess: async () => { - void refreshLimits(); - - toast({ - title: _(msg`Document deleted`), - description: _(msg`"${documentTitle}" has been successfully deleted`), - duration: 5000, - }); - - await onDelete?.(); - - onOpenChange(false); - }, - onError: () => { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`This document could not be deleted at this time. Please try again.`), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - useEffect(() => { - if (open) { - setInputValue(''); - setIsDeleteEnabled(status === DocumentStatus.DRAFT); - } - }, [open, status]); - - const onInputChange = (event: React.ChangeEvent) => { - setInputValue(event.target.value); - setIsDeleteEnabled(event.target.value === _(deleteMessage)); - }; - - return ( - !isPending && onOpenChange(value)}> - - - - Are you sure? - - - - {canManageDocument ? ( - - You are about to delete "{documentTitle}" - - ) : ( - - You are about to hide "{documentTitle}" - - )} - - - - {canManageDocument ? ( - - {match(status) - .with(DocumentStatus.DRAFT, () => ( - - - Please note that this action is irreversible. Once confirmed, - this document will be permanently deleted. - - - )) - .with(DocumentStatus.PENDING, () => ( - -

- - Please note that this action is irreversible. - -

- -

- Once confirmed, the following will occur: -

- -
    -
  • - Document will be permanently deleted -
  • -
  • - Document signing process will be cancelled -
  • -
  • - All inserted signatures will be voided -
  • -
  • - All recipients will be notified -
  • -
-
- )) - .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => ( - -

- By deleting this document, the following will occur: -

- -
    -
  • - The document will be hidden from your account -
  • -
  • - Recipients will still retain their copy of the document -
  • -
-
- )) - .exhaustive()} -
- ) : ( - - - Please contact support if you would like to revert this action. - - - )} - - {status !== DocumentStatus.DRAFT && canManageDocument && ( - - )} - - - - - - -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx deleted file mode 100644 index 9b7c82404..000000000 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { useNavigate } from 'react-router'; - -import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { trpc as trpcReact } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCurrentTeam } from '~/providers/team'; - -type DocumentDuplicateDialogProps = { - id: string; - token?: string; - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const DocumentDuplicateDialog = ({ - id, - token, - open, - onOpenChange, -}: DocumentDuplicateDialogProps) => { - const navigate = useNavigate(); - - const { toast } = useToast(); - const { _ } = useLingui(); - - const team = useCurrentTeam(); - - const documentsPath = formatDocumentsPath(team.url); - - const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = - trpcReact.envelope.duplicate.useMutation({ - onSuccess: async ({ id }) => { - toast({ - title: _(msg`Document Duplicated`), - description: _(msg`Your document has been successfully duplicated.`), - duration: 5000, - }); - - await navigate(`${documentsPath}/${id}/edit`); - onOpenChange(false); - }, - }); - - const onDuplicate = async () => { - try { - await duplicateEnvelope({ envelopeId: id }); - } catch { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`This document could not be duplicated at this time. Please try again.`), - variant: 'destructive', - duration: 7500, - }); - } - }; - - return ( - !isDuplicating && onOpenChange(value)}> - - - - Duplicate - - - - -
- - - -
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx index 2975643f6..3a8cf7dea 100644 --- a/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx @@ -52,13 +52,23 @@ export const EnvelopeDeleteDialog = ({ const [inputValue, setInputValue] = useState(''); const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); + const isDocument = type === EnvelopeType.DOCUMENT; + const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({ onSuccess: async () => { void refreshLimits(); toast({ - title: t`Document deleted`, - description: t`"${title}" has been successfully deleted`, + title: canManageDocument + ? isDocument + ? t`Document deleted` + : t`Template deleted` + : isDocument + ? t`Document hidden` + : t`Template hidden`, + description: canManageDocument + ? t`"${title}" has been successfully deleted` + : t`"${title}" has been successfully hidden`, duration: 5000, }); @@ -69,7 +79,9 @@ export const EnvelopeDeleteDialog = ({ onError: () => { toast({ title: t`Something went wrong`, - description: t`This document could not be deleted at this time. Please try again.`, + description: isDocument + ? t`This document could not be deleted at this time. Please try again.` + : t`This template could not be deleted at this time. Please try again.`, variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index 93e59ec67..cad5d7f3c 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -16,6 +16,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; +import { zEmail } from '@documenso/lib/utils/zod'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { cn } from '@documenso/ui/lib/utils'; @@ -62,10 +63,7 @@ export type EnvelopeDistributeDialogProps = { export const ZEnvelopeDistributeFormSchema = z.object({ meta: z.object({ emailId: z.string().nullable(), - emailReplyTo: z.preprocess( - (val) => (val === '' ? undefined : val), - z.string().email().optional(), - ), + emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()), subject: z.string(), message: z.string(), distributionMethod: z diff --git a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx index b327a1549..88274c0fa 100644 --- a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; -import { useLingui } from '@lingui/react/macro'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { useNavigate } from 'react-router'; @@ -10,6 +9,7 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -41,19 +41,20 @@ export const EnvelopeDuplicateDialog = ({ const team = useCurrentTeam(); + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({ onSuccess: async ({ id }) => { toast({ - title: t`Envelope Duplicated`, - description: t`Your envelope has been successfully duplicated.`, + title: isDocument ? t`Document Duplicated` : t`Template Duplicated`, + description: isDocument + ? t`Your document has been successfully duplicated.` + : t`Your template has been successfully duplicated.`, duration: 5000, }); - const path = - envelopeType === EnvelopeType.DOCUMENT - ? formatDocumentsPath(team.url) - : formatTemplatesPath(team.url); + const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url); await navigate(`${path}/${id}/edit`); setOpen(false); @@ -66,7 +67,9 @@ export const EnvelopeDuplicateDialog = ({ } catch { toast({ title: t`Something went wrong`, - description: t`This document could not be duplicated at this time. Please try again.`, + description: isDocument + ? t`This document could not be duplicated at this time. Please try again.` + : t`This template could not be duplicated at this time. Please try again.`, variant: 'destructive', duration: 7500, }); @@ -78,30 +81,25 @@ export const EnvelopeDuplicateDialog = ({ {trigger && {trigger}} - {envelopeType === EnvelopeType.DOCUMENT ? ( - - - Duplicate Document - - + + + {isDocument ? Duplicate Document : Duplicate Template} + + + {isDocument ? ( This document will be duplicated. - - - ) : ( - - - Duplicate Template - - + ) : ( This template will be duplicated. - - - )} + )} + + - + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx new file mode 100644 index 000000000..a23549f16 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +import { formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +type EnvelopeSaveAsTemplateDialogProps = { + envelopeId: string; + trigger?: React.ReactNode; +}; + +export const EnvelopeSaveAsTemplateDialog = ({ + envelopeId, + trigger, +}: EnvelopeSaveAsTemplateDialogProps) => { + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + const { t } = useLingui(); + + const team = useCurrentTeam(); + + const templatesPath = formatTemplatesPath(team.url); + + const form = useForm({ + defaultValues: { + includeRecipients: true, + includeFields: true, + }, + }); + + const includeRecipients = form.watch('includeRecipients'); + + const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({ + onSuccess: async ({ id }) => { + toast({ + title: t`Template Created`, + description: t`Your document has been saved as a template.`, + duration: 5000, + }); + + await navigate(`${templatesPath}/${id}/edit`); + setOpen(false); + }, + }); + + const onSubmit = async () => { + const { includeRecipients, includeFields } = form.getValues(); + + try { + await saveAsTemplate({ + envelopeId, + includeRecipients, + includeFields: includeRecipients && includeFields, + }); + } catch { + toast({ + title: t`Something went wrong`, + description: t`This document could not be saved as a template at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + { + if (isPending) { + return; + } + + setOpen(value); + + if (!value) { + form.reset(); + } + }} + > + {trigger && {trigger}} + + + + + Save as Template + + + Create a template from this document. + + + +
+ ( +
+ { + field.onChange(checked === true); + + if (!checked) { + form.setValue('includeFields', false); + } + }} + /> + +
+ )} + /> + + ( +
+ field.onChange(checked === true)} + /> + +
+ )} + /> +
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx index acc52ca08..d2f5eb42e 100644 --- a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -17,6 +17,7 @@ import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app' import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types'; import { cn } from '@documenso/ui/lib/utils'; @@ -94,7 +95,7 @@ type TabTypes = 'INDIVIDUAL' | 'BULK'; const ZImportOrganisationMemberSchema = z.array( z.object({ - email: z.string().email(), + email: zEmail(), organisationRole: z.nativeEnum(OrganisationMemberRole), }), ); @@ -329,12 +330,12 @@ export const OrganisationMemberInviteDialog = ({ onValueChange={(value) => setInvitationType(value as TabTypes)} > - + Invite Members - + Bulk Import @@ -382,7 +383,7 @@ export const OrganisationMemberInviteDialog = ({ )}