Merge branch 'main' into feat/prefetch-intent-navigation-links

This commit is contained in:
ephraimduncan
2026-04-20 00:20:51 +00:00
341 changed files with 12631 additions and 2208 deletions
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
@@ -1,7 +1,7 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -39,7 +39,7 @@ type EditorRecipientsProps = {
};
type ResetFormOptions = {
recipients?: Recipient[];
recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
@@ -357,6 +357,7 @@ export const EnvelopeEditorProvider = ({
},
{
enabled: !isEmbedded,
gcTime: 0,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -517,7 +518,7 @@ const mapLocalRecipientsToRecipients = ({
}, -1);
return localRecipients.map((recipient) => {
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipient.id);
const foundRecipient = envelope.recipients.find((r) => r.id === recipient.id);
let recipientId = recipient.id;
+4 -2
View File
@@ -7,6 +7,8 @@ import {
SigningStatus,
} from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
@@ -16,7 +18,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
recipient: Recipient,
recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
@@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
[DOCUMENT_EMAIL_TYPE.REMINDER]: {
description: 'Signing Reminder',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
@@ -0,0 +1,86 @@
import type { DurationLikeObject } from 'luxon';
import { Duration } from 'luxon';
import { z } from 'zod';
export const ZEnvelopeReminderDurationPeriod = z.object({
unit: z.enum(['day', 'week', 'month']),
amount: z.number().int().min(1),
});
export const ZEnvelopeReminderDisabledPeriod = z.object({
disabled: z.literal(true),
});
export const ZEnvelopeReminderPeriod = z.union([
ZEnvelopeReminderDurationPeriod,
ZEnvelopeReminderDisabledPeriod,
]);
export type TEnvelopeReminderPeriod = z.infer<typeof ZEnvelopeReminderPeriod>;
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
export const ZEnvelopeReminderSettings = z.object({
sendAfter: ZEnvelopeReminderPeriod,
repeatEvery: ZEnvelopeReminderPeriod,
});
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
sendAfter: { unit: 'day', amount: 5 },
repeatEvery: { unit: 'day', amount: 2 },
};
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
{
day: 'days',
week: 'weeks',
month: 'months',
};
export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
/**
* Resolve the next reminder timestamp from the config and the last reminder sent time.
*
* - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
if (!config) {
return null;
}
// If we haven't sent the first reminder yet, use sendAfter.
if (!lastReminderSentAt) {
if ('disabled' in config.sendAfter) {
return null;
}
const delay = getEnvelopeReminderDuration(config.sendAfter);
return new Date(sentAt.getTime() + delay.toMillis());
}
// For subsequent reminders, use repeatEvery.
if ('disabled' in config.repeatEvery) {
return null;
}
const interval = getEnvelopeReminderDuration(config.repeatEvery);
return new Date(lastReminderSentAt.getTime() + interval.toMillis());
};
-3
View File
@@ -1,13 +1,10 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES, type SupportedLanguageCodes } from './locales';
export * from './locales';
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
export type I18nLocaleData = {
/**
* The supported language extracted from the locale.
+4
View File
@@ -1,3 +1,5 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
@@ -19,3 +21,5 @@ export const APP_I18N_OPTIONS = {
sourceLang: 'en',
defaultLocale: 'en-US',
} as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
+2
View File
@@ -13,6 +13,7 @@ export enum AppErrorCode {
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
@@ -29,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
+4
View File
@@ -16,8 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
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 { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -43,6 +45,8 @@ export const jobsClient = new JobClient([
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const);
+387
View File
@@ -0,0 +1,387 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import type { Job } from 'bullmq';
import { Hono } from 'hono';
import type { Context as HonoContext } from 'hono';
import IORedis from 'ioredis';
import { createRequire } from 'node:module';
import path from 'node:path';
import { prisma } from '@documenso/prisma';
import { env } from '../../utils/env';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
const QUEUE_NAME = 'documenso-jobs';
const DEFAULT_CONCURRENCY = 10;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_DELAY = 1000;
declare global {
// eslint-disable-next-line no-var
var __documenso_bullmq_provider__: BullMQJobProvider | undefined;
}
export class BullMQJobProvider extends BaseJobProvider {
private _queue: Queue;
private _worker: Worker;
private _connection: IORedis;
private _jobDefinitions: Record<string, JobDefinition> = {};
private constructor() {
super();
const redisUrl = env('NEXT_PRIVATE_REDIS_URL');
if (!redisUrl) {
throw new Error(
'[JOBS]: NEXT_PRIVATE_REDIS_URL is required when using the BullMQ jobs provider',
);
}
const prefix = env('NEXT_PRIVATE_REDIS_PREFIX') || 'documenso';
this._connection = new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
this._queue = new Queue(QUEUE_NAME, {
connection: this._connection,
prefix,
});
const concurrency = Number(env('NEXT_PRIVATE_BULLMQ_CONCURRENCY')) || DEFAULT_CONCURRENCY;
this._worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
await this.processJob(job);
},
{
connection: this._connection,
prefix,
concurrency,
},
);
this._worker.on('failed', (job, error) => {
console.error(`[JOBS]: Job ${job?.name ?? 'unknown'} failed`, error);
});
this._worker.on('error', (error) => {
console.error('[JOBS]: Worker error', error);
});
console.log(`[JOBS]: BullMQ provider initialized (concurrency: ${concurrency})`);
}
/**
* Uses globalThis to store the singleton instance so that it's shared across
* different bundles (e.g. Hono and Vite/React Router) at runtime.
*/
static getInstance() {
if (globalThis.__documenso_bullmq_provider__) {
return globalThis.__documenso_bullmq_provider__;
}
const instance = new BullMQJobProvider();
globalThis.__documenso_bullmq_provider__ = instance;
return instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
this._jobDefinitions[definition.id] = {
...definition,
enabled: definition.enabled ?? true,
};
if (definition.trigger.cron && definition.enabled !== false) {
void this._queue
.upsertJobScheduler(
definition.id,
{ pattern: definition.trigger.cron },
{
name: definition.id,
data: {
name: definition.trigger.name,
payload: {},
},
opts: {
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
},
)
.then(() => {
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
})
.catch((error) => {
console.error(`[JOBS]: Failed to register cron job ${definition.id}`, error);
});
}
}
public async triggerJob(options: SimpleTriggerJobOptions) {
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
await Promise.all(
eligibleJobs.map(async (job) => {
const backgroundJob = await prisma.backgroundJob.create({
data: {
jobId: job.id,
name: job.name,
version: job.version,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
payload: options.payload as Prisma.InputJsonValue,
},
});
await this._queue.add(
job.id,
{
name: options.name,
payload: options.payload,
backgroundJobId: backgroundJob.id,
},
{
jobId: options.id,
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
);
}),
);
}
public override getApiHandler(): (c: HonoContext) => Promise<Response | void> {
const boardApp = this.createBoardApp();
return async (c: HonoContext) => {
const reqPath = new URL(c.req.url).pathname;
if (!reqPath.startsWith('/api/jobs/board')) {
return c.text('OK', 200);
}
// Auth check — open in dev, admin-only in production.
if (env('NODE_ENV') !== 'development') {
const { getOptionalSession } = await import('@documenso/auth/server/lib/utils/get-session');
const { isAdmin } = await import('../../utils/is-admin');
const { user } = await getOptionalSession(c);
if (!user || !isAdmin(user)) {
return c.text('Unauthorized', 401);
}
}
return boardApp.fetch(c.req.raw);
};
}
private createBoardApp(): Hono {
const _require = createRequire(import.meta.url);
const uiPackagePath = path.dirname(_require.resolve('@bull-board/ui/package.json'));
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
queues: [new BullMQAdapter(this._queue)],
serverAdapter,
options: { uiBasePath: uiPackagePath },
});
serverAdapter.setBasePath('/api/jobs/board');
const app = new Hono();
app.route('/api/jobs/board', serverAdapter.registerPlugin());
return app;
}
private async processJob(job: Job) {
const definitionId = job.name;
const definition = this._jobDefinitions[definitionId];
if (!definition) {
console.error(`[JOBS]: No definition found for job ${definitionId}`);
throw new Error(`No definition found for job ${definitionId}`);
}
if (!definition.enabled) {
console.log(`[JOBS]: Skipping disabled job ${definitionId}`);
return;
}
const jobData = job.data as {
name: string;
payload: unknown;
backgroundJobId?: string;
};
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(jobData.payload);
if (!result.success) {
console.error(`[JOBS]: Payload validation failed for ${definitionId}`, result.error);
throw new Error(`Payload validation failed for ${definitionId}`);
}
}
const backgroundJobId = jobData.backgroundJobId;
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: {
id: backgroundJobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: job.attemptsMade > 0 ? job.attemptsMade : 0,
lastRetriedAt: job.attemptsMade > 0 ? new Date() : undefined,
},
})
.catch(() => null);
}
console.log(`[JOBS]: Processing job ${definitionId} with payload`, jobData.payload);
try {
await definition.handler({
payload: jobData.payload,
io: this.createJobRunIO(backgroundJobId ?? job.id ?? definitionId),
});
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
})
.catch(() => null);
}
} catch (error) {
if (backgroundJobId) {
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? DEFAULT_MAX_RETRIES) - 1;
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: isFinalAttempt ? BackgroundJobStatus.FAILED : BackgroundJobStatus.PENDING,
completedAt: isFinalAttempt ? new Date() : undefined,
},
})
.catch(() => null);
}
throw error;
}
}
private createJobRunIO(jobId: string): JobRunIO {
return {
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
let task = await prisma.backgroundJobTask.findFirst({
where: {
id: `task-${hashedKey}--${jobId}`,
jobId,
},
});
if (!task) {
task = await prisma.backgroundJobTask.create({
data: {
id: `task-${hashedKey}--${jobId}`,
name: cacheKey,
jobId,
status: BackgroundJobStatus.PENDING,
},
});
}
if (task.status === BackgroundJobStatus.COMPLETED) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return task.result as T;
}
if (task.retried >= DEFAULT_MAX_RETRIES) {
throw new Error('Task exceeded retries');
}
try {
const result = await callback();
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.COMPLETED,
result: result === null ? Prisma.JsonNull : result,
completedAt: new Date(),
},
});
return result;
} catch (err) {
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.PENDING,
retried: {
increment: 1,
},
},
});
console.log(`[JOBS:${task.id}] Task failed`, err);
throw err;
}
},
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
logger: {
debug: (...args) => console.debug(`[${jobId}]`, ...args),
error: (...args) => console.error(`[${jobId}]`, ...args),
info: (...args) => console.info(`[${jobId}]`, ...args),
log: (...args) => console.log(`[${jobId}]`, ...args),
warn: (...args) => console.warn(`[${jobId}]`, ...args),
},
// eslint-disable-next-line @typescript-eslint/require-await
wait: async () => {
throw new Error('Not implemented');
},
};
}
}
+2
View File
@@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { env } from '../../utils/env';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { BullMQJobProvider } from './bullmq';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
@@ -12,6 +13,7 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public constructor(definitions: T) {
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
.with('inngest', () => InngestJobProvider.getInstance())
.with('bullmq', () => BullMQJobProvider.getInstance())
.otherwise(() => LocalJobProvider.getInstance());
definitions.forEach((definition) => {
@@ -1,11 +1,12 @@
import { z } from 'zod';
import { zEmail } from '../../../utils/zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
email: zEmail(),
force: z.boolean().optional(),
});
@@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -206,6 +207,8 @@ export const run = async ({
});
}
const sentAt = new Date();
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +216,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
sentAt,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
// Compute the first reminder time based on the envelope's effective settings.
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt,
lastReminderSentAt: null,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
};
@@ -9,6 +9,7 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -22,7 +23,7 @@ import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
@@ -0,0 +1,222 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
export const run = async ({
payload,
io,
}: {
payload: TProcessSigningReminderJobDefinition;
io: JobRunIO;
}) => {
const { recipientId } = payload;
const now = new Date();
// Atomically claim this reminder by setting lastReminderSentAt and clearing
// nextReminderAt so no other sweep picks it up.
const updatedCount = await prisma.recipient.updateMany({
where: {
id: recipientId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
data: {
lastReminderSentAt: now,
nextReminderAt: null,
},
});
if (updatedCount.count === 0) {
io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
return;
}
const recipient = await prisma.recipient.findFirst({
where: { id: recipientId },
include: {
envelope: {
include: {
documentMeta: true,
user: true,
recipients: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
io.logger.warn(`Recipient ${recipientId} not found`);
return;
}
const { envelope } = recipient;
if (!envelope.documentMeta) {
io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
return;
}
// Skip if distribution method is NONE (manual link sharing, no emails).
if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
return;
}
if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailSubject = i18n._(
msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
);
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
}
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': envelope.title,
};
if (envelope.documentMeta.subject) {
emailSubject = renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
);
}
const emailMessage = envelope.documentMeta.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
},
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Compute the next reminder time (repeat interval).
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
});
}
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TProcessSigningReminderJobDefinition = z.infer<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
>;
export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
name: 'Process Signing Reminder',
version: '1.0.0',
trigger: {
name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./process-signing-reminder.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
TProcessSigningReminderJobDefinition
>;
@@ -491,7 +491,7 @@ const decorateAndSignPdf = async ({
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const newDocumentData = await putPdfFileServerSide(
const { documentData: newDocumentData } = await putPdfFileServerSide(
{
name: `${name}${suffix}`,
type: 'application/pdf',
@@ -0,0 +1,49 @@
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
export const run = async ({
io,
}: {
payload: TSendSigningRemindersSweepJobDefinition;
io: JobRunIO;
}) => {
const now = new Date();
const recipients = await prisma.recipient.findMany({
where: {
nextReminderAt: { lte: now },
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
select: { id: true },
take: 1000,
});
if (recipients.length === 0) {
io.logger.info('No recipients need signing reminders');
return;
}
io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
await Promise.allSettled(
recipients.map(async (recipient) => {
await jobs.triggerJob({
name: 'internal.process-signing-reminder',
payload: {
recipientId: recipient.id,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSendSigningRemindersSweepJobDefinition = z.infer<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
name: 'Send Signing Reminders Sweep',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./send-signing-reminders-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
TSendSigningRemindersSweepJobDefinition
>;
+5
View File
@@ -23,6 +23,9 @@
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0",
"@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
"@documenso/assets": "*",
"@documenso/email": "*",
"@documenso/prisma": "*",
@@ -41,8 +44,10 @@
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"bullmq": "^5.71.1",
"csv-parse": "^6.1.0",
"inngest": "^3.45.1",
"ioredis": "^5.10.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.8",
@@ -0,0 +1,107 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { logger } from '../../utils/logger';
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
type TurnstileVerifyResponse = {
success: boolean;
'error-codes': string[];
challenge_ts?: string;
hostname?: string;
};
/**
* Verify a captcha token server-side.
*
* Currently supports Cloudflare Turnstile. This is a no-op if
* `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha
* verification an opt-in feature.
*/
export const verifyCaptchaToken = async ({
token,
ipAddress,
}: {
token?: string | null;
ipAddress?: string | null;
}) => {
const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY;
// If no secret key is configured, skip verification.
if (!secretKey) {
return;
}
if (!token) {
logger.warn({
msg: 'Captcha verification rejected: missing token',
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha token is required',
statusCode: 400,
});
}
const formData = new URLSearchParams();
formData.append('secret', secretKey);
formData.append('response', token);
if (ipAddress) {
formData.append('remoteip', ipAddress);
}
let response: Response;
try {
response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
} catch (err) {
logger.error({
msg: 'Captcha verification failed: network error calling siteverify',
err,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha verification failed',
statusCode: 400,
});
}
if (!response.ok) {
logger.error({
msg: 'Captcha verification failed: non-2xx response from siteverify',
status: response.status,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification request failed with status ${response.status}`,
statusCode: 400,
});
}
const result: TurnstileVerifyResponse = await response.json();
if (!result.success) {
logger.warn({
msg: 'Captcha verification rejected by provider',
errorCodes: result['error-codes'],
hostname: result.hostname,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`,
statusCode: 400,
});
}
};
@@ -8,7 +8,10 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
@@ -120,46 +123,6 @@ export const completeDocumentWithToken = async ({
}
}
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -218,6 +181,112 @@ export const completeDocumentWithToken = async ({
});
}
let fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// This should be scoped to the current recipient.
const uninsertedDateFields = fields.filter(
(field) => field.type === FieldType.DATE && !field.inserted,
);
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Auto-insert all un-inserted date fields for V2 envelopes at completion time.
if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
const formattedDate = DateTime.now()
.setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
const newDateFieldValues = {
customText: formattedDate,
inserted: true,
};
await prisma.field.updateMany({
where: {
id: {
in: uninsertedDateFields.map((field) => field.id),
},
},
data: {
...newDateFieldValues,
},
});
// Create audit log entries for each auto-inserted date field.
await prisma.documentAuditLog.createMany({
data: uninsertedDateFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
envelopeId: envelope.id,
user: {
email: recipientEmail,
name: recipientName,
},
requestMetadata,
data: {
recipientEmail: recipientEmail,
recipientId: recipient.id,
recipientName: recipientName,
recipientRole: recipient.role,
fieldId: field.secondaryId,
field: {
type: FieldType.DATE,
data: formattedDate,
},
},
}),
),
});
// Update the local fields array so the subsequent validation check passes.
fields = fields.map((field) => {
if (field.type === FieldType.DATE && !field.inserted) {
return {
...field,
...newDateFieldValues,
};
}
return field;
});
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
@@ -373,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,
@@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@@ -18,6 +18,7 @@ import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@@ -207,7 +208,7 @@ export const sendDocument = async ({
});
}
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient);
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
@@ -374,6 +375,7 @@ const injectFormValuesIntoDocument = async (
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
recipient: Pick<Recipient, 'email'>,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
@@ -386,6 +388,18 @@ export const extractFieldAutoInsertValues = (
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert email fields if the recipient has a valid email.
if (
field.type === FieldType.EMAIL &&
isRecipientEmailValidForSending(recipient) &&
recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL
) {
return {
fieldId,
customText: recipient.email,
};
}
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
@@ -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,
@@ -61,7 +61,7 @@ export const UNSAFE_createEnvelopeItems = async ({
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
@@ -71,7 +71,7 @@ export const UNSAFE_createEnvelopeItems = async ({
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId,
documentDataId: documentData.id,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
@@ -0,0 +1,233 @@
import type { Envelope, Field, Recipient } from '@prisma/client';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
type UnsafeReplaceEnvelopeItemPdfOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'>;
/**
* Recipients used to resolve placeholder field assignments.
* When provided and placeholders are found in the replacement PDF,
* fields will be auto-created for matching recipients.
*/
recipients: Recipient[];
/**
* The ID of the envelope item which we will be replacing the PDF for.
*/
envelopeItemId: string;
/**
* The ID of the old document data we will be deleting.
*/
oldDocumentDataId: string;
/**
* The data we will be replacing.
*/
data: {
title?: string;
order?: number;
file: File;
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
type UnsafeReplaceEnvelopeItemPdfResult = {
updatedItem: {
id: string;
title: string;
envelopeId: string;
order: number;
documentDataId: string;
};
/**
* The full list of fields for the envelope after the replacement.
*
* Only returned when fields were created or deleted during the replacement,
* otherwise `undefined`.
*/
fields: Field[] | undefined;
};
export const UNSAFE_replaceEnvelopeItemPdf = async ({
envelope,
recipients,
envelopeItemId,
oldDocumentDataId,
data,
user,
apiRequestMetadata,
}: UnsafeReplaceEnvelopeItemPdfOptions): Promise<UnsafeReplaceEnvelopeItemPdfResult> => {
let buffer = Buffer.from(await data.file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
name: data.file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
data: {
documentDataId: newDocumentData.id,
title: data.title,
order: data.order,
},
});
// Todo: Audit log if we're updating the title or order.
// Delete fields that reference pages beyond the new PDF's page count.
const outOfBoundsFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId,
page: {
gt: filePageCount,
},
},
select: {
id: true,
},
});
const deletedFieldIds = outOfBoundsFields.map((f) => f.id);
if (deletedFieldIds.length > 0) {
await tx.field.deleteMany({
where: {
id: {
in: deletedFieldIds,
},
},
});
didFieldsChange = true;
}
if (recipients.length > 0 && placeholders.length > 0) {
const orderedRecipients = [...recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
const fieldsToCreate = convertPlaceholdersToFieldInputs(
placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
updatedItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: updatedItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
didFieldsChange = true;
}
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED,
envelopeId: envelope.id,
data: {
envelopeItemId: updatedItem.id,
envelopeItemTitle: updatedItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return updatedItem;
});
// Delete the old DocumentData (now orphaned).
await prisma.documentData.delete({
where: {
id: oldDocumentDataId,
},
});
let fields: Field[] | undefined = undefined;
if (didFieldsChange) {
try {
fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
});
} catch (err) {
// Do nothing.
console.error(err);
}
}
return {
updatedItem: updatedEnvelopeItem,
fields,
};
};
@@ -1,20 +1,34 @@
import type { EnvelopeItem, EnvelopeType } from '@prisma/client';
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';
import { prisma } from '@documenso/prisma';
type UnsafeUpdateEnvelopeItemsOptions = {
envelopeId: string;
envelopeType: EnvelopeType;
existingEnvelopeItems: Pick<EnvelopeItem, 'id' | 'title' | 'order'>[];
data: {
envelopeItemId: string;
order?: number;
title?: string;
}[];
user: {
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_updateEnvelopeItems = async ({
envelopeId,
envelopeType,
existingEnvelopeItems,
data,
user,
apiRequestMetadata,
}: UnsafeUpdateEnvelopeItemsOptions) => {
// Todo: Envelope [AUDIT_LOGS]
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
@@ -36,5 +50,52 @@ export const UNSAFE_updateEnvelopeItems = async ({
),
);
// Write audit logs for DOCUMENT type envelopes when changes are detected.
if (envelopeType === 'DOCUMENT') {
const auditLogs = data.flatMap((item) => {
const existing = existingEnvelopeItems.find((e) => e.id === item.envelopeItemId);
if (!existing) {
return [];
}
const changes: { field: string; from: string; to: string }[] = [];
if (item.title !== undefined && item.title !== existing.title) {
changes.push({
field: 'title',
from: existing.title,
to: item.title,
});
}
if (changes.length === 0) {
return [];
}
return [
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED,
envelopeId,
data: {
envelopeItemId: item.envelopeItemId,
changes,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
];
});
if (auditLogs.length > 0) {
await prisma.documentAuditLog.createMany({
data: auditLogs,
});
}
}
return updatedEnvelopeItems;
};
@@ -207,7 +207,7 @@ export const createEnvelope = async ({
const titleToUse = item.title || title;
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
@@ -19,9 +19,25 @@ export interface DuplicateEnvelopeOptions {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
overrides?: {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
includeFields?: boolean;
};
}
export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => {
export const duplicateEnvelope = async ({
id,
userId,
teamId,
overrides,
}: DuplicateEnvelopeOptions) => {
const {
duplicateAsTemplate = false,
includeRecipients = true,
includeFields = true,
} = overrides ?? {};
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: null,
@@ -36,6 +52,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
title: true,
userId: true,
internalVersion: true,
templateType: true,
publicTitle: true,
publicDescription: true,
envelopeItems: {
include: {
documentData: {
@@ -69,8 +88,16 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
});
}
if (duplicateAsTemplate && envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only documents can be saved as templates',
});
}
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
envelope.type === EnvelopeType.DOCUMENT
targetType === EnvelopeType.DOCUMENT
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
legacyNumberId: documentId,
secondaryId: formattedDocumentId,
@@ -87,11 +114,16 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
]);
const duplicatedTemplateType =
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
? 'PRIVATE'
: (envelope.templateType ?? undefined);
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
type: envelope.type,
type: targetType,
internalVersion: envelope.internalVersion,
userId,
teamId,
@@ -99,8 +131,11 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
templateType: duplicatedTemplateType,
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
targetType === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},
include: {
recipients: true,
@@ -137,38 +172,42 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
);
await pMap(
envelope.recipients,
(recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
if (includeRecipients) {
await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: includeFields
? {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
}
: undefined,
},
},
}),
{ concurrency: 5 },
);
}),
{ concurrency: 5 },
);
}
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({
@@ -193,7 +232,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
id: duplicatedEnvelope.id,
envelope: duplicatedEnvelope,
legacyId: {
type: envelope.type,
type: duplicatedEnvelope.type,
id: legacyNumberId,
},
};
@@ -1,5 +1,4 @@
import type { Prisma } from '@prisma/client';
import type { EnvelopeType } from '@prisma/client';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -146,7 +146,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
...recipient,
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
const autoInsertValue = extractFieldAutoInsertValues(field, recipient);
if (!autoInsertValue) {
return field;
@@ -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';
@@ -207,9 +208,13 @@ export const updateEnvelope = async ({
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT) {
if (
!isTitleSame &&
envelope.status !== DocumentStatus.DRAFT &&
envelope.status !== DocumentStatus.PENDING
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the envelope has been sent',
message: 'Envelope title can only be updated while in draft or pending status',
});
}
@@ -350,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,
@@ -308,7 +308,7 @@ export const createEnvelopeFields = async ({
continue;
}
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
+22 -5
View File
@@ -13,14 +13,31 @@ const MIN_CERT_PAGE_HEIGHT = 300;
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
let mediaBox;
let cropBox;
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
try {
mediaBox = page.getMediaBox();
} catch {
// MediaBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
return cropBox;
try {
cropBox = page.getCropBox();
} catch {
// CropBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
if (mediaBox && cropBox) {
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
}
// If either box is missing or invalid, fall back to MediaBox if available, otherwise CropBox, or default to A4 size.
return mediaBox || cropBox || PDF_SIZE_A4_72PPI;
};
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
@@ -4,8 +4,8 @@ import { createRateLimit } from './rate-limit';
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
max: 3,
window: '3h',
});
export const forgotPasswordRateLimit = createRateLimit({
@@ -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 },
});
}),
);
};
@@ -24,6 +24,7 @@ import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
@@ -307,20 +308,12 @@ export const createDocumentFromDirectTemplate = async ({
const titleToUse = item.title || directTemplateEnvelope.title;
const duplicatedFile = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
@@ -712,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -751,14 +745,17 @@ export const createDocumentFromDirectTemplate = async ({
};
});
// Send email to template owner via background job.
await jobs.triggerJob({
name: 'send.document.created.from.direct.template.email',
payload: {
envelopeId: createdEnvelope.id,
recipientId,
},
});
const emailSettings = extractDerivedDocumentEmailSettings(documentMeta);
if (emailSettings.ownerDocumentCreated) {
await jobs.triggerJob({
name: 'send.document.created.from.direct.template.email',
payload: {
envelopeId: createdEnvelope.id,
recipientId,
},
});
}
try {
// This handles sending emails and sealing the document if required.
@@ -61,6 +61,7 @@ import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
type FinalRecipient = Pick<
Recipient,
@@ -312,29 +313,43 @@ export const createDocumentFromTemplate = async ({
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const templateInclude = {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
} as const;
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
const [teamTemplate, organisationTemplate] = await Promise.all([
prisma.envelope.findFirst({
where: envelopeWhereInput,
include: templateInclude,
}),
prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id,
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: templateInclude,
}),
]);
const template = teamTemplate ?? organisationTemplate;
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -541,7 +556,7 @@ export const createDocumentFromTemplate = async ({
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
teamId,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {
@@ -0,0 +1,84 @@
import { EnvelopeType, type Prisma, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { type FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
};
export const findOrganisationTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: team.organisationId,
},
};
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({ where }),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -24,8 +24,6 @@ export const findTemplates = async ({
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@@ -34,63 +32,55 @@ export const findTemplates = async ({
},
});
whereFilter.push(
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
},
{ userId, teamId },
],
},
);
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
};
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
include: {
team: {
select: {
id: true,
url: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
}),
prisma.envelope.count({ where }),
]);
return {
@@ -0,0 +1,136 @@
import type { Prisma, TeamMemberRole } from '@prisma/client';
import { EnvelopeType, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type GetOrganisationTemplateByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
/**
* Get an organisation template by ID.
*
* This validates that the caller's team belongs to the same organisation as the template's team,
* that the template is of type ORGANISATION, and that the template's visibility is permitted
* for the caller's role on their own team.
*/
export const getOrganisationTemplateById = async ({
id,
userId,
teamId,
}: GetOrganisationTemplateByIdOptions) => {
const [callerTeam, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: callerTeam.organisationId,
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation template not found',
});
}
return {
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
/**
* Build a where input for querying an organisation template.
*
* Matches a TEMPLATE envelope with templateType ORGANISATION belonging to any team
* within the provided organisation, respecting the caller's team role visibility.
*/
export const getOrganisationTemplateWhereInput = ({
id,
organisationId,
teamRole,
}: {
id: EnvelopeIdOptions;
organisationId: string;
teamRole: TeamMemberRole;
}): Prisma.EnvelopeWhereInput => {
return {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId,
},
};
};
@@ -34,19 +34,23 @@ export const searchTemplatesWithKeyword = async ({
const teamIds = [...teamGroupsByTeamId.keys()];
const titleOrRecipientMatch: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
};
const filters: Prisma.EnvelopeWhereInput[] = [
// Templates owned by the user matching title or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
...titleOrRecipientMatch,
},
];
@@ -55,14 +59,7 @@ export const searchTemplatesWithKeyword = async ({
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
...titleOrRecipientMatch,
});
}
@@ -85,6 +82,7 @@ export const searchTemplatesWithKeyword = async ({
},
team: {
select: {
id: true,
url: true,
},
},
@@ -122,6 +120,7 @@ export const searchTemplatesWithKeyword = async ({
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {
@@ -6,6 +6,7 @@ import { withTimeout } from '../../utils/timeout';
import { isPrivateUrl } from './is-private-url';
const ZIpSchema = z.string().ip();
const WEBHOOK_DNS_LOOKUP_TIMEOUT_MS = 250;
type TLookupAddress = {
@@ -26,9 +27,55 @@ const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/
const toAddressUrl = (address: string) =>
address.includes(':') ? `http://[${address}]` : `http://${address}`;
/**
* Parse the NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS environment variable into
* a Set of lowercased hostnames/IPs that are allowed to resolve to private
* addresses. The Set is built once at module load and never changes.
*
* Empty or unset = no bypasses (safe default).
*/
const webhookSSRFBypassHosts = (): Set<string> => {
const raw = process.env['NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS'] ?? '';
const hosts = new Set<string>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim().toLowerCase();
if (trimmed.length > 0) {
hosts.add(trimmed);
}
}
return hosts;
};
const WEBHOOK_SSRF_BYPASS_HOSTS = webhookSSRFBypassHosts();
/**
* Check whether the hostname of the given URL is present in the SSRF bypass
* list. Matches against URL.hostname which covers both DNS names and raw IP
* addresses uniformly.
*/
const isBypassedHost = (url: string): boolean => {
if (WEBHOOK_SSRF_BYPASS_HOSTS.size === 0) {
return false;
}
try {
const hostname = normalizeHostname(new URL(url).hostname);
return WEBHOOK_SSRF_BYPASS_HOSTS.has(hostname);
} catch {
return false;
}
};
/**
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
*
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*/
export const assertNotPrivateUrl = async (
url: string,
@@ -36,6 +83,10 @@ export const assertNotPrivateUrl = async (
lookup?: TLookupFn;
},
) => {
if (isBypassedHost(url)) {
return;
}
if (isPrivateUrl(url)) {
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
message: 'Webhook URL resolves to a private or loopback address',
@@ -50,6 +101,7 @@ export const assertNotPrivateUrl = async (
}
const resolveHostname = options?.lookup ?? lookup;
const lookupResult = await withTimeout(
resolveHostname(hostname, {
all: true,
@@ -440,6 +440,7 @@ export const generateSampleWebhookPayload = (
documentCompleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
ownerDocumentCreated: true,
recipientSigningRequest: true,
},
},
+3 -1
View File
@@ -1,8 +1,10 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { zEmail } from '../utils/zod';
export const ZDefaultRecipientSchema = z.object({
email: z.string().email(),
email: zEmail(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
});
+35 -1
View File
@@ -7,6 +7,7 @@
import { DocumentSource, FieldType } from '@prisma/client';
import { z } from 'zod';
import { zEmail } from '../utils/zod';
import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from './document-auth';
export const ZDocumentAuditLogTypeSchema = z.enum([
@@ -23,6 +24,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'ENVELOPE_ITEM_CREATED',
'ENVELOPE_ITEM_DELETED',
'ENVELOPE_ITEM_UPDATED',
'ENVELOPE_ITEM_PDF_REPLACED',
// Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
@@ -60,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'ASSISTING_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
'REMINDER',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
@@ -209,6 +213,34 @@ export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({
}),
});
/**
* Event: Envelope item updated.
*/
export const ZDocumentAuditLogEventEnvelopeItemUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED),
data: z.object({
envelopeItemId: z.string(),
changes: z.array(
z.object({
field: z.string(),
from: z.string(),
to: z.string(),
}),
),
}),
});
/**
* Event: Envelope item PDF replaced.
*/
export const ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/**
* Event: Email sent.
*/
@@ -249,7 +281,7 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
z.object({
type: z.literal(DocumentSource.TEMPLATE_DIRECT_LINK),
templateId: z.number(),
directRecipientEmail: z.string().email(),
directRecipientEmail: zEmail(),
}),
])
.optional(),
@@ -722,6 +754,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEnvelopeItemCreatedSchema,
ZDocumentAuditLogEventEnvelopeItemDeletedSchema,
ZDocumentAuditLogEventEnvelopeItemUpdatedSchema,
ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema,
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
+9
View File
@@ -11,6 +11,7 @@ export enum DocumentEmailEvents {
DocumentDeleted = 'documentDeleted',
OwnerDocumentCompleted = 'ownerDocumentCompleted',
OwnerRecipientExpired = 'ownerRecipientExpired',
OwnerDocumentCreated = 'ownerDocumentCreated',
}
export const ZDocumentEmailSettingsSchema = z
@@ -59,6 +60,12 @@ export const ZDocumentEmailSettingsSchema = z
"Whether to send an email to the document owner when a recipient's signing window has expired.",
)
.default(true),
ownerDocumentCreated: z
.boolean()
.describe(
'Whether to send an email to the document owner when a document is created from a direct template.',
)
.default(true),
})
.strip()
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
@@ -86,6 +93,7 @@ export const extractDerivedDocumentEmailSettings = (
documentDeleted: false,
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
ownerRecipientExpired: emailSettings.ownerRecipientExpired,
ownerDocumentCreated: emailSettings.ownerDocumentCreated,
};
};
@@ -98,4 +106,5 @@ export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
documentDeleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
ownerDocumentCreated: true,
};
+4 -1
View File
@@ -4,8 +4,10 @@ import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { ZDocumentEmailSettingsSchema } from './document-email';
@@ -127,9 +129,10 @@ export const ZDocumentMetaCreateSchema = z.object({
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
+1
View File
@@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
reminderSettings: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
+3
View File
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/locales';
import { ZCssVarsSchema } from './css-vars';
export const ZBaseEmbedDataSchema = z.object({
@@ -9,4 +11,5 @@ export const ZBaseEmbedDataSchema = z.object({
.optional()
.transform((value) => value || undefined),
cssVars: ZCssVarsSchema.optional().default({}),
language: ZSupportedLanguageCodeSchema.optional(),
});
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { zEmail } from '../utils/zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZDirectTemplateEmbedDataSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.union([z.literal(''), zEmail()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { zEmail } from '../utils/zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.union([z.literal(''), zEmail()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { zEmail } from '../utils/zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.union([z.literal(''), zEmail()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
+10
View File
@@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(),
allowConfigureExpirationPeriod: z.boolean(),
allowConfigureReminders: z.boolean(),
allowConfigureEmailSender: z.boolean(),
allowConfigureEmailReplyTo: z.boolean(),
})
@@ -56,6 +57,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowDistributing: z.boolean(),
allowDirectLink: z.boolean(),
allowDuplication: z.boolean(),
allowSaveAsTemplate: z.boolean(),
allowDownloadPDF: z.boolean(),
allowDeletion: z.boolean(),
}),
@@ -71,6 +73,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureOrder: z.boolean(),
allowUpload: z.boolean(),
allowDelete: z.boolean(),
allowReplace: z.boolean(),
})
.nullable(),
@@ -120,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -128,6 +132,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowDistributing: true,
allowDirectLink: true,
allowDuplication: true,
allowSaveAsTemplate: true,
allowDownloadPDF: true,
allowDeletion: true,
},
@@ -136,6 +141,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
allowReplace: true,
},
recipients: {
allowAIDetection: true,
@@ -176,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -184,6 +191,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowDistributing: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDirectLink: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDuplication: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowSaveAsTemplate: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDownloadPDF: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDeletion: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
},
@@ -192,6 +200,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
allowReplace: true,
},
recipients: {
allowAIDetection: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
@@ -265,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
reminderSettings: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
+9 -1
View File
@@ -4,6 +4,7 @@ import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/Rec
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { zEmail } from '../utils/zod';
import { ZFieldSchema } from './field';
/**
@@ -117,7 +118,14 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
templateId: true,
});
export type TRecipientSchema = z.infer<typeof ZRecipientSchema>;
export type TRecipientLite = z.infer<typeof ZRecipientLiteSchema>;
export type TRecipientMany = z.infer<typeof ZRecipientManySchema>;
export type TEnvelopeRecipientSchema = z.infer<typeof ZEnvelopeRecipientSchema>;
export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema>;
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
export const ZRecipientEmailSchema = z.union([
z.literal(''),
z.string().trim().toLowerCase().email({ message: 'Invalid email' }).max(254),
zEmail('Invalid email').trim().toLowerCase().max(254),
]);
+1
View File
@@ -146,6 +146,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
team: TeamSchema.pick({
id: true,
url: true,
name: true,
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),
@@ -41,7 +41,12 @@ export const putPdfFileServerSide = async (file: File, initialData?: string) =>
const { type, data } = await putFileServerSide(file);
return await createDocumentData({ type, data, initialData });
const createdData = await createDocumentData({ type, data, initialData });
return {
documentData: createdData,
filePageCount: pdf.getPageCount(),
};
};
/**
+16
View File
@@ -571,6 +571,22 @@ export const formatDocumentAuditLogAction = (
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED }, () => ({
anonymous: msg({
message: `Envelope item updated`,
context: `Audit log format`,
}),
you: msg`You updated an envelope item`,
user: msg`${user} updated an envelope item`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED }, ({ data }) => ({
anonymous: msg({
message: `Envelope item PDF replaced`,
context: `Audit log format`,
}),
you: msg`You replaced the PDF for envelope item ${data.envelopeItemTitle}`,
user: msg`${user} replaced the PDF for envelope item ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
anonymous: msg({
message: `Recipient signing window expired`,
+3
View File
@@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
// Envelope expiration.
envelopeExpirationPeriod:
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
// Reminder settings.
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
} satisfies Omit<DocumentMeta, 'id'>;
};
+9
View File
@@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = (
allowConfigureExpirationPeriod:
features.settings?.allowConfigureExpirationPeriod ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
allowConfigureReminders:
features.settings?.allowConfigureReminders ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
allowConfigureEmailSender:
features.settings?.allowConfigureEmailSender ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
@@ -88,6 +91,9 @@ export const buildEmbeddedFeatures = (
allowDuplication:
features.actions?.allowDuplication ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
allowSaveAsTemplate:
features.actions?.allowSaveAsTemplate ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowSaveAsTemplate,
allowDownloadPDF:
features.actions?.allowDownloadPDF ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
@@ -110,6 +116,9 @@ export const buildEmbeddedFeatures = (
allowDelete:
features.envelopeItems?.allowDelete ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
allowReplace:
features.envelopeItems?.allowReplace ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowReplace,
}
: null,
+2 -1
View File
@@ -21,6 +21,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
import { zEmail } from '@documenso/lib/utils/zod';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
@@ -37,7 +38,7 @@ export const extractFieldInsertionValues = ({
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
return match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
const parsedEmailValue = zEmail().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
+53 -24
View File
@@ -244,28 +244,57 @@ export const mapSecondaryIdToTemplateId = (secondaryId: string) => {
return parseInt(parsed.data.split('_')[1]);
};
export const canEnvelopeItemsBeModified = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Recipient[],
) => {
if (envelope.completedAt || envelope.deletedAt || envelope.status !== DocumentStatus.DRAFT) {
return false;
}
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (
recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.sendStatus === SendStatus.SENT),
)
) {
return false;
}
return true;
export type EnvelopeItemPermissions = {
canTitleBeChanged: boolean;
canFileBeChanged: boolean;
canOrderBeChanged: boolean;
};
export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
if (
envelope.completedAt ||
envelope.deletedAt ||
envelope.status === DocumentStatus.REJECTED ||
envelope.status === DocumentStatus.COMPLETED
) {
return {
canTitleBeChanged: false,
canFileBeChanged: false,
canOrderBeChanged: false,
};
}
// Templates can always be modified.
if (envelope.type === EnvelopeType.TEMPLATE) {
return {
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
};
}
const hasActiveRecipients = recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.signingStatus === SigningStatus.REJECTED ||
recipient.sendStatus === SendStatus.SENT),
);
return match(envelope.status)
.with(DocumentStatus.DRAFT, () => ({
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
}))
.with(DocumentStatus.PENDING, () => ({
canTitleBeChanged: true,
canFileBeChanged: false,
canOrderBeChanged: !hasActiveRecipients, // Only allow order changes if no active recipients.
}))
.exhaustive();
};
+3
View File
@@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
@@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
aiFeaturesEnabled: false,
};
};
+25 -8
View File
@@ -1,12 +1,13 @@
import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
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 type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod';
/**
* Roles that require fields to be assigned before a document can be distributed.
@@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
recipients: T[],
fields: Pick<Field, 'type' | 'recipientId'>[],
): T[] => {
@@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
/**
* Whether a recipient can be modified by the document owner.
*/
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
export const canRecipientBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!recipient) {
return false;
}
@@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
* - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified)
*/
export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
export const canRecipientFieldsBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!canRecipientBeModified(recipient, fields)) {
return false;
}
@@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
};
export const mapRecipientToLegacyRecipient = (
recipient: Recipient,
recipient: TRecipientLite,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
const legacyId = extractLegacyIds(envelope);
@@ -92,8 +99,18 @@ export const mapRecipientToLegacyRecipient = (
};
};
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
export const findRecipientByEmail = <T extends { email: string }>({
recipients,
userEmail,
teamEmail,
}: {
recipients: T[];
userEmail: string;
teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
return zEmail().safeParse(recipient.email).success;
};
/**
+2
View File
@@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
envelopeExpirationPeriod: null,
reminderSettings: null,
aiFeaturesEnabled: null,
};
};
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
/**
* RFC 5322 compliant email regex.
*
* This is more permissive than Zod's built-in `.email()` validator which rejects
* valid international characters (e.g. "Søren@gmail.com").
*
* Compiled once at module level to avoid re-compilation on every validation call.
*/
const EMAIL_REGEX =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\u{0080}-\u{FFFF}-]+@[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?(?:\.[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?)*$/u;
const DEFAULT_EMAIL_MESSAGE = 'Invalid email address';
/**
* Creates a Zod email schema using an RFC 5322 compliant regex.
*
* Supports international characters in the local part and domain
* (e.g. "Søren@gmail.com", "user@dömain.com").
*
* Returns a standard `ZodString` so all string methods are chainable:
* `.min()`, `.max()`, `.trim()`, `.toLowerCase()`, `.optional()`, `.nullish()`, etc.
*
* @example
* ```ts
* zEmail()
* zEmail().min(1).max(254)
* zEmail().trim().toLowerCase()
* zEmail('Email is invalid')
* zEmail({ message: 'Email is invalid' })
* ```
*/
export const zEmail = (options?: string | { message?: string }) => {
const message =
typeof options === 'string' ? options : (options?.message ?? DEFAULT_EMAIL_MESSAGE);
return z.string().regex(EMAIL_REGEX, { message });
};