--- date: 2026-02-10 title: Envelope Expiration --- ## Summary Envelopes (documents sent for signing) should automatically expire after a configurable period, preventing recipients from completing stale documents. Expiration is tracked **per-recipient** — when a recipient's signing window lapses, the document owner is notified and can resend (extending the deadline) or cancel. The document itself stays PENDING so other recipients can continue signing. **Settings cascade**: Organisation → Team → Document (each level can override the prior). **Default**: 1 month from when the envelope is sent (transitions to PENDING). --- ## 1. Database Schema Changes ### 1.1 Expiration period data shape Store expiration as a structured JSON object rather than an enum or raw milliseconds. This avoids the enum treadmill (adding `FOUR_MONTHS` later requires a migration) while keeping values validated and meaningful. **Zod schema** (defined in `packages/lib/constants/envelope-expiration.ts`): ```typescript export const ZEnvelopeExpirationPeriod = z.union([ z.object({ unit: z.enum(['day', 'week', 'month', 'year']), amount: z.number().int().min(1) }), z.object({ disabled: z.literal(true) }), ]); export type TEnvelopeExpirationPeriod = z.infer; ``` Semantics: - `null` on `DocumentMeta` / `TeamGlobalSettings` = inherit from parent - `{ disabled: true }` = explicitly never expires - `{ unit: 'month', amount: 1 }` = expires in 1 month No Prisma enum is needed — the period is stored as `Json?` on the relevant models (see sections 1.3 and 1.4). ### 1.2 Add expiration fields to `Recipient` ```prisma model Recipient { // ... existing fields expiresAt DateTime? expirationNotifiedAt DateTime? // null = not yet notified; set when owner notification sent @@index([expiresAt]) } ``` `expiresAt` is a computed timestamp set when the envelope transitions to PENDING (at send time). It is calculated from the effective expiration period. Storing the concrete timestamp rather than a relative duration means: - Sweep queries are simple (`WHERE expiresAt <= NOW() AND expirationNotifiedAt IS NULL`) - No need to re-resolve the settings cascade at query time - The sender can see the exact deadline in the UI - The index on `expiresAt` ensures the expiration sweep query is efficient `expirationNotifiedAt` tracks whether the owner has already been notified about this recipient's expiration, making the notification job idempotent. ### 1.3 Add expiration period to settings models **OrganisationGlobalSettings** (JSON, application-level default): ```prisma model OrganisationGlobalSettings { // ... existing fields envelopeExpirationPeriod Json? } ``` Prisma `@default` doesn't work for `Json` columns, so the application-level default (`{ unit: 'month', amount: 1 }`) is applied in `extractDerivedTeamSettings` / `extractDerivedDocumentMeta` when the value is null. The migration should backfill existing rows with `{ "unit": "month", "amount": 1 }`. **TeamGlobalSettings** (nullable, null = inherit from org): ```prisma model TeamGlobalSettings { // ... existing fields envelopeExpirationPeriod Json? } ``` ### 1.4 Add expiration period to DocumentMeta This allows per-document override during the document editing flow: ```prisma model DocumentMeta { // ... existing fields envelopeExpirationPeriod Json? } ``` When null on DocumentMeta, the resolved team/org setting is used at send time. Validated at write time using `ZEnvelopeExpirationPeriod.nullable()`. **Important**: `envelopeExpirationPeriod` on `DocumentMeta` is a user-facing preference that may be set during the draft editing flow. It does NOT determine the final expiration — that is resolved at send time (see section 2.3). The value stored here is just the user's selection in the document editor. --- ## 2. Expiration Period Resolution ### 2.1 Duration mapping Add to `packages/lib/constants/envelope-expiration.ts` alongside the Zod schema: ```typescript import { Duration } from 'luxon'; const UNIT_TO_LUXON_KEY: Record = { day: 'days', week: 'weeks', month: 'months', year: 'years', }; export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationPeriod = { unit: 'month', amount: 1, }; export const getEnvelopeExpirationDuration = (period: TEnvelopeExpirationPeriod): Duration => { return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount }); }; ``` ### 2.2 Settings cascade integration `extractDerivedTeamSettings()` in `packages/lib/utils/teams.ts` needs **no code changes** — it iterates `Object.keys(derivedSettings)` and overrides with non-null team values at runtime. The new `envelopeExpirationPeriod` field on both `OrganisationGlobalSettings` and `TeamGlobalSettings` will be automatically picked up. Update `extractDerivedDocumentMeta()` in `packages/lib/utils/document.ts` to include the new field: ```typescript envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod, ``` ### 2.3 Compute `expiresAt` at send time The expiration period is **locked at send time** — when the envelope transitions to PENDING. The concrete `expiresAt` timestamp is computed for each recipient when the document is actually sent. In `packages/lib/server-only/document/send-document.ts`: ```typescript // Resolve effective period: document meta -> team/org settings -> default const rawPeriod = envelope.documentMeta?.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod; const expiresAt = resolveExpiresAt(rawPeriod); // Inside the $transaction, for each recipient: await tx.recipient.updateMany({ where: { envelopeId: envelope.id }, data: { expiresAt }, }); ``` ### 2.4 Compute `expiresAt` in the direct template flow `create-document-from-direct-template.ts` creates envelopes directly as PENDING and then calls `sendDocument` afterward. Since `sendDocument` handles setting `expiresAt` on recipients, the direct template flow doesn't need to set it directly — `sendDocument` handles it. --- ## 3. Cron Job Infrastructure (New) The current job system is purely event-triggered. Inngest natively supports cron-triggered functions, but the local provider (used in dev and by self-hosters who don't want a third-party dependency) has no scheduling capability. This section adds cron support to the local provider to maintain feature parity. ### 3.1 Extend `JobDefinition` with cron support Add an optional `cron` field to the trigger type in `packages/lib/jobs/client/_internal/job.ts`: ```typescript export type JobDefinition = { id: string; name: string; version: string; enabled?: boolean; optimizeParallelism?: boolean; trigger: { name: Name; schema?: z.ZodType; /** Cron expression (e.g. "* * * * *"). When set, the job runs on a schedule. */ cron?: string; }; handler: (options: { payload: Schema; io: JobRunIO }) => Promise; }; ``` ### 3.2 Inngest provider: wire up native cron In `packages/lib/jobs/client/inngest.ts`, when defining a function, check for `cron`: ```typescript defineJob(job) { if (job.trigger.cron) { this._functions.push( this._client.createFunction( { id: job.id, name: job.name }, { cron: job.trigger.cron }, async ({ step, logger }) => { const io = convertInngestIoToJobRunIo(step, logger, this); await job.handler({ payload: {} as any, io }); }, ), ); } else { // Existing event-triggered logic (unchanged) } } ``` ### 3.3 Local provider: poller + deterministic `BackgroundJob` IDs Use the existing `BackgroundJob` table for multi-instance dedupe instead of advisory locks. This approach keeps implementation Prisma-only (no raw SQL), works for single-instance and multi-instance deployments, and preserves existing retry/visibility behavior. **On `defineJob()`**: If the job has a `cron` field, register an in-process scheduler entry and start a lightweight poller (every 30s with jitter). **Each poll tick**: 1. Evaluate whether the cron schedule has one or more due run slots since the last tick (use a real cron parser, e.g. `cron-parser`) 2. For each due slot, build a deterministic run ID from job ID + scheduled slot time 3. Create a `BackgroundJob` row with that deterministic ID using Prisma 4. If insert succeeds → enqueue via the existing local job pipeline 5. If insert fails with Prisma `P2002` (unique violation) → another node already enqueued that run, skip ### 3.4 Summary of changes to the job system | File | Change | | ------------------------------------------- | ---------------------------------------------------------------- | | `packages/lib/jobs/client/_internal/job.ts` | Add optional `cron` field to `trigger` type | | `packages/lib/jobs/client/local.ts` | Add cron poller + deterministic `BackgroundJob.id` dedupe | | `packages/lib/jobs/client/inngest.ts` | Wire up `{ cron: ... }` in `createFunction` for cron jobs | | `packages/lib/jobs/client/_internal/*` | Add cron helper utilities (`getDueCronSlots`, run ID generation) | --- ## 4. Expiration Processing ### 4.1 Two-job architecture Expiration uses two jobs: a **sweep dispatcher** that runs on a cron schedule and finds expired recipients, and an **individual notification job** that handles the audit log, owner notification email, and webhook for a single recipient. This separation means: - The sweep is lightweight and fast (just a query + N job triggers) - Each recipient's expiration notification is independently retryable - The individual jobs are idempotent — they check `expirationNotifiedAt IS NULL` before processing ### 4.2 Sweep job: `EXPIRE_RECIPIENTS_SWEEP_JOB` A cron-triggered job that runs every minute to find and dispatch notifications for expired recipients. **Definition:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts` **Handler:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts` ```typescript const expiredRecipients = await prisma.recipient.findMany({ where: { expiresAt: { lte: new Date() }, expirationNotifiedAt: null, signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] }, envelope: { status: DocumentStatus.PENDING }, }, select: { id: true }, take: 100, }); for (const recipient of expiredRecipients) { await jobs.triggerJob({ name: 'internal.notify-recipient-expired', payload: { recipientId: recipient.id }, }); } ``` ### 4.3 Individual notification job: `NOTIFY_RECIPIENT_EXPIRED_JOB` An event-triggered job that handles a single recipient's expiration. **Definition:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts` **Handler:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts` The handler: 1. Fetches the recipient (with guard: `expirationNotifiedAt IS NULL` + not signed/rejected) 2. Sets `recipient.expirationNotifiedAt = now()` (idempotency) 3. Creates audit log entry with `DOCUMENT_RECIPIENT_EXPIRED` type 4. Sends email notification to the **document owner** (inline — no separate email job) 5. The document stays PENDING — the owner decides whether to resend or cancel ### 4.4 Register in job client Add `EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION` and `NOTIFY_RECIPIENT_EXPIRED_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`. ### 4.5 Email template: Recipient Expired Target the **document owner**: - Subject: `Signing window expired for "{recipientName}" on "{documentTitle}"` - Body: "The signing window for {recipientName} ({recipientEmail}) on document {title} has expired. You can resend the document to extend their deadline or cancel the document." - Include a "View Document" link to the document page in the app Template files: - `packages/email/templates/recipient-expired.tsx` — wrapper - `packages/email/template-components/template-recipient-expired.tsx` — body ### 4.6 Recipient signing guard In the signing flow, check `recipient.expiresAt` before allowing any signing action. Note that the document stays PENDING even after recipient expiration, so the existing `status !== PENDING` guard does not block expired recipients — an explicit expiration check is required: ```typescript if (recipient.expiresAt && recipient.expiresAt <= new Date()) { throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, { message: 'Recipient signing window has expired', }); } ``` **Files to update:** - `packages/lib/server-only/document/complete-document-with-token.ts` - `packages/lib/server-only/field/sign-field-with-token.ts` - `packages/lib/server-only/field/remove-signed-field-with-token.ts` - `packages/lib/server-only/document/reject-document-with-token.ts` --- ## 5. UI Design ### 5.1 Expiration Period Selector Component Use a number input + unit selector combo. This gives organisations full flexibility to configure any duration without needing schema changes for new options. **Layout**: A horizontal group with: - A number `` (min 1, integer) - A `