mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: per-recipient envelope expiration (#2519)
This commit is contained in:
parent
f3ec8ddc57
commit
006b1d0a57
70 changed files with 2705 additions and 93 deletions
519
.agents/plans/calm-violet-tide-envelope-expiration.md
Normal file
519
.agents/plans/calm-violet-tide-envelope-expiration.md
Normal file
|
|
@ -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<typeof ZEnvelopeExpirationPeriod>;
|
||||
```
|
||||
|
||||
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<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:
|
||||
|
||||
```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<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`:
|
||||
|
||||
```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 `<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 `isExpired` flag in `get-envelope-for-recipient-signing.ts` is derived from `recipient.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 `EmbedRecipientExpired` component
|
||||
- Direct templates (`direct.$token.tsx`) create fresh recipients so `isExpired` is always `false`
|
||||
|
||||
---
|
||||
|
||||
## 6. API / TRPC Changes
|
||||
|
||||
### 6.1 Update settings mutation schemas
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod` (non-nullable at org level)
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` (null = inherit from org)
|
||||
|
||||
### 6.2 Update document mutation schemas
|
||||
|
||||
- `packages/lib/types/document-meta.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` to the meta schema
|
||||
- `packages/trpc/server/document-router/create-document.types.ts` — include in meta
|
||||
- `packages/trpc/server/document-router/update-document.types.ts` — include in meta
|
||||
- `packages/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 `expiresAt` and `expirationNotifiedAt` fields (replacing the old `expired` field)
|
||||
- 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
|
||||
|
||||
1. Add Prisma schema changes (`expiresAt` + `expirationNotifiedAt` on Recipient, `Json?` fields on settings models, index)
|
||||
2. Generate and run migration
|
||||
3. Backfill: set `envelopeExpirationPeriod` to `{ "unit": "month", "amount": 1 }` on all existing `OrganisationGlobalSettings` rows
|
||||
4. No backfill on `Recipient.expiresAt` — existing recipients keep null (never expire)
|
||||
5. Deploy backend changes (jobs, guards, email template)
|
||||
6. Deploy frontend changes (settings UI, document editor, signing page, embeds)
|
||||
|
||||
---
|
||||
|
||||
## 9. Files to Create or Modify
|
||||
|
||||
### New Files
|
||||
|
||||
- `packages/lib/constants/envelope-expiration.ts` — `ZEnvelopeExpirationPeriod` schema, types, `DEFAULT_ENVELOPE_EXPIRATION_PERIOD`, `getEnvelopeExpirationDuration()`, `resolveExpiresAt()` helper
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts` — cron sweep job definition
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts` — cron sweep handler
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts` — individual notification job definition
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts` — notification handler (includes inline email sending)
|
||||
- `packages/email/templates/recipient-expired.tsx` — email template wrapper
|
||||
- `packages/email/template-components/template-recipient-expired.tsx` — email template body
|
||||
- `apps/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 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`
|
||||
- `packages/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 + index
|
||||
- `packages/lib/utils/document.ts` — `extractDerivedDocumentMeta` (add `envelopeExpirationPeriod`)
|
||||
- `packages/lib/server-only/document/send-document.ts` — resolve settings + compute and set `recipient.expiresAt`
|
||||
- `packages/lib/server-only/template/create-document-from-direct-template.ts` — no changes (sendDocument handles it)
|
||||
- `packages/lib/server-only/document/resend-document.ts` — refresh `recipient.expiresAt` on redistribute
|
||||
- `packages/lib/server-only/document/complete-document-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/sign-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/remove-signed-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/document/reject-document-with-token.ts` — recipient expiration guard
|
||||
|
||||
**Error handling:**
|
||||
|
||||
- `packages/lib/errors/app-error.ts` — add `RECIPIENT_EXPIRED` error code
|
||||
|
||||
**Audit logs:**
|
||||
|
||||
- `packages/lib/types/document-audit-logs.ts` — add `DOCUMENT_RECIPIENT_EXPIRED` type with `recipientEmail`/`recipientName` data fields
|
||||
- `packages/lib/utils/document-audit-logs.ts` — add human-readable rendering for `DOCUMENT_RECIPIENT_EXPIRED`
|
||||
|
||||
**Signing page:**
|
||||
|
||||
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` — derive `isExpired` from `recipient.expiresAt`
|
||||
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx` — keep redirect to expired page using `isExpired`
|
||||
|
||||
**Embeds:**
|
||||
|
||||
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — check recipient expiration in V1/V2 loaders
|
||||
- `apps/remix/app/routes/embed+/_v0+/_layout.tsx` — handle `embed-recipient-expired` in error boundary
|
||||
|
||||
**Webhook / API:**
|
||||
|
||||
- `packages/lib/types/recipient.ts` — add `expiresAt`/`expirationNotifiedAt` to recipient type
|
||||
- `packages/lib/types/webhook-payload.ts` — add `expiresAt`/`expirationNotifiedAt` to webhook recipient
|
||||
- `packages/lib/server-only/webhooks/trigger/generate-sample-data.ts` — update sample data
|
||||
- `packages/lib/server-only/webhooks/zapier/list-documents.ts` — update zapier recipient shape
|
||||
- `packages/api/v1/schema.ts` — add `expiresAt` to API recipient schema
|
||||
|
||||
**TRPC / settings:**
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts`
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts`
|
||||
- `packages/lib/types/document-meta.ts`
|
||||
|
||||
**UI:**
|
||||
|
||||
- `apps/remix/app/components/forms/document-preferences-form.tsx` — add expiration period picker
|
||||
- `packages/ui/primitives/document-flow/add-settings.tsx` — add expiration field
|
||||
- `packages/ui/primitives/document-flow/add-settings.types.ts` — add to schema
|
||||
|
|
@ -115,7 +115,9 @@ export const ConfigureFieldsView = ({
|
|||
templateId: null,
|
||||
token: '',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expired: null, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
|
|
|
|||
46
apps/remix/app/components/embed/embed-recipient-expired.tsx
Normal file
46
apps/remix/app/components/embed/embed-recipient-expired.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedRecipientExpired = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'recipient-expired',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-center text-2xl font-bold text-foreground">
|
||||
<Trans>Signing Window Expired</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Your signing window for this document has expired. Please contact the sender for a new
|
||||
invitation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,6 +11,10 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
|||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
|
|
@ -27,6 +31,7 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
|
@ -70,6 +75,7 @@ export type TDocumentPreferencesFormSchema = {
|
|||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
|
|
@ -87,6 +93,7 @@ type SettingsSubset = Pick<
|
|||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
|
|
@ -126,6 +133,7 @@ export const DocumentPreferencesForm = ({
|
|||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
|
|
@ -146,6 +154,7 @@ export const DocumentPreferencesForm = ({
|
|||
: null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
|
@ -669,6 +678,35 @@ export const DocumentPreferencesForm = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Envelope Expiration</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls how long recipients have to complete signing before the document
|
||||
expires. After expiration, recipients can no longer sign the document.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,21 @@ import {
|
|||
AlertTriangle,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
Clock8Icon,
|
||||
MailIcon,
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
|
@ -44,7 +46,7 @@ export const DocumentPageViewRecipients = ({
|
|||
envelope,
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
|
|
@ -66,9 +68,9 @@ export const DocumentPageViewRecipients = ({
|
|||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
|
|
@ -87,7 +89,7 @@ export const DocumentPageViewRecipients = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
<ul className="divide-y border-t text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
|
|
@ -98,9 +100,9 @@ export const DocumentPageViewRecipients = ({
|
|||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
|
@ -154,12 +156,41 @@ export const DocumentPageViewRecipients = ({
|
|||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
isRecipientExpired(recipient) && (
|
||||
<Badge variant="destructive">
|
||||
<Clock8Icon className="mr-1 h-3 w-3" />
|
||||
<Trans>Expired</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
!isRecipientExpired(recipient) &&
|
||||
(recipient.expiresAt ? (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Expires{' '}
|
||||
{recipient.expiresAt
|
||||
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
|
||||
: 'N/A'}
|
||||
</Trans>
|
||||
</p>
|
||||
</PopoverHover>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
))}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
|
|
@ -175,7 +206,7 @@ export const DocumentPageViewRecipients = ({
|
|||
<Trans>Reason for rejection: </Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{recipient.rejectionReason}
|
||||
</p>
|
||||
</PopoverHover>
|
||||
|
|
@ -183,7 +214,8 @@ export const DocumentPageViewRecipients = ({
|
|||
|
||||
{envelope.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
!isRecipientExpired(recipient) && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
DOCUMENT_DISTRIBUTION_METHODS,
|
||||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
|
|
@ -62,6 +63,7 @@ import {
|
|||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
|
|
@ -135,6 +137,7 @@ export const ZAddSettingsFormSchema = z.object({
|
|||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -207,6 +210,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -245,6 +249,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
message,
|
||||
subject,
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
|
|
@ -273,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -373,7 +379,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
|
||||
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 py-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
key={activeTab}
|
||||
>
|
||||
|
|
@ -636,6 +642,40 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Expiration</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
How long recipients have to complete this document after it is
|
||||
sent. Uses the team default when set to inherit.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={envelopeHasBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.with('email', () => (
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
|
|
@ -90,6 +91,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export default function TeamsSettingsPage() {
|
|||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
|
|
@ -67,6 +68,7 @@ export default function TeamsSettingsPage() {
|
|||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
|
|||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
|
|
@ -140,6 +141,10 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
|
||||
if (
|
||||
document.status === DocumentStatus.COMPLETED ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
|
@ -201,7 +206,8 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||
return envelopeForSigning;
|
||||
}
|
||||
|
||||
const { envelope, recipient, isCompleted, isRejected, isRecipientsTurn } = envelopeForSigning;
|
||||
const { envelope, recipient, isCompleted, isRejected, isExpired, isRecipientsTurn } =
|
||||
envelopeForSigning;
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw redirect(`/sign/${token}/waiting`);
|
||||
|
|
@ -233,12 +239,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||
} as const;
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
if (isRejected) {
|
||||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
|
|
@ -247,6 +247,16 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||
throw redirect(envelope.documentMeta.redirectUrl || `/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
envelopeForSigning,
|
||||
|
|
|
|||
114
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
114
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Trans } from '@lingui/react/macro';
|
||||
import { TimerOffIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const title = document.title;
|
||||
|
||||
const recipient = await getRecipientByToken({ token }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const recipientEmail = recipient.email;
|
||||
|
||||
if (isDocumentAccessValid) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
recipientEmail,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge
|
||||
variant="neutral"
|
||||
size="default"
|
||||
title={title}
|
||||
className="mb-6 rounded-xl border bg-transparent"
|
||||
>
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if
|
||||
you need a new copy to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { EmbedDocumentCompleted } from '~/components/embed/embed-document-comple
|
|||
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||
import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expired';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
|
|
@ -79,6 +80,10 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
|||
return <EmbedDocumentWaitingForTurn />;
|
||||
}
|
||||
|
||||
if (error.status === 403 && error.data.type === 'embed-recipient-expired') {
|
||||
return <EmbedRecipientExpired />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
||||
return <EmbedDocumentRejected />;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
|
|||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
||||
|
|
@ -78,6 +79,17 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-recipient-expired',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
|
@ -190,7 +202,7 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
||||
const { envelope, recipient, isRecipientsTurn, isExpired } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
|
|
@ -208,6 +220,17 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-recipient-expired',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw data(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ app.use(async (c, next) => {
|
|||
|
||||
const honoLogger = logger.child({
|
||||
requestId: c.var.requestId,
|
||||
requestPath: c.req.path,
|
||||
ipAddress: metadata.ipAddress,
|
||||
userAgent: metadata.userAgent,
|
||||
});
|
||||
|
|
@ -146,6 +147,10 @@ if (env('NODE_ENV') !== 'development') {
|
|||
// Start license client to verify license on startup.
|
||||
void LicenseClient.start();
|
||||
|
||||
// Start cron scheduler for background jobs (e.g. envelope expiration sweep).
|
||||
// No-op for Inngest provider which handles cron externally.
|
||||
jobsClient.startCron();
|
||||
|
||||
void migrateDeletedAccountServiceAccount();
|
||||
void migrateLegacyServiceAccount();
|
||||
|
||||
|
|
|
|||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
|
|
@ -20192,6 +20193,18 @@
|
|||
"typescript": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
|
||||
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
|
|
|
|||
|
|
@ -437,8 +437,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
|||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().nullish(),
|
||||
token: z.string(),
|
||||
// !: Not used for now
|
||||
// expired: z.string(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
signedAt: z.date().nullable(),
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
|
|
@ -576,7 +576,8 @@ export const ZRecipientSchema = z.object({
|
|||
token: z.string(),
|
||||
signingOrder: z.number().nullish(),
|
||||
documentDeletedAt: z.date().nullish(),
|
||||
expired: z.date().nullish(),
|
||||
expiresAt: z.date().nullish(),
|
||||
expirationNotifiedAt: z.date().nullish(),
|
||||
signedAt: z.date().nullish(),
|
||||
authOptions: z.unknown(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ test.describe('API V2 Envelopes', () => {
|
|||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
|
|
@ -180,6 +181,7 @@ test.describe('API V2 Envelopes', () => {
|
|||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -205,6 +207,7 @@ test.describe('API V2 Envelopes', () => {
|
|||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-send',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-expiry@test.documenso.com',
|
||||
name: 'Signer Expiry',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
// Check that recipients now have expiresAt set.
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// The default expiration period is 3 months. Verify it's roughly correct.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// 3 months is roughly 89-92 days. Allow a generous range.
|
||||
expect(diffDays).toBeGreaterThan(80);
|
||||
expect(diffDays).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Set org expiration to 7 days.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'day', amount: 7 } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-custom',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Custom Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-custom@test.documenso.com',
|
||||
name: 'Signer Custom',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).not.toBeNull();
|
||||
|
||||
// 7 days expiration.
|
||||
const expiresAt = recipients[0].expiresAt!;
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(diffDays).toBeGreaterThan(6);
|
||||
expect(diffDays).toBeLessThan(8);
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: sending document with expiration disabled', async ({ request }) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Disable expiration at org level.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { disabled: true } },
|
||||
});
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-expiration-disabled',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const createPayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: '[TEST] Disabled Expiration Send Test',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer-disabled@test.documenso.com',
|
||||
name: 'Signer Disabled',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(createPayload));
|
||||
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
|
||||
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(recipients.length).toBe(1);
|
||||
expect(recipients[0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['resend-target@test.documenso.com']);
|
||||
|
||||
const recipient = document.recipients[0];
|
||||
|
||||
// Set an initial expiresAt that's 1 day from now.
|
||||
const initialExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: initialExpiresAt },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
// Open the document action menu and click Resend.
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeAttached();
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
|
||||
// Select the recipient and send.
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Verify expiresAt was refreshed.
|
||||
await expect(async () => {
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.expiresAt).not.toBeNull();
|
||||
expect(updatedRecipient.expiresAt!.getTime()).toBeGreaterThan(initialExpiresAt.getTime());
|
||||
}).toPass({ timeout: 10_000 });
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('2');
|
||||
|
||||
// Find all triggers, the unit picker is the one showing Months/Days/etc.
|
||||
// In the duration mode, there's a mode select and a unit select.
|
||||
// The unit select is inside the duration row, after the number input.
|
||||
// Let's find the select trigger that contains the unit text.
|
||||
const unitTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: 'Custom duration' });
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(orgSettings.envelopeExpirationPeriod).toEqual({ disabled: true });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team inherits expiration from organisation', async () => {
|
||||
const { organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks directly.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
// Verify team settings inherit the org setting.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ page }) => {
|
||||
const { user, organisation, team } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Set org expiration to 2 weeks.
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Scope to the "Default Envelope Expiration" form field section.
|
||||
const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
// Switch to custom duration.
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Custom duration' }).click();
|
||||
|
||||
// Set to 5 days.
|
||||
const amountInput = expirationSection.getByRole('spinbutton');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('5');
|
||||
|
||||
const unitTrigger = expirationSection
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'day', amount: 5 });
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient is redirected to expired page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['expired-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to the past so the recipient is expired.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
await page.waitForURL(`/sign/${recipient.token}/expired`);
|
||||
|
||||
await expect(page.getByText('Signing Deadline Expired')).toBeVisible();
|
||||
await expect(page.getByText('The signing deadline for this document has passed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: non-expired recipient can access signing page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['active-recipient@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Set expiresAt to 1 hour in the future.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: recipient with null expiresAt can sign normally', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['null-expiry@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Verify expiresAt is null (default from seed).
|
||||
const dbRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(dbRecipient.expiresAt).toBeNull();
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Use only a SIGNATURE field to simplify the signing flow.
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: [user],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/sign/${recipient.token}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Now expire the recipient while they're on the signing page.
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
// Set up signature.
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Click the signature field to attempt to insert it.
|
||||
// The server will reject because the recipient is now expired.
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (signatureField) {
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
}
|
||||
|
||||
// The server should reject the signing attempt because the recipient has expired.
|
||||
// Verify the field was NOT inserted (stays data-inserted="false").
|
||||
if (signatureField) {
|
||||
await expect(async () => {
|
||||
const field = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: signatureField.id },
|
||||
});
|
||||
|
||||
expect(field.inserted).toBe(false);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
|
@ -229,6 +229,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
documentPending: false, // unchecked
|
||||
documentCompleted: true,
|
||||
documentDeleted: false, // unchecked
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
|
|
@ -244,9 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients with a signing request' })
|
||||
.uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
|
||||
.uncheck();
|
||||
|
|
@ -270,6 +269,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
|
|
@ -290,6 +290,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
documentPending: true,
|
||||
documentCompleted: false,
|
||||
documentDeleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: false,
|
||||
});
|
||||
|
||||
|
|
@ -315,6 +316,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
|
||||
|
|
@ -335,6 +337,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
|||
documentPending: false,
|
||||
documentCompleted: true,
|
||||
documentDeleted: false,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCompleted: true,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateRecipientExpiredProps = {
|
||||
documentName: string;
|
||||
recipientName: string;
|
||||
recipientEmail: string;
|
||||
documentLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateRecipientExpired = ({
|
||||
documentName,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
documentLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateRecipientExpiredProps) => {
|
||||
const displayName = recipientName || recipientEmail;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold text-primary">
|
||||
<Trans>
|
||||
Signing window expired for "{displayName}" on "{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>
|
||||
The signing window for {displayName} on document "{documentName}" has expired. You can
|
||||
resend the document to extend their deadline or cancel the document.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="my-4 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center text-sm font-medium text-white no-underline"
|
||||
href={documentLink}
|
||||
>
|
||||
<Trans>View Document</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateRecipientExpired;
|
||||
68
packages/email/templates/recipient-expired.tsx
Normal file
68
packages/email/templates/recipient-expired.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateRecipientExpiredProps } from '../template-components/template-recipient-expired';
|
||||
import { TemplateRecipientExpired } from '../template-components/template-recipient-expired';
|
||||
|
||||
export type RecipientExpiredEmailTemplateProps = Partial<TemplateRecipientExpiredProps>;
|
||||
|
||||
export const RecipientExpiredTemplate = ({
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
recipientName = 'John Doe',
|
||||
recipientEmail = 'john@example.com',
|
||||
documentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: RecipientExpiredEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`The signing window for "${recipientName}" on document "${documentName}" has expired.`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateRecipientExpired
|
||||
documentName={documentName}
|
||||
recipientName={recipientName}
|
||||
recipientEmail={recipientEmail}
|
||||
documentLink={documentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipientExpiredTemplate;
|
||||
66
packages/lib/constants/envelope-expiration.ts
Normal file
66
packages/lib/constants/envelope-expiration.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { DurationLikeObject } from 'luxon';
|
||||
import { Duration } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeExpirationDurationPeriod = z.object({
|
||||
unit: z.enum(['day', 'week', 'month', 'year']),
|
||||
amount: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const ZEnvelopeExpirationDisabledPeriod = z.object({
|
||||
disabled: z.literal(true),
|
||||
});
|
||||
|
||||
export const ZEnvelopeExpirationPeriod = z.union([
|
||||
ZEnvelopeExpirationDurationPeriod,
|
||||
ZEnvelopeExpirationDisabledPeriod,
|
||||
]);
|
||||
|
||||
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
|
||||
export type TEnvelopeExpirationDurationPeriod = z.infer<typeof ZEnvelopeExpirationDurationPeriod>;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<
|
||||
TEnvelopeExpirationDurationPeriod['unit'],
|
||||
keyof DurationLikeObject
|
||||
> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
month: 'months',
|
||||
year: 'years',
|
||||
};
|
||||
|
||||
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationDurationPeriod = {
|
||||
unit: 'month',
|
||||
amount: 3,
|
||||
};
|
||||
|
||||
export const getEnvelopeExpirationDuration = (
|
||||
period: TEnvelopeExpirationDurationPeriod,
|
||||
): Duration => {
|
||||
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the concrete expiresAt timestamp from a raw expiration period (from JSON column).
|
||||
*
|
||||
* - `null` means use the default period (3 months).
|
||||
* - `{ disabled: true }` means never expires (returns null).
|
||||
* - `{ unit, amount }` means compute the timestamp from now + duration.
|
||||
*/
|
||||
export const resolveExpiresAt = (rawPeriod: unknown): Date | null => {
|
||||
if (rawPeriod === null || rawPeriod === undefined) {
|
||||
const duration = getEnvelopeExpirationDuration(DEFAULT_ENVELOPE_EXPIRATION_PERIOD);
|
||||
|
||||
return new Date(Date.now() + duration.toMillis());
|
||||
}
|
||||
|
||||
const parsed = ZEnvelopeExpirationPeriod.parse(rawPeriod);
|
||||
|
||||
if ('disabled' in parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = getEnvelopeExpirationDuration(parsed);
|
||||
|
||||
return new Date(Date.now() + duration.toMillis());
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ export enum AppErrorCode {
|
|||
'EXPIRED_CODE' = 'EXPIRED_CODE',
|
||||
'INVALID_BODY' = 'INVALID_BODY',
|
||||
'INVALID_REQUEST' = 'INVALID_REQUEST',
|
||||
'RECIPIENT_EXPIRED' = 'RECIPIENT_EXPIRED',
|
||||
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
|
||||
'NOT_FOUND' = 'NOT_FOUND',
|
||||
'NOT_SETUP' = 'NOT_SETUP',
|
||||
|
|
@ -23,6 +24,7 @@ export enum AppErrorCode {
|
|||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
||||
{
|
||||
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_EXPIRED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
|||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
|
|
@ -11,6 +12,8 @@ import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
|||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
/**
|
||||
|
|
@ -28,9 +31,12 @@ export const jobsClient = new JobClient([
|
|||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
|
||||
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
|
|||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
/**
|
||||
* Optional cron expression (e.g., "* * * * *" for every minute).
|
||||
* When set, the job runs on a schedule instead of being event-triggered.
|
||||
*/
|
||||
cron?: string;
|
||||
};
|
||||
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,4 +16,14 @@ export abstract class BaseJobProvider {
|
|||
public getApiHandler(): (req: HonoContext) => Promise<Response | void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron scheduler for any registered cron jobs.
|
||||
*
|
||||
* No-op for providers that handle cron scheduling externally (e.g. Inngest).
|
||||
* Must be called explicitly at application startup.
|
||||
*/
|
||||
public startCron(): void {
|
||||
// No-op by default — providers override if needed.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,15 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
|||
public getApiHandler() {
|
||||
return this._provider.getApiHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron scheduler for any registered cron jobs.
|
||||
*
|
||||
* Call this once at application startup after the instance is ready to
|
||||
* process requests. No-op for providers that handle cron externally
|
||||
* (e.g. Inngest).
|
||||
*/
|
||||
public startCron() {
|
||||
this._provider.startCron();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,16 +35,17 @@ export class InngestJobProvider extends BaseJobProvider {
|
|||
}
|
||||
|
||||
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
|
||||
console.log('defining job', job.id);
|
||||
const triggerConfig: { cron: string } | { event: N } = job.trigger.cron
|
||||
? { cron: job.trigger.cron }
|
||||
: { event: job.trigger.name };
|
||||
|
||||
const fn = this._client.createFunction(
|
||||
{
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
optimizeParallelism: job.optimizeParallelism ?? false,
|
||||
},
|
||||
{
|
||||
event: job.trigger.name,
|
||||
},
|
||||
triggerConfig,
|
||||
async (ctx) => {
|
||||
const io = this.convertInngestIoToJobRunIo(ctx);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { BackgroundJobStatus, Prisma } from '@prisma/client';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import type { Context as HonoContext } from 'hono';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
|
@ -16,10 +17,33 @@ import {
|
|||
import type { Json } from './_internal/json';
|
||||
import { BaseJobProvider } from './base';
|
||||
|
||||
/**
|
||||
* Build a deterministic BackgroundJob ID for a cron run so that multiple
|
||||
* instances of the local provider racing to enqueue the same slot will
|
||||
* collide on the primary key instead of creating duplicates.
|
||||
*/
|
||||
const createCronRunId = (jobId: string, scheduledFor: Date): string => {
|
||||
const key = `cron:${jobId}:${scheduledFor.toISOString()}`;
|
||||
const hash = Buffer.from(sha256(key)).toString('hex').slice(0, 24);
|
||||
|
||||
return `cron_${hash}`;
|
||||
};
|
||||
|
||||
type CronJobEntry = {
|
||||
definition: JobDefinition;
|
||||
cron: string;
|
||||
lastTickAt: Date;
|
||||
};
|
||||
|
||||
const CRON_POLL_INTERVAL_MS = 30_000; // 30 seconds
|
||||
const CRON_POLL_JITTER_MS = 5_000; // 0-5 seconds random offset
|
||||
|
||||
export class LocalJobProvider extends BaseJobProvider {
|
||||
private static _instance: LocalJobProvider;
|
||||
|
||||
private _jobDefinitions: Record<string, JobDefinition> = {};
|
||||
private _cronJobs: CronJobEntry[] = [];
|
||||
private _cronPoller: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
|
|
@ -38,6 +62,133 @@ export class LocalJobProvider extends BaseJobProvider {
|
|||
...definition,
|
||||
enabled: definition.enabled ?? true,
|
||||
};
|
||||
|
||||
if (definition.trigger.cron && definition.enabled !== false) {
|
||||
const alreadyRegistered = this._cronJobs.some((job) => job.definition.id === definition.id);
|
||||
|
||||
if (!alreadyRegistered) {
|
||||
this._cronJobs.push({
|
||||
definition: {
|
||||
...definition,
|
||||
enabled: definition.enabled ?? true,
|
||||
},
|
||||
cron: definition.trigger.cron,
|
||||
lastTickAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the single cron poller for all registered cron jobs.
|
||||
*
|
||||
* Must be called explicitly at application startup after all jobs have been
|
||||
* defined. The poller runs every 30 seconds (+ random jitter to avoid
|
||||
* thundering herd across instances) and evaluates all registered cron jobs
|
||||
* for due slots.
|
||||
*
|
||||
* For each due slot it creates a BackgroundJob row with a deterministic ID.
|
||||
* If the insert succeeds the job is dispatched; if it fails with a unique
|
||||
* constraint violation (P2002) another instance already claimed that slot.
|
||||
*/
|
||||
public override startCron() {
|
||||
if (this._cronPoller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._cronJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const jitter = Math.floor(Math.random() * CRON_POLL_JITTER_MS);
|
||||
|
||||
this._cronPoller = setTimeout(() => {
|
||||
void this.processCronTick().finally(tick);
|
||||
}, CRON_POLL_INTERVAL_MS + jitter);
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
console.log(`[JOBS]: Started cron poller for ${this._cronJobs.length} job(s)`);
|
||||
}
|
||||
|
||||
private async processCronTick() {
|
||||
for (const cronJob of this._cronJobs) {
|
||||
try {
|
||||
const dueSlots = this.getDueCronSlots(cronJob);
|
||||
|
||||
cronJob.lastTickAt = new Date();
|
||||
|
||||
if (dueSlots.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only take the latest slot — sweep-style jobs don't need to catch up
|
||||
// every missed slot after downtime, just the most recent one.
|
||||
const scheduledFor = dueSlots[dueSlots.length - 1];
|
||||
const deterministicId = createCronRunId(cronJob.definition.id, scheduledFor);
|
||||
|
||||
const pendingJob = await prisma.backgroundJob
|
||||
.create({
|
||||
data: {
|
||||
id: deterministicId,
|
||||
jobId: cronJob.definition.id,
|
||||
name: cronJob.definition.name,
|
||||
version: cronJob.definition.version,
|
||||
payload: { scheduledFor: scheduledFor.toISOString() },
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// P2002 = unique constraint violation — another instance already enqueued this slot.
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!pendingJob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.submitJobToEndpoint({
|
||||
jobId: pendingJob.id,
|
||||
jobDefinitionId: pendingJob.jobId,
|
||||
data: {
|
||||
name: cronJob.definition.trigger.name,
|
||||
payload: { scheduledFor: scheduledFor.toISOString() },
|
||||
},
|
||||
isRetry: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[JOBS]: Cron tick failed for ${cronJob.definition.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use cron-parser to find all cron slots that are due between the last tick
|
||||
* and now.
|
||||
*/
|
||||
private getDueCronSlots(cronJob: CronJobEntry): Date[] {
|
||||
const expr = CronExpressionParser.parse(cronJob.cron, {
|
||||
currentDate: cronJob.lastTickAt,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const slots: Date[] = [];
|
||||
|
||||
let next = expr.next();
|
||||
|
||||
while (next.toDate() <= now) {
|
||||
slots.push(next.toDate());
|
||||
next = expr.next();
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
public async triggerJob(options: SimpleTriggerJobOptions) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../../utils/teams';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendOwnerRecipientExpiredEmailJobDefinition } from './send-owner-recipient-expired-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendOwnerRecipientExpiredEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { recipientId, envelopeId } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error(`Envelope ${envelopeId} not found`);
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Recipient ${recipientId} not found on envelope ${envelopeId}`);
|
||||
}
|
||||
|
||||
const { documentMeta, user: documentOwner } = envelope;
|
||||
|
||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).ownerRecipientExpired;
|
||||
|
||||
if (!isEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`;
|
||||
|
||||
const template = createElement(RecipientExpiredTemplate, {
|
||||
documentName: envelope.title,
|
||||
recipientName: recipient.name || recipient.email,
|
||||
recipientEmail: recipient.email,
|
||||
documentLink,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
await io.runTask('send-owner-recipient-expired-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: documentOwner.name || '',
|
||||
address: documentOwner.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(
|
||||
msg`Signing window expired for "${recipient.name || recipient.email}" on "${envelope.title}"`,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID = 'send.owner.recipient.expired.email';
|
||||
|
||||
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
recipientId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export type TSendOwnerRecipientExpiredEmailJobDefinition = z.infer<
|
||||
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Owner Recipient Expired Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-owner-recipient-expired-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendOwnerRecipientExpiredEmailJobDefinition
|
||||
>;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TExpireRecipientsSweepJobDefinition } from './expire-recipients-sweep';
|
||||
|
||||
export const run = async ({
|
||||
io,
|
||||
}: {
|
||||
payload: TExpireRecipientsSweepJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
|
||||
const expiredRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: now,
|
||||
},
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: {
|
||||
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
|
||||
},
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
take: 1000, // Limit to 1000 to avoid long-running jobs. Will be picked up in the next run if there are more.
|
||||
});
|
||||
|
||||
if (expiredRecipients.length === 0) {
|
||||
io.logger.info('No expired recipients found');
|
||||
return;
|
||||
}
|
||||
|
||||
io.logger.info(`Found ${expiredRecipients.length} expired recipients`);
|
||||
|
||||
await Promise.allSettled(
|
||||
expiredRecipients.map(async (recipient) => {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.process-recipient-expired',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID = 'internal.expire-recipients-sweep';
|
||||
|
||||
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TExpireRecipientsSweepJobDefinition = z.infer<
|
||||
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION = {
|
||||
id: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
name: 'Expire Recipients Sweep',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
schema: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./expire-recipients-sweep.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
TExpireRecipientsSweepJobDefinition
|
||||
>;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../../types/webhook-payload';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TProcessRecipientExpiredJobDefinition } from './process-recipient-expired';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TProcessRecipientExpiredJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { recipientId } = payload;
|
||||
|
||||
// Atomic idempotency guard — only one concurrent worker wins.
|
||||
// Wrapping in runTask caches the result so that on retry the claim is not
|
||||
// re-evaluated and subsequent steps can still proceed.
|
||||
const claimedCount = await io.runTask('claim-recipient', async () => {
|
||||
const result = await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipientId,
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
|
||||
},
|
||||
data: { expirationNotifiedAt: new Date() },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
if (claimedCount === 0) {
|
||||
io.logger.info(`Recipient ${recipientId} already processed or no longer eligible, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch recipient (now marked) with its envelope for downstream steps.
|
||||
// Re-fetch after claiming so that expirationNotifiedAt reflects the updated value
|
||||
// and webhook consumers see consistent state.
|
||||
const recipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipientId },
|
||||
include: {
|
||||
envelope: {
|
||||
include: { recipients: true, documentMeta: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { envelope } = recipient;
|
||||
|
||||
io.logger.info(
|
||||
`Recipient ${recipientId} (${recipient.email}) expired on envelope ${recipient.envelopeId}`,
|
||||
);
|
||||
|
||||
// Create audit log entry — wrapped so a retry skips this if it already succeeded.
|
||||
await io.runTask('create-audit-log', async () => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger webhook for recipient expiration.
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.RECIPIENT_EXPIRED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
// Trigger email notification to the document owner.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.owner.recipient.expired.email',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID = 'internal.process-recipient-expired';
|
||||
|
||||
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TProcessRecipientExpiredJobDefinition = z.infer<
|
||||
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION = {
|
||||
id: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
name: 'Process Recipient Expired',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
schema: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./process-recipient-expired.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
TProcessRecipientExpiredJobDefinition
|
||||
>;
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
|
@ -94,6 +95,8 @@ export const completeDocumentWithToken = async ({
|
|||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
|
@ -9,6 +9,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
|||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
|
||||
export type RejectDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
|
|
@ -42,6 +43,14 @@ export async function rejectDocumentWithToken({
|
|||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} must be pending to reject`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Update the recipient status to rejected
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
|
|
@ -94,11 +95,31 @@ export const resendDocument = async ({
|
|||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
// Refresh the expiresAt on each resent recipient.
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
|
||||
recipients.includes(recipient.id) &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC,
|
||||
);
|
||||
|
||||
// Extend the expiration deadline for recipients being resent.
|
||||
if (expiresAt && recipientsToRemind.length > 0) {
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: recipientsToRemind.map((r) => r.id),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
expiresAt,
|
||||
expirationNotifiedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
|
|
@ -257,6 +258,28 @@ export const sendDocument = async ({
|
|||
});
|
||||
}
|
||||
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
// Set expiresAt on each recipient that hasn't already signed/rejected.
|
||||
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
|
||||
if (expiresAt) {
|
||||
await tx.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: {
|
||||
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
|
||||
},
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
expiresAt,
|
||||
expirationNotifiedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
|||
isRecipientsTurn: true,
|
||||
isCompleted: false,
|
||||
isRejected: false,
|
||||
isExpired: false,
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
|||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
|
||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||
import { isRecipientExpired } from '../../utils/recipients';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
|
|
@ -56,7 +57,9 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, //!: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
|
|
@ -102,7 +105,8 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
token: true,
|
||||
|
|
@ -126,6 +130,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||
|
||||
isCompleted: z.boolean(),
|
||||
isRejected: z.boolean(),
|
||||
isExpired: z.boolean(),
|
||||
isRecipientsTurn: z.boolean(),
|
||||
|
||||
sender: z.object({
|
||||
|
|
@ -291,6 +296,7 @@ export const getEnvelopeForRecipientSigning = async ({
|
|||
isRejected:
|
||||
recipient.signingStatus === SigningStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.REJECTED,
|
||||
isExpired: isRecipientExpired(recipient),
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
|||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
|
|
@ -56,6 +57,8 @@ export const removeSignedFieldWithToken = async ({
|
|||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (
|
||||
recipient?.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
|
|
@ -108,6 +109,8 @@ export const signFieldWithToken = async ({
|
|||
throw new Error(`Document ${envelope.id} must be pending for signing`);
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
|||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
|
||||
|
|
@ -119,6 +120,7 @@ export type CreateDocumentFromTemplateOptions = {
|
|||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
formValues?: TDocumentFormValues;
|
||||
|
|
@ -521,6 +523,8 @@ export const createDocumentFromTemplate = async ({
|
|||
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner:
|
||||
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod:
|
||||
override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'John Doe',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
|
|
@ -81,7 +82,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'John Doe',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
|
|
@ -120,7 +122,8 @@ export const generateSampleWebhookPayload = (
|
|||
role: RecipientRole.VIEWER,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
|
|
@ -139,7 +142,8 @@ export const generateSampleWebhookPayload = (
|
|||
role: RecipientRole.SIGNER,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
|
|
@ -168,7 +172,8 @@ export const generateSampleWebhookPayload = (
|
|||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
|
|
@ -185,7 +190,8 @@ export const generateSampleWebhookPayload = (
|
|||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
|
|
@ -222,7 +228,8 @@ export const generateSampleWebhookPayload = (
|
|||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
|
|
@ -243,7 +250,8 @@ export const generateSampleWebhookPayload = (
|
|||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
|
|
@ -270,7 +278,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer 2',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -291,7 +300,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -314,7 +324,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer 2',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -335,7 +346,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -374,7 +386,8 @@ export const generateSampleWebhookPayload = (
|
|||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
|
|
@ -391,7 +404,8 @@ export const generateSampleWebhookPayload = (
|
|||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
|
|
@ -425,6 +439,7 @@ export const generateSampleWebhookPayload = (
|
|||
recipientRemoved: true,
|
||||
documentCompleted: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
recipientSigningRequest: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -437,7 +452,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -460,7 +476,8 @@ export const generateSampleWebhookPayload = (
|
|||
name: 'Signer',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
|
|
@ -480,5 +497,53 @@ export const generateSampleWebhookPayload = (
|
|||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.RECIPIENT_EXPIRED) {
|
||||
const expiresAt = new Date(now.getTime() - 60 * 1000); // Expired 1 minute ago
|
||||
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
status: DocumentStatus.PENDING,
|
||||
recipients: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
email: 'signer1@documenso.com',
|
||||
name: 'Signer 1',
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expiresAt,
|
||||
expirationNotifiedAt: now,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
Recipient: [
|
||||
{
|
||||
...basePayload.Recipient[0],
|
||||
email: 'signer1@documenso.com',
|
||||
name: 'Signer 1',
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expiresAt,
|
||||
expirationNotifiedAt: now,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported event type: ${event}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ export const listDocumentsHandler = async (req: Request) => {
|
|||
name: recipient.name,
|
||||
token: recipient.token,
|
||||
documentDeletedAt: recipient.documentDeletedAt,
|
||||
expired: recipient.expired,
|
||||
expired: recipient.expired, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: recipient.expiresAt,
|
||||
expirationNotifiedAt: recipient.expirationNotifiedAt,
|
||||
signedAt: recipient.signedAt,
|
||||
authOptions: recipient.authOptions,
|
||||
signingOrder: recipient.signingOrder,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||
'DOCUMENT_VIEWED', // When the document is viewed by a recipient.
|
||||
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
|
||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||
'DOCUMENT_RECIPIENT_EXPIRED', // When a recipient's signing window expires.
|
||||
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
|
|
@ -694,6 +695,18 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
|
|||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's signing window expired.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
|
||||
data: z.object({
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
|
|
@ -739,6 +752,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export enum DocumentEmailEvents {
|
|||
DocumentCompleted = 'documentCompleted',
|
||||
DocumentDeleted = 'documentDeleted',
|
||||
OwnerDocumentCompleted = 'ownerDocumentCompleted',
|
||||
OwnerRecipientExpired = 'ownerRecipientExpired',
|
||||
}
|
||||
|
||||
export const ZDocumentEmailSettingsSchema = z
|
||||
|
|
@ -52,6 +53,12 @@ export const ZDocumentEmailSettingsSchema = z
|
|||
.boolean()
|
||||
.describe('Whether to send an email to the document owner when the document is complete.')
|
||||
.default(true),
|
||||
ownerRecipientExpired: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether to send an email to the document owner when a recipient's signing window has expired.",
|
||||
)
|
||||
.default(true),
|
||||
})
|
||||
.strip()
|
||||
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
|
||||
|
|
@ -78,6 +85,7 @@ export const extractDerivedDocumentEmailSettings = (
|
|||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
|
||||
ownerRecipientExpired: emailSettings.ownerRecipientExpired,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -89,4 +97,5 @@ export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
|
|||
documentCompleted: true,
|
||||
documentDeleted: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client
|
|||
import { z } from 'zod';
|
||||
|
||||
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
|
|
@ -128,6 +129,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
|||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
|||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
}).extend({
|
||||
password: z.string().nullable().default(null),
|
||||
documentId: z.number().default(-1).optional(),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
|||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
}),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
fields: ZEnvelopeFieldSchema.array(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
|||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
|
|
@ -50,7 +52,9 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
|||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
|
|
@ -75,7 +79,9 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
|||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export const ZWebhookRecipientSchema = z.object({
|
|||
name: z.string(),
|
||||
token: z.string(),
|
||||
documentDeletedAt: z.date().nullable(),
|
||||
expired: z.date().nullable(),
|
||||
expiresAt: z.date().nullable(),
|
||||
expirationNotifiedAt: z.date().nullable(),
|
||||
signedAt: z.date().nullable(),
|
||||
authOptions: z.any().nullable(),
|
||||
signingOrder: z.number().nullable(),
|
||||
|
|
@ -116,7 +117,8 @@ export const mapEnvelopeToWebhookDocumentPayload = (
|
|||
name: recipient.name,
|
||||
token: recipient.token,
|
||||
documentDeletedAt: recipient.documentDeletedAt,
|
||||
expired: recipient.expired,
|
||||
expiresAt: recipient.expiresAt,
|
||||
expirationNotifiedAt: recipient.expirationNotifiedAt,
|
||||
signedAt: recipient.signedAt,
|
||||
authOptions: recipient.authOptions,
|
||||
signingOrder: recipient.signingOrder,
|
||||
|
|
|
|||
|
|
@ -571,6 +571,14 @@ export const formatDocumentAuditLogAction = (
|
|||
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Recipient signing window expired`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
|
||||
user: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ export const extractDerivedDocumentMeta = (
|
|||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||
emailSettings:
|
||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
|
||||
// Envelope expiration.
|
||||
envelopeExpirationPeriod:
|
||||
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
|
||||
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
|
|
@ -138,6 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
|||
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
|
||||
defaultRecipients: null,
|
||||
|
||||
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { z } from 'zod';
|
|||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
/**
|
||||
|
|
@ -94,3 +95,23 @@ export const mapRecipientToLegacyRecipient = (
|
|||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
||||
return z.string().email().safeParse(recipient.email).success;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the recipient's signing window has expired.
|
||||
*/
|
||||
export const isRecipientExpired = (recipient: { expiresAt: Date | null }) => {
|
||||
return Boolean(recipient.expiresAt && new Date(recipient.expiresAt) <= new Date());
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that the recipient's signing window has not expired.
|
||||
*
|
||||
* Throws an AppError with RECIPIENT_EXPIRED if the expiration date has passed.
|
||||
*/
|
||||
export const assertRecipientNotExpired = (recipient: { expiresAt: Date | null }) => {
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
|
||||
message: 'Recipient signing window has expired',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||
// emailReplyToName: null,
|
||||
|
||||
defaultRecipients: null,
|
||||
|
||||
envelopeExpirationPeriod: null,
|
||||
|
||||
aiFeaturesEnabled: null,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECIPIENT_EXPIRED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "envelopeExpirationPeriod" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "envelopeExpirationPeriod" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recipient" ADD COLUMN "expirationNotifiedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "expiresAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "envelopeExpirationPeriod" JSONB;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_expiresAt_idx" ON "Recipient"("expiresAt");
|
||||
|
|
@ -172,6 +172,7 @@ enum WebhookTriggerEvents {
|
|||
DOCUMENT_COMPLETED
|
||||
DOCUMENT_REJECTED
|
||||
DOCUMENT_CANCELLED
|
||||
RECIPIENT_EXPIRED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
|
@ -500,7 +501,7 @@ enum DocumentDistributionMethod {
|
|||
NONE
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
model DocumentMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
|
|
@ -522,6 +523,8 @@ model DocumentMeta {
|
|||
emailReplyTo String?
|
||||
emailId String?
|
||||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
envelope Envelope?
|
||||
}
|
||||
|
||||
|
|
@ -569,29 +572,32 @@ enum RecipientRole {
|
|||
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
model Recipient {
|
||||
id Int @id @default(autoincrement())
|
||||
envelopeId String
|
||||
email String @db.VarChar(255)
|
||||
name String @default("") @db.VarChar(255)
|
||||
token String
|
||||
documentDeletedAt DateTime?
|
||||
expired DateTime?
|
||||
signedAt DateTime?
|
||||
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
|
||||
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
|
||||
rejectionReason String?
|
||||
role RecipientRole @default(SIGNER)
|
||||
readStatus ReadStatus @default(NOT_OPENED)
|
||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||
sendStatus SendStatus @default(NOT_SENT)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
id Int @id @default(autoincrement())
|
||||
envelopeId String
|
||||
email String @db.VarChar(255)
|
||||
name String @default("") @db.VarChar(255)
|
||||
token String
|
||||
documentDeletedAt DateTime?
|
||||
expired DateTime? // deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt DateTime?
|
||||
expirationNotifiedAt DateTime?
|
||||
signedAt DateTime?
|
||||
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
|
||||
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
|
||||
rejectionReason String?
|
||||
role RecipientRole @default(SIGNER)
|
||||
readStatus ReadStatus @default(NOT_OPENED)
|
||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||
sendStatus SendStatus @default(NOT_SENT)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@index([signedAt])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
|
|
@ -808,7 +814,7 @@ enum OrganisationMemberInviteStatus {
|
|||
DECLINED
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
model OrganisationGlobalSettings {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
|
|
@ -840,11 +846,13 @@ model OrganisationGlobalSettings {
|
|||
brandingUrl String @default("")
|
||||
brandingCompanyDetails String @default("")
|
||||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
// AI features settings.
|
||||
aiFeaturesEnabled Boolean @default(false)
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
model TeamGlobalSettings {
|
||||
id String @id
|
||||
team Team?
|
||||
|
|
@ -877,6 +885,8 @@ model TeamGlobalSettings {
|
|||
brandingUrl String?
|
||||
brandingCompanyDetails String?
|
||||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
// AI features settings.
|
||||
aiFeaturesEnabled Boolean?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
import {
|
||||
|
|
@ -97,6 +98,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
|||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
.describe('Override values from the template for the created document.')
|
||||
.optional(),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||
drawSignatureEnabled,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
@ -148,6 +149,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||
drawSignatureEnabled,
|
||||
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||
envelopeExpirationPeriod:
|
||||
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
|
|
@ -25,6 +26,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||
drawSignatureEnabled: z.boolean().optional(),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
@ -154,6 +155,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod:
|
||||
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
|
|
@ -28,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||
uploadSignatureEnabled: z.boolean().nullish(),
|
||||
drawSignatureEnabled: z.boolean().nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled: z.boolean().nullish(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/
|
|||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZDocumentSchema } from '@documenso/lib/types/document';
|
||||
import {
|
||||
ZDocumentAccessAuthTypesSchema,
|
||||
|
|
@ -160,6 +161,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
|||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
.describe('Override values from the template for the created document.')
|
||||
.optional(),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.RecipientSigned}
|
||||
>
|
||||
<Trans>Email the owner when a recipient signs</Trans>
|
||||
|
|
@ -44,7 +44,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Recipient signed email</Trans>
|
||||
|
|
@ -72,7 +72,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
|
||||
>
|
||||
<Trans>Email recipients with a signing request</Trans>
|
||||
|
|
@ -82,7 +82,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Recipient signing request email</Trans>
|
||||
|
|
@ -110,7 +110,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.RecipientRemoved}
|
||||
>
|
||||
<Trans>Email recipients when they're removed from a pending document</Trans>
|
||||
|
|
@ -120,7 +120,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Recipient removed email</Trans>
|
||||
|
|
@ -148,7 +148,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.DocumentPending}
|
||||
>
|
||||
<Trans>Email the signer if the document is still pending</Trans>
|
||||
|
|
@ -158,7 +158,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document pending email</Trans>
|
||||
|
|
@ -187,7 +187,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.DocumentCompleted}
|
||||
>
|
||||
<Trans>Email recipients when the document is completed</Trans>
|
||||
|
|
@ -197,7 +197,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document completed email</Trans>
|
||||
|
|
@ -225,7 +225,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.DocumentDeleted}
|
||||
>
|
||||
<Trans>Email recipients when a pending document is deleted</Trans>
|
||||
|
|
@ -235,7 +235,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document deleted email</Trans>
|
||||
|
|
@ -263,7 +263,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.OwnerDocumentCompleted}
|
||||
>
|
||||
<Trans>Email the owner when the document is completed</Trans>
|
||||
|
|
@ -273,7 +273,7 @@ export const DocumentEmailCheckboxes = ({
|
|||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document completed email</Trans>
|
||||
|
|
@ -290,6 +290,45 @@ export const DocumentEmailCheckboxes = ({
|
|||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id={DocumentEmailEvents.OwnerRecipientExpired}
|
||||
className="h-5 w-5"
|
||||
checked={value.ownerRecipientExpired}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...value, [DocumentEmailEvents.OwnerRecipientExpired]: Boolean(checked) })
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={DocumentEmailEvents.OwnerRecipientExpired}
|
||||
>
|
||||
<Trans>Send recipient expired email to the owner</Trans>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Recipient expired email</Trans>
|
||||
</strong>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
This will be sent to the document owner when a recipient's signing window has
|
||||
expired.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
153
packages/ui/components/document/expiration-period-picker.tsx
Normal file
153
packages/ui/components/document/expiration-period-picker.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import type {
|
||||
TEnvelopeExpirationDurationPeriod,
|
||||
TEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
const EXPIRATION_UNITS: Array<{
|
||||
value: TEnvelopeExpirationDurationPeriod['unit'];
|
||||
label: MessageDescriptor;
|
||||
}> = [
|
||||
{ value: 'day', label: msg`Days` },
|
||||
{ value: 'week', label: msg`Weeks` },
|
||||
{ value: 'month', label: msg`Months` },
|
||||
{ value: 'year', label: msg`Years` },
|
||||
];
|
||||
|
||||
type ExpirationMode = 'duration' | 'disabled' | 'inherit';
|
||||
|
||||
const getMode = (value: TEnvelopeExpirationPeriod | null | undefined): ExpirationMode => {
|
||||
if (!value) {
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
if ('disabled' in value) {
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
return 'duration';
|
||||
};
|
||||
|
||||
const getAmount = (value: TEnvelopeExpirationPeriod | null | undefined): number => {
|
||||
if (value && 'amount' in value) {
|
||||
return value.amount;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const getUnit = (
|
||||
value: TEnvelopeExpirationPeriod | null | undefined,
|
||||
): TEnvelopeExpirationDurationPeriod['unit'] => {
|
||||
if (value && 'unit' in value) {
|
||||
return value.unit;
|
||||
}
|
||||
|
||||
return 'month';
|
||||
};
|
||||
|
||||
export type ExpirationPeriodPickerProps = {
|
||||
value: TEnvelopeExpirationPeriod | null | undefined;
|
||||
onChange: (value: TEnvelopeExpirationPeriod | null) => void;
|
||||
disabled?: boolean;
|
||||
inheritLabel?: string;
|
||||
};
|
||||
|
||||
export const ExpirationPeriodPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
inheritLabel,
|
||||
}: ExpirationPeriodPickerProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const mode = getMode(value);
|
||||
const amount = getAmount(value);
|
||||
const unit = getUnit(value);
|
||||
|
||||
const onModeChange = (newMode: string) => {
|
||||
if (newMode === 'inherit') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === 'disabled') {
|
||||
onChange({ disabled: true });
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ unit, amount });
|
||||
};
|
||||
|
||||
const onAmountChange = (newAmount: number) => {
|
||||
const clamped = Math.max(1, Math.floor(newAmount));
|
||||
|
||||
onChange({ unit, amount: clamped });
|
||||
};
|
||||
|
||||
const onUnitChange = (newUnit: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onChange({ unit: newUnit as TEnvelopeExpirationDurationPeriod['unit'], amount });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="duration">
|
||||
<Trans>Custom duration</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="disabled">
|
||||
<Trans>Never expires</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{mode === 'duration' && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-20 bg-background"
|
||||
value={amount}
|
||||
onChange={(e) => onAmountChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
|
||||
<SelectTrigger className="flex-1 bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{EXPIRATION_UNITS.map((u) => (
|
||||
<SelectItem key={u.value} value={u.value}>
|
||||
{_(u.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue