22 KiB
date, title
| date | title |
|---|---|
| 2026-02-10 | 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):
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<typeof ZEnvelopeExpirationPeriod>;
Semantics:
nullonDocumentMeta/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
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
expiresAtensures 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):
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):
model TeamGlobalSettings {
// ... existing fields
envelopeExpirationPeriod Json?
}
1.4 Add expiration period to DocumentMeta
This allows per-document override during the document editing flow:
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:
import { Duration } from 'luxon';
const UNIT_TO_LUXON_KEY: Record<TEnvelopeExpirationPeriod['unit'], string> = {
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:
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:
// 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:
export type JobDefinition<Name extends string = string, Schema = any> = {
id: string;
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;
/** Cron expression (e.g. "* * * * *"). When set, the job runs on a schedule. */
cron?: string;
};
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
};
3.2 Inngest provider: wire up native cron
In packages/lib/jobs/client/inngest.ts, when defining a function, check for cron:
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:
- 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) - For each due slot, build a deterministic run ID from job ID + scheduled slot time
- Create a
BackgroundJobrow with that deterministic ID using Prisma - If insert succeeds → enqueue via the existing local job pipeline
- 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 NULLbefore 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
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:
- Fetches the recipient (with guard:
expirationNotifiedAt IS NULL+ not signed/rejected) - Sets
recipient.expirationNotifiedAt = now()(idempotency) - Creates audit log entry with
DOCUMENT_RECIPIENT_EXPIREDtype - Sends email notification to the document owner (inline — no separate email job)
- 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— wrapperpackages/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:
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.tspackages/lib/server-only/field/sign-field-with-token.tspackages/lib/server-only/field/remove-signed-field-with-token.tspackages/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
<Input>(min 1, integer) - A
<Select>for the unit (day,week,month,year) - A "Never expires" toggle/checkbox that disables the duration inputs and sets the value to
{ disabled: true }
At the team level, include an "Inherit from organisation" option that clears the value to null.
Validation: Use ZEnvelopeExpirationPeriod for form validation.
5.2 Organisation Settings → Document Preferences
Add a "Default Envelope Expiration" field to the DocumentPreferencesForm component. At the org level, there is no "Inherit" option — it must have a concrete value (default: { unit: 'month', amount: 1 }).
5.3 Team Settings → Document Preferences
Same field as org, but with the additional "Inherit from organisation" option (stored as null).
5.4 Document Editor → Settings Step
Add the expiration selector to packages/ui/primitives/document-flow/add-settings.tsx inside the "Advanced Options" accordion.
Label: "Expiration" Description: "How long recipients have to complete this document after it is sent."
5.5 Recipient Signing Page — Expired State
When a recipient visits a signing link for an expired recipient:
- Redirect to
/sign/{token}/expired - Show a clear, non-alarming message: "Your signing window has expired. Please contact the sender for a new invitation."
- Do not show the signing form or fields
- The
isExpiredflag inget-envelope-for-recipient-signing.tsis derived fromrecipient.expiresAt
5.6 Embed Signing — Expired State
Embed signing routes handle recipient expiration by throwing embed-recipient-expired:
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx— both V1 and V2 loaders check expiration- The embed error boundary renders an
EmbedRecipientExpiredcomponent - Direct templates (
direct.$token.tsx) create fresh recipients soisExpiredis alwaysfalse
6. API / TRPC Changes
6.1 Update settings mutation schemas
packages/trpc/server/organisation-router/update-organisation-settings.types.ts— addenvelopeExpirationPeriod: ZEnvelopeExpirationPeriod(non-nullable at org level)packages/trpc/server/team-router/update-team-settings.types.ts— addenvelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()(null = inherit from org)
6.2 Update document mutation schemas
packages/lib/types/document-meta.ts— addenvelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()to the meta schemapackages/trpc/server/document-router/create-document.types.ts— include in metapackages/trpc/server/document-router/update-document.types.ts— include in metapackages/trpc/server/document-router/distribute-document.types.ts— include in meta
6.3 Expose expiresAt in recipient responses
Ensure expiresAt and expirationNotifiedAt are returned when fetching recipients/documents so the UI can display expiration status.
6.4 Webhook / API schema updates
- Recipient schema includes
expiresAtandexpirationNotifiedAtfields (replacing the oldexpiredfield) - Update
packages/api/v1/schema.ts, webhook payload types, zapier integration, and sample data generators
7. Edge Cases & Considerations
7.1 Already-sent documents
The migration should NOT retroactively expire existing recipients. expiresAt will be null for all existing recipients, meaning they never expire (backward-compatible).
7.2 Re-sending / redistributing
When redistribute is called on a PENDING document, expiresAt should be refreshed on all eligible recipients. Redistributing signals active intent, so the clock should restart.
Implementation: resendDocument refreshes recipient.expiresAt for all recipients that haven't signed/rejected yet.
7.3 Multi-recipient partial expiration
If some recipients have signed and others expire, the document stays PENDING. This is the key advantage over document-level expiration — the owner can resend to extend the expired recipients' deadlines without affecting those who've already signed.
7.4 Partial completion
Partial signatures are preserved. The document is not sealed/completed until all required recipients have signed (or the owner cancels).
7.5 Timezone handling
expiresAt is stored as UTC. Display in the sender's configured timezone.
7.6 Race condition: signing at expiration time
The signing guard checks recipient.expiresAt in application code before the signing operation. The notification job's guard (expirationNotifiedAt IS NULL + signingStatus NOT IN (SIGNED, REJECTED)) prevents double-notifications. If a recipient signs just before expiration, the sweep's signingStatus filter skips them.
7.7 Direct template flow
create-document-from-direct-template.ts creates envelopes directly as PENDING then calls sendDocument. Since sendDocument sets recipient.expiresAt, no special handling is needed in the direct template flow.
8. Migration Plan
- Add Prisma schema changes (
expiresAt+expirationNotifiedAton Recipient,Json?fields on settings models, index) - Generate and run migration
- Backfill: set
envelopeExpirationPeriodto{ "unit": "month", "amount": 1 }on all existingOrganisationGlobalSettingsrows - No backfill on
Recipient.expiresAt— existing recipients keep null (never expire) - Deploy backend changes (jobs, guards, email template)
- Deploy frontend changes (settings UI, document editor, signing page, embeds)
9. Files to Create or Modify
New Files
packages/lib/constants/envelope-expiration.ts—ZEnvelopeExpirationPeriodschema, types,DEFAULT_ENVELOPE_EXPIRATION_PERIOD,getEnvelopeExpirationDuration(),resolveExpiresAt()helperpackages/lib/jobs/definitions/internal/expire-recipients-sweep.ts— cron sweep job definitionpackages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts— cron sweep handlerpackages/lib/jobs/definitions/internal/notify-recipient-expired.ts— individual notification job definitionpackages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts— notification handler (includes inline email sending)packages/email/templates/recipient-expired.tsx— email template wrapperpackages/email/template-components/template-recipient-expired.tsx— email template bodyapps/remix/app/components/embed/embed-recipient-expired.tsx— embed expired component
Modified Files
Job system (cron infrastructure):
packages/lib/jobs/client/_internal/job.ts— add optionalcronfield totriggertypepackages/lib/jobs/client/local.ts— add cron poller + deterministicBackgroundJob.iddedupepackages/lib/jobs/client/inngest.ts— wire up{ cron: ... }increateFunctionpackages/lib/jobs/client/_internal/*— add cron helper utilities (slot calc + run ID)packages/lib/jobs/client.ts— register new jobs
Schema & data layer:
packages/prisma/schema.prisma— model changes + indexpackages/lib/utils/document.ts—extractDerivedDocumentMeta(addenvelopeExpirationPeriod)packages/lib/server-only/document/send-document.ts— resolve settings + compute and setrecipient.expiresAtpackages/lib/server-only/template/create-document-from-direct-template.ts— no changes (sendDocument handles it)packages/lib/server-only/document/resend-document.ts— refreshrecipient.expiresAton redistributepackages/lib/server-only/document/complete-document-with-token.ts— recipient expiration guardpackages/lib/server-only/field/sign-field-with-token.ts— recipient expiration guardpackages/lib/server-only/field/remove-signed-field-with-token.ts— recipient expiration guardpackages/lib/server-only/document/reject-document-with-token.ts— recipient expiration guard
Error handling:
packages/lib/errors/app-error.ts— addRECIPIENT_EXPIREDerror code
Audit logs:
packages/lib/types/document-audit-logs.ts— addDOCUMENT_RECIPIENT_EXPIREDtype withrecipientEmail/recipientNamedata fieldspackages/lib/utils/document-audit-logs.ts— add human-readable rendering forDOCUMENT_RECIPIENT_EXPIRED
Signing page:
packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts— deriveisExpiredfromrecipient.expiresAtapps/remix/app/routes/_recipient+/sign.$token+/_index.tsx— keep redirect to expired page usingisExpired
Embeds:
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx— check recipient expiration in V1/V2 loadersapps/remix/app/routes/embed+/_v0+/_layout.tsx— handleembed-recipient-expiredin error boundary
Webhook / API:
packages/lib/types/recipient.ts— addexpiresAt/expirationNotifiedAtto recipient typepackages/lib/types/webhook-payload.ts— addexpiresAt/expirationNotifiedAtto webhook recipientpackages/lib/server-only/webhooks/trigger/generate-sample-data.ts— update sample datapackages/lib/server-only/webhooks/zapier/list-documents.ts— update zapier recipient shapepackages/api/v1/schema.ts— addexpiresAtto API recipient schema
TRPC / settings:
packages/trpc/server/organisation-router/update-organisation-settings.types.tspackages/trpc/server/team-router/update-team-settings.types.tspackages/lib/types/document-meta.ts
UI:
apps/remix/app/components/forms/document-preferences-form.tsx— add expiration period pickerpackages/ui/primitives/document-flow/add-settings.tsx— add expiration fieldpackages/ui/primitives/document-flow/add-settings.types.ts— add to schema