mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
Merge branch 'main' into feat/prefetch-intent-navigation-links
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
|
||||
envelopeExpirationPeriod: null,
|
||||
|
||||
reminderSettings: null,
|
||||
|
||||
aiFeaturesEnabled: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user