From 006b1d0a579e36448983b0a4d624805d7230ef4e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 20 Feb 2026 11:36:20 +1100 Subject: [PATCH] feat: per-recipient envelope expiration (#2519) --- .../calm-violet-tide-envelope-expiration.md | 519 ++++++++++++++++++ .../embed/authoring/configure-fields-view.tsx | 4 +- .../embed/embed-recipient-expired.tsx | 46 ++ .../forms/document-preferences-form.tsx | 38 ++ .../document-page-view-recipients.tsx | 54 +- .../envelope-editor-settings-dialog.tsx | 42 +- .../o.$orgUrl.settings.document.tsx | 2 + .../t.$teamUrl+/settings.document.tsx | 2 + .../_recipient+/sign.$token+/_index.tsx | 24 +- .../_recipient+/sign.$token+/expired.tsx | 114 ++++ apps/remix/app/routes/embed+/_v0+/_layout.tsx | 5 + .../app/routes/embed+/_v0+/sign.$token.tsx | 25 +- apps/remix/server/router.ts | 5 + package-lock.json | 13 + package.json | 1 + packages/api/v1/schema.ts | 7 +- .../e2e/api/v2/envelopes-api.spec.ts | 3 + .../envelope-expiration-send.spec.ts | 291 ++++++++++ .../envelope-expiration-settings.spec.ts | 150 +++++ .../envelope-expiration-signing.spec.ts | 131 +++++ .../organisation-team-preferences.spec.ts | 9 +- .../template-recipient-expired.tsx | 54 ++ .../email/templates/recipient-expired.tsx | 68 +++ packages/lib/constants/envelope-expiration.ts | 66 +++ packages/lib/errors/app-error.ts | 2 + packages/lib/jobs/client.ts | 6 + packages/lib/jobs/client/_internal/job.ts | 5 + packages/lib/jobs/client/base.ts | 10 + packages/lib/jobs/client/client.ts | 11 + packages/lib/jobs/client/inngest.ts | 9 +- packages/lib/jobs/client/local.ts | 153 +++++- ...d-owner-recipient-expired-email.handler.ts | 117 ++++ .../send-owner-recipient-expired-email.ts | 32 ++ .../expire-recipients-sweep.handler.ts | 53 ++ .../internal/expire-recipients-sweep.ts | 30 + .../process-recipient-expired.handler.ts | 95 ++++ .../internal/process-recipient-expired.ts | 31 ++ .../document/complete-document-with-token.ts | 3 + .../document/reject-document-with-token.ts | 11 +- .../server-only/document/resend-document.ts | 23 +- .../lib/server-only/document/send-document.ts | 23 + ...et-envelope-for-direct-template-signing.ts | 1 + .../get-envelope-for-recipient-signing.ts | 10 +- .../field/remove-signed-field-with-token.ts | 3 + .../field/sign-field-with-token.ts | 3 + .../template/create-document-from-template.ts | 4 + .../webhooks/trigger/generate-sample-data.ts | 97 +++- .../webhooks/zapier/list-documents.ts | 4 +- packages/lib/types/document-audit-logs.ts | 14 + packages/lib/types/document-email.ts | 9 + packages/lib/types/document-meta.ts | 2 + packages/lib/types/document.ts | 1 + packages/lib/types/envelope.ts | 1 + packages/lib/types/recipient.ts | 12 +- packages/lib/types/webhook-payload.ts | 6 +- packages/lib/utils/document-audit-logs.ts | 8 + packages/lib/utils/document.ts | 4 + packages/lib/utils/organisations.ts | 4 + packages/lib/utils/recipients.ts | 21 + packages/lib/utils/teams.ts | 3 + .../migration.sql | 18 + packages/prisma/schema.prisma | 52 +- .../envelope-router/use-envelope.types.ts | 2 + .../update-organisation-settings.ts | 3 + .../update-organisation-settings.types.ts | 2 + .../team-router/update-team-settings.ts | 3 + .../team-router/update-team-settings.types.ts | 2 + .../trpc/server/template-router/schema.ts | 2 + .../document/document-email-checkboxes.tsx | 67 ++- .../document/expiration-period-picker.tsx | 153 ++++++ 70 files changed, 2705 insertions(+), 93 deletions(-) create mode 100644 .agents/plans/calm-violet-tide-envelope-expiration.md create mode 100644 apps/remix/app/components/embed/embed-recipient-expired.tsx create mode 100644 apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx create mode 100644 packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-expiration-signing.spec.ts create mode 100644 packages/email/template-components/template-recipient-expired.tsx create mode 100644 packages/email/templates/recipient-expired.tsx create mode 100644 packages/lib/constants/envelope-expiration.ts create mode 100644 packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.ts create mode 100644 packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts create mode 100644 packages/lib/jobs/definitions/internal/process-recipient-expired.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/process-recipient-expired.ts create mode 100644 packages/prisma/migrations/20260219091107_add_recipient_expirations/migration.sql create mode 100644 packages/ui/components/document/expiration-period-picker.tsx diff --git a/.agents/plans/calm-violet-tide-envelope-expiration.md b/.agents/plans/calm-violet-tide-envelope-expiration.md new file mode 100644 index 000000000..b2e8506ec --- /dev/null +++ b/.agents/plans/calm-violet-tide-envelope-expiration.md @@ -0,0 +1,519 @@ +--- +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 ` + + + + + + + Custom duration + + + + Never expires + + + {inheritLabel !== undefined && {inheritLabel}} + + + + {mode === 'duration' && ( +
+ onAmountChange(Number(e.target.value))} + disabled={disabled} + /> + + +
+ )} + + ); +};