Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-24 18:51:04 +11:00
179 changed files with 14753 additions and 3181 deletions
@@ -0,0 +1,69 @@
import * as React from 'react';
import type { Dispatch, SetStateAction } from 'react';
function dispatchStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
}
const setSessionStorageItem = <T>(key: string, value: T) => {
const stringifiedValue = JSON.stringify(value);
window.sessionStorage.setItem(key, stringifiedValue);
dispatchStorageEvent(key, stringifiedValue);
};
const removeSessionStorageItem = (key: string) => {
window.sessionStorage.removeItem(key);
dispatchStorageEvent(key, null);
};
const getSessionStorageItem = (key: string) => {
return window.sessionStorage.getItem(key);
};
const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
export function useSessionStorage<T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const serializedInitialValue = JSON.stringify(initialValue);
const getSnapshot = () => getSessionStorageItem(key);
const getServerSnapshot = () => serializedInitialValue;
const store = React.useSyncExternalStore(
useSessionStorageSubscribe,
getSnapshot,
getServerSnapshot,
);
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
(v) => {
try {
const prevValue = store ? JSON.parse(store) : initialValue;
// @ts-expect-error - SetStateAction function check is safe at runtime
const nextState = typeof v === 'function' ? v(prevValue) : v;
if (nextState === undefined || nextState === null) {
removeSessionStorageItem(key);
} else {
setSessionStorageItem(key, nextState);
}
} catch (e) {
console.warn(e);
}
},
[key, store, initialValue],
);
React.useEffect(() => {
if (getSessionStorageItem(key) === null && typeof initialValue !== 'undefined') {
setSessionStorageItem(key, initialValue);
}
}, [key, initialValue]);
return [store ? JSON.parse(store) : initialValue, setState];
}
-2
View File
@@ -8,8 +8,6 @@ export const DOCUMENSO_INTERNAL_EMAIL = {
address: FROM_ADDRESS,
};
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
export const EMAIL_VERIFICATION_STATE = {
NOT_FOUND: 'NOT_FOUND',
VERIFIED: 'VERIFIED',
@@ -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());
};
+10
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 },
@@ -62,6 +64,11 @@ type AppErrorOptions = {
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
/**
* Optional headers to include when this error is returned in an API response.
*/
headers?: Record<string, string>;
};
export class AppError extends Error {
@@ -80,6 +87,8 @@ export class AppError extends Error {
*/
statusCode?: number;
headers?: Record<string, string>;
name = 'AppError';
/**
@@ -95,6 +104,7 @@ export class AppError extends Error {
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
this.headers = options?.headers;
}
/**
+8
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';
@@ -10,7 +11,10 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
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 { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
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 +32,13 @@ 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,
CLEANUP_RATE_LIMITS_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,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
import type { TCleanupRateLimitsJobDefinition } from './cleanup-rate-limits';
const BATCH_SIZE = 10_000;
export const run = async ({ io }: { payload: TCleanupRateLimitsJobDefinition; io: JobRunIO }) => {
const cutoff = DateTime.now().minus({ hours: 24 }).toJSDate();
let totalDeleted = 0;
let deleted = 0;
do {
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
// to avoid long-running transactions that could lock the table.
deleted = await prisma.$executeRaw`
DELETE FROM "RateLimit"
WHERE ctid IN (
SELECT ctid FROM "RateLimit"
WHERE "createdAt" < ${cutoff}
LIMIT ${BATCH_SIZE}
)
`;
totalDeleted += deleted;
} while (deleted >= BATCH_SIZE);
if (totalDeleted > 0) {
io.logger.info(`Cleaned up ${totalDeleted} expired rate limit entries`);
} else {
io.logger.info('No expired rate limit entries to clean up');
}
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
export type TCleanupRateLimitsJobDefinition = z.infer<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
>;
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
name: 'Cleanup Rate Limits',
version: '1.0.0',
trigger: {
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-rate-limits.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
TCleanupRateLimitsJobDefinition
>;
@@ -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
>;
@@ -15,11 +15,11 @@ import { groupBy } from 'remeda';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-size';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { PDF_SIZE_A4_72PPI } from '../../../constants/pdf';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
@@ -29,7 +29,10 @@ import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import {
DOCUMENT_AUDIT_LOG_TYPE,
type TDocumentAuditLog,
} from '../../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@@ -169,56 +172,38 @@ export const run = async ({
});
}
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const envelopeCompletedAuditLog = createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
envelopeId: envelope.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
},
});
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
envelope,
recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients.
fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
};
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
// Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation.
// This is a temporary toggle while we validate the Konva-based approach.
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
// Pre-fetch all PDF data so we can read dimensions and pass it
// to decorateAndSignPdf without fetching again.
const prefetchedItems = await Promise.all(
envelopeItems.map(async (envelopeItem) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
const makeCertificatePdf = async () =>
usePlaywrightPdf
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload);
return { envelopeItem, pdfData };
}),
);
const makeAuditLogPdf = async () =>
usePlaywrightPdf
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload);
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
settings.includeSigningCertificate ? makeCertificatePdf() : null,
settings.includeAuditLog ? makeAuditLogPdf() : null,
]);
certificateDoc = createdCertificatePdf;
auditLogDoc = createdAuditLogPdf;
}
const needsCertificate = settings.includeSigningCertificate;
const needsAuditLog = settings.includeAuditLog;
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
for (const { envelopeItem, pdfData } of prefetchedItems) {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@@ -227,12 +212,70 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
if (needsCertificate || needsAuditLog) {
const pdfDoc = await PDF.load(pdfData);
const { width: pageWidth, height: pageHeight } = getLastPageDimensions(pdfDoc);
const additionalAuditLogs = [
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
{
...envelopeCompletedAuditLog,
id: '',
createdAt: new Date(),
} as TDocumentAuditLog,
];
const certificatePayload = {
envelope: {
...envelope,
status: finalEnvelopeStatus,
},
recipients: envelope.recipients,
fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelopeItems.map((item) => item.title),
pageWidth,
pageHeight,
additionalAuditLogs,
};
const makeCertificatePdf = async () =>
usePlaywrightPdf
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () =>
usePlaywrightPdf
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload);
[certificateDoc, auditLogDoc] = await Promise.all([
needsCertificate ? makeCertificatePdf() : null,
needsAuditLog ? makeAuditLogPdf() : null,
]);
}
const result = await decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
pdfData,
certificateDoc,
auditLogDoc,
});
@@ -258,22 +301,13 @@ export const run = async ({
id: envelope.id,
},
data: {
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
status: finalEnvelopeStatus,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
envelopeId: envelope.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
},
}),
data: envelopeCompletedAuditLog,
});
});
@@ -325,12 +359,13 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
pdfData: Uint8Array;
certificateDoc: PDF | null;
auditLogDoc: PDF | null;
};
/**
* Fetch, normalize, flatten and insert fields into a PDF document.
* Normalize, flatten and insert fields into a PDF document.
*/
const decorateAndSignPdf = async ({
envelope,
@@ -338,11 +373,10 @@ const decorateAndSignPdf = async ({
envelopeItemFields,
isRejected,
rejectionReason,
pdfData,
certificateDoc,
auditLogDoc,
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
let pdfDoc = await PDF.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -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
@@ -1,7 +1,7 @@
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
@@ -117,7 +117,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
@@ -1,6 +1,7 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
@@ -12,15 +13,24 @@ import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
envelopeItems: string[];
additionalAuditLogs?: TDocumentAuditLog[];
};
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
options;
const {
envelope,
envelopeOwner,
envelopeItems,
recipients,
language,
pageWidth,
pageHeight,
additionalAuditLogs = [],
} = options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
const [organisationClaim, partialAuditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getAuditLogs(envelope.id),
getTranslations(documentLanguage),
@@ -31,6 +41,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
messages,
});
const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs];
const auditLogPages = await renderAuditLogs({
envelope,
envelopeOwner,
@@ -16,7 +16,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c
import { renderCertificate } from './render-certificate';
export type GenerateCertificatePdfOptions = {
envelope: Envelope & {
/**
* Note: completedAt is not included since it's not real at this point in time.
*
* If we actually need it here in the future, we will need to preserve the
* completedAt value and pass it to the final `envelope.update` function when
* the document is initially sealed.
*/
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeOwner: {
@@ -1,4 +1,10 @@
import type { PDFPage } from '@cantoo/pdf-lib';
import type { PDF } from '@libpdf/core';
import { PDF_SIZE_A4_72PPI } from '../../constants/pdf';
const MIN_CERT_PAGE_WIDTH = 300;
const MIN_CERT_PAGE_HEIGHT = 300;
/**
* Gets the effective page size for PDF operations.
@@ -16,3 +22,20 @@ export const getPageSize = (page: PDFPage) => {
return cropBox;
};
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
if (!lastPage) {
return PDF_SIZE_A4_72PPI;
}
const width = Math.round(lastPage.width);
const height = Math.round(lastPage.height);
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
return PDF_SIZE_A4_72PPI;
}
return { width, height };
};
@@ -30,7 +30,7 @@ export type AuditLogRecipient = {
};
type GenerateAuditLogsOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -168,7 +168,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
};
type RenderOverviewCardOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -78,6 +78,7 @@ const getDevice = (userAgent?: string | null): string => {
const textMutedForegroundLight = '#929DAE';
const textForeground = '#000';
const textMutedForeground = '#64748B';
const textRejectedRed = '#dc2626';
const textBase = 10;
const textSm = 9;
const textXs = 8;
@@ -97,6 +98,8 @@ type RenderLabelAndTextOptions = {
text: string;
width: number;
y?: number;
labelFill?: string;
valueFill?: string;
};
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
@@ -106,13 +109,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
y,
});
const labelFill = options.labelFill ?? textMutedForeground;
const valueFill = options.valueFill ?? textMutedForeground;
const label = new Konva.Text({
x: 0,
y: 0,
text: `${options.label}: `,
fontStyle: fontMedium,
fontFamily: 'Inter',
fill: textMutedForeground,
fill: labelFill,
fontSize: textSm,
});
@@ -124,7 +130,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
width: width - label.width(),
fontFamily: 'Inter',
text: options.text,
fill: textMutedForeground,
fill: valueFill,
wrap: 'char',
fontSize: textSm,
});
@@ -269,6 +275,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
const columnWidth = width - columnPadding;
const isRejected = Boolean(recipient.logs.rejected);
if (recipient.signatureField?.secondaryId) {
// Signature container with green border
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
@@ -313,7 +321,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
signatureContainer.add(typedSig);
}
column.add(signatureContainer);
// Do not add the signature container for rejected recipients.
if (!isRejected) {
column.add(signatureContainer);
}
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
@@ -342,7 +353,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
// Signature ID
const sigIdLabel = new Konva.Text({
x: 0,
y: signatureHeight + 10,
y: isRejected ? 0 : signatureHeight + 10,
text: `${i18n._(msg`Signature ID`)}:`,
fill: textMutedForeground,
width: columnWidth,
@@ -376,9 +387,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
column.add(naText);
}
const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed;
const ipLabelAndText = renderLabelAndText({
label: i18n._(msg`IP Address`),
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`),
width,
y: column.getClientRect().height + 6,
});
@@ -386,7 +399,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
const deviceLabelAndText = renderLabelAndText({
label: i18n._(msg`Device`),
text: getDevice(recipient.logs.completed?.userAgent),
text: getDevice(relevantLog?.userAgent),
width,
y: column.getClientRect().height + 6,
});
@@ -400,7 +413,14 @@ const renderColumnThree = (options: RenderColumnOptions) => {
const column = new Konva.Group();
const itemsToRender = [
type DetailItem = {
label: string;
value: string;
labelFill?: string;
valueFill?: string;
};
const itemsToRender: DetailItem[] = [
{
label: i18n._(msg`Sent`),
value: recipient.logs.emailed
@@ -429,6 +449,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
labelFill: textRejectedRed,
valueFill: textRejectedRed,
});
} else {
itemsToRender.push({
@@ -459,6 +481,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
text: item.value,
width,
y: column.getClientRect().height + (index === 0 ? 0 : 8),
labelFill: item.labelFill,
valueFill: item.valueFill,
});
column.add(labelAndText);
}
@@ -0,0 +1,95 @@
import type { Context } from 'hono';
import type { MiddlewareHandler } from 'hono/types';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getIpAddress } from '../../universal/get-ip-address';
import type { RateLimitCheckResult } from './rate-limit';
import type { createRateLimit } from './rate-limit';
/**
* Set rate limit response headers on a Hono context.
*/
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
};
/**
* Create a Hono middleware that applies rate limiting to a route.
*
* Uses IP address for identification. Optionally accepts an identifier
* function for per-user/per-entity limiting.
*/
export const createRateLimitMiddleware = (
limiter: ReturnType<typeof createRateLimit>,
options?: { identifierFn?: (c: Context) => string | undefined },
): MiddlewareHandler => {
return async (c, next) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const identifier = options?.identifierFn?.(c);
const result = await limiter.check({ ip, identifier });
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
await next();
};
};
/**
* Helper for inline rate limit checks in Hono auth routes.
*
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
*/
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
return null;
};
/**
* Helper for inline rate limit checks in tRPC routes.
*
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
*/
export const assertRateLimit = (result: RateLimitCheckResult): void => {
if (result.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Too many requests, please try again later.',
headers: {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
'Retry-After': retryAfter,
},
});
}
};
@@ -0,0 +1,197 @@
import { prisma } from '@documenso/prisma';
import { logger } from '../../utils/logger';
type WindowUnit = 's' | 'm' | 'h' | 'd';
type WindowStr = `${number}${WindowUnit}`;
type RateLimitConfig = {
action: string;
max: number;
globalMax?: number;
window: WindowStr;
};
type CheckParams = {
ip: string;
identifier?: string;
};
export type RateLimitCheckResult = {
isLimited: boolean;
remaining: number;
limit: number;
reset: Date;
};
/**
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
*/
export const parseWindow = (window: WindowStr): number => {
const value = parseInt(window.slice(0, -1), 10);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const unit = window.slice(-1) as WindowUnit;
const multipliers: Record<WindowUnit, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
};
/**
* Compute the current time bucket for the given window size.
*/
export const getBucket = (windowMs: number): Date => {
const now = Date.now();
return new Date(now - (now % windowMs));
};
/**
* Create a rate limiter with the given configuration.
*
* Uses bucketed counters in the database for distributed rate limiting
* across multiple instances. Each check atomically increments the counter
* and returns the new count.
*/
export const createRateLimit = (config: RateLimitConfig) => {
const windowMs = parseWindow(config.window);
return {
async check(params: CheckParams): Promise<RateLimitCheckResult> {
const bucket = getBucket(windowMs);
const reset = new Date(bucket.getTime() + windowMs);
const ipLimit = config.globalMax ?? config.max;
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return {
isLimited: false,
remaining: ipLimit,
limit: ipLimit,
reset,
};
}
try {
// Always upsert the IP counter.
const ipResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
},
},
create: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
// Check IP against globalMax if set, or against max if no identifier is provided.
let ipCheckLimit = config.globalMax;
if (!params.identifier) {
ipCheckLimit = config.max;
}
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'ip',
key: params.ip,
count: ipResult.count,
limit: ipCheckLimit,
});
return {
isLimited: true,
remaining: 0,
limit: ipCheckLimit,
reset,
};
}
// Upsert the identifier counter if provided.
if (params.identifier) {
const identifierResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
},
},
create: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
if (identifierResult.count > config.max) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'identifier',
key: params.identifier,
count: identifierResult.count,
limit: config.max,
});
return {
isLimited: true,
remaining: 0,
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, config.max - identifierResult.count),
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, ipLimit - ipResult.count),
limit: ipLimit,
reset,
};
} catch (error) {
// Fail-open: if the rate limit DB query fails, allow the request through.
logger.error({
msg: 'Rate limit check failed, failing open',
action: config.action,
error,
});
const limit = params.identifier ? config.max : ipLimit;
return {
isLimited: false,
remaining: limit,
limit,
reset,
};
}
},
};
};
@@ -0,0 +1,99 @@
import { createRateLimit } from './rate-limit';
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
});
export const forgotPasswordRateLimit = createRateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = createRateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = createRateLimit({
action: 'auth.request-2fa-email',
max: 5,
globalMax: 20,
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = createRateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = createRateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = createRateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = createRateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = createRateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
window: '1m',
});
export const apiTrpcRateLimit = createRateLimit({
action: 'api.trpc',
max: 100,
window: '1m',
});
export const aiRateLimit = createRateLimit({
action: 'api.ai',
max: 3,
window: '1m',
});
export const fileUploadRateLimit = createRateLimit({
action: 'api.file-upload',
max: 20,
window: '1m',
});
@@ -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,
}),
});
@@ -0,0 +1,24 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByResetTokenOptions {
token: string;
}
export const getUserByResetToken = async ({ token }: GetUserByResetTokenOptions) => {
const result = await prisma.passwordResetToken.findFirst({
where: {
token,
},
include: {
user: true,
},
});
if (!result || !result.user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return result.user;
};
@@ -1,9 +1,27 @@
import { prisma } from '@documenso/prisma';
const LEGACY_DELETED_ACCOUNT_EMAIL = 'deleted-account@documenso.com';
export const deletedServiceAccountEmail = () => {
try {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL;
}
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
return `deleted-account@${hostname}`;
} catch (error) {
return LEGACY_DELETED_ACCOUNT_EMAIL;
}
};
export const deletedAccountServiceAccount = async () => {
const serviceAccount = await prisma.user.findFirst({
where: {
email: 'deleted-account@documenso.com',
email: deletedServiceAccountEmail(),
},
select: {
id: true,
@@ -29,3 +47,20 @@ export const deletedAccountServiceAccount = async () => {
return serviceAccount;
};
export const migrateDeletedAccountServiceAccount = async () => {
if (deletedServiceAccountEmail() !== LEGACY_DELETED_ACCOUNT_EMAIL) {
console.log(
`Migrating deleted account service account to new email: ${deletedServiceAccountEmail()}`,
);
await prisma.user.updateMany({
where: {
email: LEGACY_DELETED_ACCOUNT_EMAIL,
},
data: {
email: deletedServiceAccountEmail(),
},
});
}
};
@@ -0,0 +1,34 @@
import { prisma } from '@documenso/prisma';
const LEGACY_SERVICE_ACCOUNT_EMAIL = 'serviceaccount@documenso.com';
export const legacyServiceAccountEmail = () => {
try {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL;
}
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
return `serviceaccount@${hostname}`;
} catch (error) {
return LEGACY_SERVICE_ACCOUNT_EMAIL;
}
};
export const migrateLegacyServiceAccount = async () => {
if (legacyServiceAccountEmail() !== LEGACY_SERVICE_ACCOUNT_EMAIL) {
console.log(`Migrating legacy service account to new email: ${legacyServiceAccountEmail()}`);
await prisma.user.updateMany({
where: {
email: LEGACY_SERVICE_ACCOUNT_EMAIL,
},
data: {
email: legacyServiceAccountEmail(),
},
});
}
};
@@ -20,6 +20,12 @@ export const submitSupportTicket = async ({
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
if (!plainClient) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Support ticket system is not configured',
});
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@@ -52,6 +58,29 @@ export const submitSupportTicket = async ({
})
: null;
// Ensure the customer exists in Plain before creating a thread
const plainCustomer = await plainClient.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
// If the user doesn't have a name, default to their email
fullName: user.name || user.email,
email: {
email: user.email,
isVerified: !!user.emailVerified,
},
},
// No need to update the customer if it already exists
onUpdate: {},
});
if (plainCustomer.error) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create customer in support system: ${plainCustomer.error.message}`,
});
}
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
@@ -60,12 +89,14 @@ ${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
customerIdentifier: { customerId: plainCustomer.data.customer.id },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create support ticket: ${res.error.message}`,
});
}
return res;
@@ -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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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,
+161 -101
View File
@@ -294,8 +294,10 @@ export const formatDocumentAuditLogAction = (
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const prefix =
userId === auditLog.userId ? i18n._(msg`You`) : auditLog.name || auditLog.email || '';
const isCurrentUser = userId === auditLog.userId;
const user = auditLog.name || auditLog.email || '';
const prefix = isCurrentUser ? i18n._(msg`You`) : user || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
@@ -303,245 +305,303 @@ export const formatDocumentAuditLogAction = (
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`,
you: msg`You added a field`,
user: msg`${user} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`,
you: msg`You removed a field`,
user: msg`${user} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`,
you: msg`You updated a field`,
user: msg`${user} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`,
you: msg`You added a recipient`,
user: msg`${user} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`,
you: msg`You removed a recipient`,
user: msg`${user} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`,
you: msg`You updated a recipient`,
user: msg`${user} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`,
you: msg`You created the document`,
user: msg`${user} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`,
you: msg`You deleted the document`,
user: msg`${user} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
identified: msg`System auto inserted fields`,
you: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
user: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`,
you: msg`You signed a field`,
user: msg`${user} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`,
you: msg`You unsigned a field`,
user: msg`${user} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`,
you: msg`You prefilled a field`,
user: msg`${user} prefilled a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`,
you: msg`You updated the document visibility`,
user: msg`${user} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`,
you: msg`You updated the document access auth requirements`,
user: msg`${user} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`,
you: msg`You updated the document signing auth requirements`,
user: msg`${user} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`,
you: msg`You updated the document`,
user: msg`${user} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`,
you: msg`You opened the document`,
user: msg`${user} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`,
you: msg`You viewed the document`,
user: msg`${user} viewed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`,
you: msg`You updated the document title`,
user: msg`${user} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`,
you: msg`You updated the document external ID`,
user: msg`${user} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`,
you: msg`You sent the document`,
user: msg`${user} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`,
you: msg`You moved the document to team`,
user: msg`${user} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = match(data.recipientRole)
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
.with(RecipientRole.VIEWER, () => msg`${userName} viewed the document`)
.with(RecipientRole.APPROVER, () => msg`${userName} approved the document`)
.with(RecipientRole.CC, () => msg`${userName} CC'd the document`)
.otherwise(() => msg`${userName} completed their task`);
return {
anonymous: result,
identified: result,
};
return match(data.recipientRole)
.with(RecipientRole.SIGNER, () => ({
anonymous: msg`Recipient signed the document`,
you: msg`You signed the document`,
user: msg`${user} signed the document`,
}))
.with(RecipientRole.VIEWER, () => ({
anonymous: msg`Recipient viewed the document`,
you: msg`You viewed the document`,
user: msg`${user} viewed the document`,
}))
.with(RecipientRole.APPROVER, () => ({
anonymous: msg`Recipient approved the document`,
you: msg`You approved the document`,
user: msg`${user} approved the document`,
}))
.with(RecipientRole.CC, () => ({
anonymous: msg`Recipient CC'd the document`,
you: msg`You CC'd the document`,
user: msg`${user} CC'd the document`,
}))
.otherwise(() => ({
anonymous: msg`Recipient completed their task`,
you: msg`You completed your task`,
user: msg`${user} completed their task`,
}));
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} requested a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} validated a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} failed to validate a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
? msg`${prefix} resent an email to ${data.recipientEmail}`
: msg`${prefix} sent an email to ${data.recipientEmail}`,
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
anonymous: msg`Recipient rejected the document`,
you: msg`You rejected the document`,
user: msg`${user} rejected the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
anonymous: msg`Recipient requested a 2FA token for the document`,
you: msg`You requested a 2FA token for the document`,
user: msg`${user} requested a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, () => ({
anonymous: msg`Recipient validated a 2FA token for the document`,
you: msg`You validated a 2FA token for the document`,
user: msg`${user} validated a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, () => ({
anonymous: msg`Recipient failed to validate a 2FA token for the document`,
you: msg`You failed to validate a 2FA token for the document`,
user: msg`${user} failed to validate a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => {
if (data.isResending) {
return {
anonymous: msg({
message: `Email resent`,
context: `Audit log format`,
}),
you: msg`You resent an email to ${data.recipientEmail}`,
user: msg`${user} resent an email to ${data.recipientEmail}`,
};
}
return {
anonymous: msg({
message: `Email sent`,
context: `Audit log format`,
}),
you: msg`You sent an email to ${data.recipientEmail}`,
user: msg`${user} sent an email to ${data.recipientEmail}`,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg({
message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
anonymous: msg({ message: `Document completed`, context: `Audit log format` }),
you: msg({ message: `Document completed`, context: `Audit log format` }),
user: msg({ message: `Document completed`, context: `Audit log format` }),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
anonymous: msg`Envelope item created`,
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg`Envelope item deleted`,
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => ({
anonymous: msg({
message: `Document ownership delegated`,
message: `Envelope item created`,
context: `Audit log format`,
}),
identified: msg`The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
you: msg`You created an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg({
message: `Envelope item deleted`,
context: `Audit log format`,
}),
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}`,
context: `Audit log format`,
});
return {
anonymous: message,
you: message,
user: message,
};
})
.exhaustive();
let selectedDescription = description.anonymous;
if (isCurrentUser) {
selectedDescription = description.you;
} else if (user) {
selectedDescription = description.user;
}
return {
prefix,
description: i18n._(prefix ? description.identified : description.anonymous),
description: i18n._(selectedDescription),
};
};
+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,
};
};