feat: signing reminders (#1749)

This commit is contained in:
Ephraim Duncan
2026-04-14 11:01:53 +00:00
committed by GitHub
parent 6d7bd212bf
commit 4935f387bf
69 changed files with 1426 additions and 156 deletions
@@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -69,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
// Only set sentAt if not already set (email may have been sent before they opened).
...(!recipient.sentAt ? { sentAt: new Date() } : {}),
},
});
@@ -92,6 +94,9 @@ export const viewedDocument = async ({
});
});
// Don't schedule reminders for manually distributed documents —
// there's no email pathway to send them through.
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
@@ -18,6 +18,7 @@ import {
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
@@ -354,6 +355,11 @@ export const updateEnvelope = async ({
return result;
});
// Recompute reminders for active recipients when reminder settings change.
if (meta && 'reminderSettings' in meta) {
await recomputeNextReminderForEnvelope(envelope.id);
}
if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
@@ -0,0 +1,112 @@
import {
DocumentDistributionMethod,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import {
ZEnvelopeReminderSettings,
resolveNextReminderAt,
} from '../../constants/envelope-reminder';
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
let settings = options.reminderSettings;
if (settings === undefined) {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: { documentMeta: { select: { reminderSettings: true } } },
});
settings = envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt,
lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
});
};
/**
* Recompute `nextReminderAt` for all active (unsigned, sent) recipients
* of a given envelope. Call when document-level reminder settings change.
*/
export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
documentMeta: {
select: { reminderSettings: true, distributionMethod: true },
},
},
});
// No reminders for manually distributed documents.
const isEmailDistribution =
envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
const settings =
isEmailDistribution && envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
const recipients = await prisma.recipient.findMany({
where: {
envelopeId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
sentAt: { not: null },
role: { not: RecipientRole.CC },
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
});
await Promise.all(
recipients.map(async (recipient) => {
if (!recipient.sentAt) {
return;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipient.id },
data: { nextReminderAt },
});
}),
);
};
@@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,