feat: per-recipient envelope expiration (#2519)

This commit is contained in:
Lucas Smith
2026-02-20 11:36:20 +11:00
committed by GitHub
parent f3ec8ddc57
commit 006b1d0a57
70 changed files with 2705 additions and 93 deletions
+4 -3
View File
@@ -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;
@@ -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;
@@ -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());
};
+2
View File
@@ -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 },
+6
View File
@@ -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>;
};
+10
View File
@@ -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.
}
}
+11
View File
@@ -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();
}
}
+5 -4
View File
@@ -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);
+152 -1
View File
@@ -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,
+14
View File
@@ -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,
]),
);
+9
View File
@@ -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,
};
+2
View File
@@ -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>;
+1
View File
@@ -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(),
+1
View File
@@ -55,6 +55,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
+9 -3
View File
@@ -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,
+4 -2
View File
@@ -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}`,
+4
View File
@@ -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'>;
};
+4
View File
@@ -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,
};
};
+21
View File
@@ -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',
});
}
};
+3
View File
@@ -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");
+31 -21
View File
@@ -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>
);
};
@@ -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>
);
};