mirror of
https://github.com/documenso/documenso.git
synced 2026-07-23 00:13:46 +10:00
Merge branch 'main' into feat/add-pdf-image-renderer
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
function dispatchStorageEvent(key: string, newValue: string | null) {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
|
||||
}
|
||||
|
||||
const setSessionStorageItem = <T>(key: string, value: T) => {
|
||||
const stringifiedValue = JSON.stringify(value);
|
||||
window.sessionStorage.setItem(key, stringifiedValue);
|
||||
dispatchStorageEvent(key, stringifiedValue);
|
||||
};
|
||||
|
||||
const removeSessionStorageItem = (key: string) => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
dispatchStorageEvent(key, null);
|
||||
};
|
||||
|
||||
const getSessionStorageItem = (key: string) => {
|
||||
return window.sessionStorage.getItem(key);
|
||||
};
|
||||
|
||||
const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) => {
|
||||
window.addEventListener('storage', callback);
|
||||
return () => window.removeEventListener('storage', callback);
|
||||
};
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const serializedInitialValue = JSON.stringify(initialValue);
|
||||
|
||||
const getSnapshot = () => getSessionStorageItem(key);
|
||||
const getServerSnapshot = () => serializedInitialValue;
|
||||
|
||||
const store = React.useSyncExternalStore(
|
||||
useSessionStorageSubscribe,
|
||||
getSnapshot,
|
||||
getServerSnapshot,
|
||||
);
|
||||
|
||||
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
|
||||
(v) => {
|
||||
try {
|
||||
const prevValue = store ? JSON.parse(store) : initialValue;
|
||||
// @ts-expect-error - SetStateAction function check is safe at runtime
|
||||
const nextState = typeof v === 'function' ? v(prevValue) : v;
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeSessionStorageItem(key);
|
||||
} else {
|
||||
setSessionStorageItem(key, nextState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
[key, store, initialValue],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (getSessionStorageItem(key) === null && typeof initialValue !== 'undefined') {
|
||||
setSessionStorageItem(key, initialValue);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
return [store ? JSON.parse(store) : initialValue, setState];
|
||||
}
|
||||
@@ -8,8 +8,6 @@ export const DOCUMENSO_INTERNAL_EMAIL = {
|
||||
address: FROM_ADDRESS,
|
||||
};
|
||||
|
||||
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
|
||||
|
||||
export const EMAIL_VERIFICATION_STATE = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
VERIFIED: 'VERIFIED',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DurationLikeObject } from 'luxon';
|
||||
import { Duration } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeExpirationDurationPeriod = z.object({
|
||||
unit: z.enum(['day', 'week', 'month', 'year']),
|
||||
amount: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const ZEnvelopeExpirationDisabledPeriod = z.object({
|
||||
disabled: z.literal(true),
|
||||
});
|
||||
|
||||
export const ZEnvelopeExpirationPeriod = z.union([
|
||||
ZEnvelopeExpirationDurationPeriod,
|
||||
ZEnvelopeExpirationDisabledPeriod,
|
||||
]);
|
||||
|
||||
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
|
||||
export type TEnvelopeExpirationDurationPeriod = z.infer<typeof ZEnvelopeExpirationDurationPeriod>;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<
|
||||
TEnvelopeExpirationDurationPeriod['unit'],
|
||||
keyof DurationLikeObject
|
||||
> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
month: 'months',
|
||||
year: 'years',
|
||||
};
|
||||
|
||||
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationDurationPeriod = {
|
||||
unit: 'month',
|
||||
amount: 3,
|
||||
};
|
||||
|
||||
export const getEnvelopeExpirationDuration = (
|
||||
period: TEnvelopeExpirationDurationPeriod,
|
||||
): Duration => {
|
||||
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the concrete expiresAt timestamp from a raw expiration period (from JSON column).
|
||||
*
|
||||
* - `null` means use the default period (3 months).
|
||||
* - `{ disabled: true }` means never expires (returns null).
|
||||
* - `{ unit, amount }` means compute the timestamp from now + duration.
|
||||
*/
|
||||
export const resolveExpiresAt = (rawPeriod: unknown): Date | null => {
|
||||
if (rawPeriod === null || rawPeriod === undefined) {
|
||||
const duration = getEnvelopeExpirationDuration(DEFAULT_ENVELOPE_EXPIRATION_PERIOD);
|
||||
|
||||
return new Date(Date.now() + duration.toMillis());
|
||||
}
|
||||
|
||||
const parsed = ZEnvelopeExpirationPeriod.parse(rawPeriod);
|
||||
|
||||
if ('disabled' in parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = getEnvelopeExpirationDuration(parsed);
|
||||
|
||||
return new Date(Date.now() + duration.toMillis());
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export enum AppErrorCode {
|
||||
'EXPIRED_CODE' = 'EXPIRED_CODE',
|
||||
'INVALID_BODY' = 'INVALID_BODY',
|
||||
'INVALID_REQUEST' = 'INVALID_REQUEST',
|
||||
'RECIPIENT_EXPIRED' = 'RECIPIENT_EXPIRED',
|
||||
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
|
||||
'NOT_FOUND' = 'NOT_FOUND',
|
||||
'NOT_SETUP' = 'NOT_SETUP',
|
||||
@@ -23,6 +24,7 @@ export enum AppErrorCode {
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
||||
{
|
||||
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_EXPIRED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
|
||||
@@ -62,6 +64,11 @@ type AppErrorOptions = {
|
||||
* Mainly used for API -> Frontend communication and logging filtering.
|
||||
*/
|
||||
statusCode?: number;
|
||||
|
||||
/**
|
||||
* Optional headers to include when this error is returned in an API response.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class AppError extends Error {
|
||||
@@ -80,6 +87,8 @@ export class AppError extends Error {
|
||||
*/
|
||||
statusCode?: number;
|
||||
|
||||
headers?: Record<string, string>;
|
||||
|
||||
name = 'AppError';
|
||||
|
||||
/**
|
||||
@@ -95,6 +104,7 @@ export class AppError extends Error {
|
||||
this.code = errorCode;
|
||||
this.userMessage = options?.userMessage;
|
||||
this.statusCode = options?.statusCode;
|
||||
this.headers = options?.headers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
@@ -10,7 +11,10 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
/**
|
||||
@@ -28,9 +32,13 @@ export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
|
||||
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@@ -36,6 +36,11 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
|
||||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
/**
|
||||
* Optional cron expression (e.g., "* * * * *" for every minute).
|
||||
* When set, the job runs on a schedule instead of being event-triggered.
|
||||
*/
|
||||
cron?: string;
|
||||
};
|
||||
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
|
||||
};
|
||||
|
||||
@@ -16,4 +16,14 @@ export abstract class BaseJobProvider {
|
||||
public getApiHandler(): (req: HonoContext) => Promise<Response | void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron scheduler for any registered cron jobs.
|
||||
*
|
||||
* No-op for providers that handle cron scheduling externally (e.g. Inngest).
|
||||
* Must be called explicitly at application startup.
|
||||
*/
|
||||
public startCron(): void {
|
||||
// No-op by default — providers override if needed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,15 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
||||
public getApiHandler() {
|
||||
return this._provider.getApiHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron scheduler for any registered cron jobs.
|
||||
*
|
||||
* Call this once at application startup after the instance is ready to
|
||||
* process requests. No-op for providers that handle cron externally
|
||||
* (e.g. Inngest).
|
||||
*/
|
||||
public startCron() {
|
||||
this._provider.startCron();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,17 @@ export class InngestJobProvider extends BaseJobProvider {
|
||||
}
|
||||
|
||||
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
|
||||
console.log('defining job', job.id);
|
||||
const triggerConfig: { cron: string } | { event: N } = job.trigger.cron
|
||||
? { cron: job.trigger.cron }
|
||||
: { event: job.trigger.name };
|
||||
|
||||
const fn = this._client.createFunction(
|
||||
{
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
optimizeParallelism: job.optimizeParallelism ?? false,
|
||||
},
|
||||
{
|
||||
event: job.trigger.name,
|
||||
},
|
||||
triggerConfig,
|
||||
async (ctx) => {
|
||||
const io = this.convertInngestIoToJobRunIo(ctx);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { BackgroundJobStatus, Prisma } from '@prisma/client';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import type { Context as HonoContext } from 'hono';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -16,10 +17,33 @@ import {
|
||||
import type { Json } from './_internal/json';
|
||||
import { BaseJobProvider } from './base';
|
||||
|
||||
/**
|
||||
* Build a deterministic BackgroundJob ID for a cron run so that multiple
|
||||
* instances of the local provider racing to enqueue the same slot will
|
||||
* collide on the primary key instead of creating duplicates.
|
||||
*/
|
||||
const createCronRunId = (jobId: string, scheduledFor: Date): string => {
|
||||
const key = `cron:${jobId}:${scheduledFor.toISOString()}`;
|
||||
const hash = Buffer.from(sha256(key)).toString('hex').slice(0, 24);
|
||||
|
||||
return `cron_${hash}`;
|
||||
};
|
||||
|
||||
type CronJobEntry = {
|
||||
definition: JobDefinition;
|
||||
cron: string;
|
||||
lastTickAt: Date;
|
||||
};
|
||||
|
||||
const CRON_POLL_INTERVAL_MS = 30_000; // 30 seconds
|
||||
const CRON_POLL_JITTER_MS = 5_000; // 0-5 seconds random offset
|
||||
|
||||
export class LocalJobProvider extends BaseJobProvider {
|
||||
private static _instance: LocalJobProvider;
|
||||
|
||||
private _jobDefinitions: Record<string, JobDefinition> = {};
|
||||
private _cronJobs: CronJobEntry[] = [];
|
||||
private _cronPoller: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
@@ -38,6 +62,133 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
...definition,
|
||||
enabled: definition.enabled ?? true,
|
||||
};
|
||||
|
||||
if (definition.trigger.cron && definition.enabled !== false) {
|
||||
const alreadyRegistered = this._cronJobs.some((job) => job.definition.id === definition.id);
|
||||
|
||||
if (!alreadyRegistered) {
|
||||
this._cronJobs.push({
|
||||
definition: {
|
||||
...definition,
|
||||
enabled: definition.enabled ?? true,
|
||||
},
|
||||
cron: definition.trigger.cron,
|
||||
lastTickAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the single cron poller for all registered cron jobs.
|
||||
*
|
||||
* Must be called explicitly at application startup after all jobs have been
|
||||
* defined. The poller runs every 30 seconds (+ random jitter to avoid
|
||||
* thundering herd across instances) and evaluates all registered cron jobs
|
||||
* for due slots.
|
||||
*
|
||||
* For each due slot it creates a BackgroundJob row with a deterministic ID.
|
||||
* If the insert succeeds the job is dispatched; if it fails with a unique
|
||||
* constraint violation (P2002) another instance already claimed that slot.
|
||||
*/
|
||||
public override startCron() {
|
||||
if (this._cronPoller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._cronJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const jitter = Math.floor(Math.random() * CRON_POLL_JITTER_MS);
|
||||
|
||||
this._cronPoller = setTimeout(() => {
|
||||
void this.processCronTick().finally(tick);
|
||||
}, CRON_POLL_INTERVAL_MS + jitter);
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
console.log(`[JOBS]: Started cron poller for ${this._cronJobs.length} job(s)`);
|
||||
}
|
||||
|
||||
private async processCronTick() {
|
||||
for (const cronJob of this._cronJobs) {
|
||||
try {
|
||||
const dueSlots = this.getDueCronSlots(cronJob);
|
||||
|
||||
cronJob.lastTickAt = new Date();
|
||||
|
||||
if (dueSlots.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only take the latest slot — sweep-style jobs don't need to catch up
|
||||
// every missed slot after downtime, just the most recent one.
|
||||
const scheduledFor = dueSlots[dueSlots.length - 1];
|
||||
const deterministicId = createCronRunId(cronJob.definition.id, scheduledFor);
|
||||
|
||||
const pendingJob = await prisma.backgroundJob
|
||||
.create({
|
||||
data: {
|
||||
id: deterministicId,
|
||||
jobId: cronJob.definition.id,
|
||||
name: cronJob.definition.name,
|
||||
version: cronJob.definition.version,
|
||||
payload: { scheduledFor: scheduledFor.toISOString() },
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// P2002 = unique constraint violation — another instance already enqueued this slot.
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!pendingJob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.submitJobToEndpoint({
|
||||
jobId: pendingJob.id,
|
||||
jobDefinitionId: pendingJob.jobId,
|
||||
data: {
|
||||
name: cronJob.definition.trigger.name,
|
||||
payload: { scheduledFor: scheduledFor.toISOString() },
|
||||
},
|
||||
isRetry: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[JOBS]: Cron tick failed for ${cronJob.definition.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use cron-parser to find all cron slots that are due between the last tick
|
||||
* and now.
|
||||
*/
|
||||
private getDueCronSlots(cronJob: CronJobEntry): Date[] {
|
||||
const expr = CronExpressionParser.parse(cronJob.cron, {
|
||||
currentDate: cronJob.lastTickAt,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const slots: Date[] = [];
|
||||
|
||||
let next = expr.next();
|
||||
|
||||
while (next.toDate() <= now) {
|
||||
slots.push(next.toDate());
|
||||
next = expr.next();
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
public async triggerJob(options: SimpleTriggerJobOptions) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../../utils/teams';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendOwnerRecipientExpiredEmailJobDefinition } from './send-owner-recipient-expired-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendOwnerRecipientExpiredEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { recipientId, envelopeId } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error(`Envelope ${envelopeId} not found`);
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Recipient ${recipientId} not found on envelope ${envelopeId}`);
|
||||
}
|
||||
|
||||
const { documentMeta, user: documentOwner } = envelope;
|
||||
|
||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).ownerRecipientExpired;
|
||||
|
||||
if (!isEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`;
|
||||
|
||||
const template = createElement(RecipientExpiredTemplate, {
|
||||
documentName: envelope.title,
|
||||
recipientName: recipient.name || recipient.email,
|
||||
recipientEmail: recipient.email,
|
||||
documentLink,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
await io.runTask('send-owner-recipient-expired-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: documentOwner.name || '',
|
||||
address: documentOwner.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(
|
||||
msg`Signing window expired for "${recipient.name || recipient.email}" on "${envelope.title}"`,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID = 'send.owner.recipient.expired.email';
|
||||
|
||||
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
recipientId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export type TSendOwnerRecipientExpiredEmailJobDefinition = z.infer<
|
||||
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Owner Recipient Expired Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-owner-recipient-expired-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendOwnerRecipientExpiredEmailJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TCleanupRateLimitsJobDefinition } from './cleanup-rate-limits';
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
export const run = async ({ io }: { payload: TCleanupRateLimitsJobDefinition; io: JobRunIO }) => {
|
||||
const cutoff = DateTime.now().minus({ hours: 24 }).toJSDate();
|
||||
|
||||
let totalDeleted = 0;
|
||||
let deleted = 0;
|
||||
|
||||
do {
|
||||
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
|
||||
// to avoid long-running transactions that could lock the table.
|
||||
deleted = await prisma.$executeRaw`
|
||||
DELETE FROM "RateLimit"
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
LIMIT ${BATCH_SIZE}
|
||||
)
|
||||
`;
|
||||
|
||||
totalDeleted += deleted;
|
||||
} while (deleted >= BATCH_SIZE);
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
io.logger.info(`Cleaned up ${totalDeleted} expired rate limit entries`);
|
||||
} else {
|
||||
io.logger.info('No expired rate limit entries to clean up');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
|
||||
|
||||
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TCleanupRateLimitsJobDefinition = z.infer<
|
||||
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
|
||||
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
name: 'Cleanup Rate Limits',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cleanup-rate-limits.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
TCleanupRateLimitsJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TExpireRecipientsSweepJobDefinition } from './expire-recipients-sweep';
|
||||
|
||||
export const run = async ({
|
||||
io,
|
||||
}: {
|
||||
payload: TExpireRecipientsSweepJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
|
||||
const expiredRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: now,
|
||||
},
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: {
|
||||
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
|
||||
},
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
take: 1000, // Limit to 1000 to avoid long-running jobs. Will be picked up in the next run if there are more.
|
||||
});
|
||||
|
||||
if (expiredRecipients.length === 0) {
|
||||
io.logger.info('No expired recipients found');
|
||||
return;
|
||||
}
|
||||
|
||||
io.logger.info(`Found ${expiredRecipients.length} expired recipients`);
|
||||
|
||||
await Promise.allSettled(
|
||||
expiredRecipients.map(async (recipient) => {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.process-recipient-expired',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID = 'internal.expire-recipients-sweep';
|
||||
|
||||
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TExpireRecipientsSweepJobDefinition = z.infer<
|
||||
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION = {
|
||||
id: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
name: 'Expire Recipients Sweep',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
schema: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./expire-recipients-sweep.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
|
||||
TExpireRecipientsSweepJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../../types/webhook-payload';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TProcessRecipientExpiredJobDefinition } from './process-recipient-expired';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TProcessRecipientExpiredJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { recipientId } = payload;
|
||||
|
||||
// Atomic idempotency guard — only one concurrent worker wins.
|
||||
// Wrapping in runTask caches the result so that on retry the claim is not
|
||||
// re-evaluated and subsequent steps can still proceed.
|
||||
const claimedCount = await io.runTask('claim-recipient', async () => {
|
||||
const result = await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipientId,
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
|
||||
},
|
||||
data: { expirationNotifiedAt: new Date() },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
if (claimedCount === 0) {
|
||||
io.logger.info(`Recipient ${recipientId} already processed or no longer eligible, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch recipient (now marked) with its envelope for downstream steps.
|
||||
// Re-fetch after claiming so that expirationNotifiedAt reflects the updated value
|
||||
// and webhook consumers see consistent state.
|
||||
const recipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipientId },
|
||||
include: {
|
||||
envelope: {
|
||||
include: { recipients: true, documentMeta: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { envelope } = recipient;
|
||||
|
||||
io.logger.info(
|
||||
`Recipient ${recipientId} (${recipient.email}) expired on envelope ${recipient.envelopeId}`,
|
||||
);
|
||||
|
||||
// Create audit log entry — wrapped so a retry skips this if it already succeeded.
|
||||
await io.runTask('create-audit-log', async () => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger webhook for recipient expiration.
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.RECIPIENT_EXPIRED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
// Trigger email notification to the document owner.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.owner.recipient.expired.email',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID = 'internal.process-recipient-expired';
|
||||
|
||||
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TProcessRecipientExpiredJobDefinition = z.infer<
|
||||
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION = {
|
||||
id: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
name: 'Process Recipient Expired',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
schema: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./process-recipient-expired.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
|
||||
TProcessRecipientExpiredJobDefinition
|
||||
>;
|
||||
@@ -15,11 +15,11 @@ import { groupBy } from 'remeda';
|
||||
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-size';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
|
||||
import { PDF_SIZE_A4_72PPI } from '../../../constants/pdf';
|
||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
||||
@@ -29,7 +29,10 @@ import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf
|
||||
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import {
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
type TDocumentAuditLog,
|
||||
} from '../../../types/document-audit-logs';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@@ -169,56 +172,38 @@ export const run = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let certificateDoc: PDF | null = null;
|
||||
let auditLogDoc: PDF | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const envelopeCompletedAuditLog = createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (settings.includeSigningCertificate || settings.includeAuditLog) {
|
||||
const certificatePayload = {
|
||||
envelope,
|
||||
recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients.
|
||||
fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
envelopeItems: envelopeItems.map((item) => item.title),
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
};
|
||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||
|
||||
// Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation.
|
||||
// This is a temporary toggle while we validate the Konva-based approach.
|
||||
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
||||
// Pre-fetch all PDF data so we can read dimensions and pass it
|
||||
// to decorateAndSignPdf without fetching again.
|
||||
const prefetchedItems = await Promise.all(
|
||||
envelopeItems.map(async (envelopeItem) => {
|
||||
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
const makeCertificatePdf = async () =>
|
||||
usePlaywrightPdf
|
||||
? getCertificatePdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateCertificatePdf(certificatePayload);
|
||||
return { envelopeItem, pdfData };
|
||||
}),
|
||||
);
|
||||
|
||||
const makeAuditLogPdf = async () =>
|
||||
usePlaywrightPdf
|
||||
? getAuditLogsPdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateAuditLogPdf(certificatePayload);
|
||||
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
||||
|
||||
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
|
||||
settings.includeSigningCertificate ? makeCertificatePdf() : null,
|
||||
settings.includeAuditLog ? makeAuditLogPdf() : null,
|
||||
]);
|
||||
|
||||
certificateDoc = createdCertificatePdf;
|
||||
auditLogDoc = createdAuditLogPdf;
|
||||
}
|
||||
const needsCertificate = settings.includeSigningCertificate;
|
||||
const needsAuditLog = settings.includeAuditLog;
|
||||
|
||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||
|
||||
for (const envelopeItem of envelopeItems) {
|
||||
for (const { envelopeItem, pdfData } of prefetchedItems) {
|
||||
const envelopeItemFields = envelope.envelopeItems.find(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
@@ -227,12 +212,70 @@ export const run = async ({
|
||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||
}
|
||||
|
||||
let certificateDoc: PDF | null = null;
|
||||
let auditLogDoc: PDF | null = null;
|
||||
|
||||
if (needsCertificate || needsAuditLog) {
|
||||
const pdfDoc = await PDF.load(pdfData);
|
||||
|
||||
const { width: pageWidth, height: pageHeight } = getLastPageDimensions(pdfDoc);
|
||||
|
||||
const additionalAuditLogs = [
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
{
|
||||
...envelopeCompletedAuditLog,
|
||||
id: '',
|
||||
createdAt: new Date(),
|
||||
} as TDocumentAuditLog,
|
||||
];
|
||||
|
||||
const certificatePayload = {
|
||||
envelope: {
|
||||
...envelope,
|
||||
status: finalEnvelopeStatus,
|
||||
},
|
||||
recipients: envelope.recipients,
|
||||
fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
envelopeItems: envelopeItems.map((item) => item.title),
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
additionalAuditLogs,
|
||||
};
|
||||
|
||||
const makeCertificatePdf = async () =>
|
||||
usePlaywrightPdf
|
||||
? getCertificatePdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateCertificatePdf(certificatePayload);
|
||||
|
||||
const makeAuditLogPdf = async () =>
|
||||
usePlaywrightPdf
|
||||
? getAuditLogsPdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateAuditLogPdf(certificatePayload);
|
||||
|
||||
[certificateDoc, auditLogDoc] = await Promise.all([
|
||||
needsCertificate ? makeCertificatePdf() : null,
|
||||
needsAuditLog ? makeAuditLogPdf() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
const result = await decorateAndSignPdf({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
pdfData,
|
||||
certificateDoc,
|
||||
auditLogDoc,
|
||||
});
|
||||
@@ -258,22 +301,13 @@ export const run = async ({
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||
status: finalEnvelopeStatus,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
||||
},
|
||||
}),
|
||||
data: envelopeCompletedAuditLog,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,12 +359,13 @@ type DecorateAndSignPdfOptions = {
|
||||
envelopeItemFields: Field[];
|
||||
isRejected: boolean;
|
||||
rejectionReason: string;
|
||||
pdfData: Uint8Array;
|
||||
certificateDoc: PDF | null;
|
||||
auditLogDoc: PDF | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch, normalize, flatten and insert fields into a PDF document.
|
||||
* Normalize, flatten and insert fields into a PDF document.
|
||||
*/
|
||||
const decorateAndSignPdf = async ({
|
||||
envelope,
|
||||
@@ -338,11 +373,10 @@ const decorateAndSignPdf = async ({
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
pdfData,
|
||||
certificateDoc,
|
||||
auditLogDoc,
|
||||
}: DecorateAndSignPdfOptions) => {
|
||||
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
let pdfDoc = await PDF.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
@@ -94,6 +95,8 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -9,6 +9,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
|
||||
export type RejectDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -42,6 +43,14 @@ export async function rejectDocumentWithToken({
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} must be pending to reject`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Update the recipient status to rejected
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
@@ -94,11 +95,31 @@ export const resendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
// Refresh the expiresAt on each resent recipient.
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
|
||||
recipients.includes(recipient.id) &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC,
|
||||
);
|
||||
|
||||
// Extend the expiration deadline for recipients being resent.
|
||||
if (expiresAt && recipientsToRemind.length > 0) {
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: recipientsToRemind.map((r) => r.id),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
expiresAt,
|
||||
expirationNotifiedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
@@ -257,6 +258,28 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
// Set expiresAt on each recipient that hasn't already signed/rejected.
|
||||
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
|
||||
if (expiresAt) {
|
||||
await tx.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: {
|
||||
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
|
||||
},
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
expiresAt,
|
||||
expirationNotifiedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@@ -163,6 +163,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
isRecipientsTurn: true,
|
||||
isCompleted: false,
|
||||
isRejected: false,
|
||||
isExpired: false,
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
|
||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||
import { isRecipientExpired } from '../../utils/recipients';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
@@ -56,7 +57,9 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, //!: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
@@ -102,7 +105,8 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
token: true,
|
||||
@@ -126,6 +130,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
|
||||
isCompleted: z.boolean(),
|
||||
isRejected: z.boolean(),
|
||||
isExpired: z.boolean(),
|
||||
isRecipientsTurn: z.boolean(),
|
||||
|
||||
sender: z.object({
|
||||
@@ -291,6 +296,7 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
isRejected:
|
||||
recipient.signingStatus === SigningStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.REJECTED,
|
||||
isExpired: isRecipientExpired(recipient),
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
@@ -56,6 +57,8 @@ export const removeSignedFieldWithToken = async ({
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (
|
||||
recipient?.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
@@ -108,6 +109,8 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Document ${envelope.id} must be pending for signing`);
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PDF, rgb } from '@libpdf/core';
|
||||
import type { FieldType, Recipient } from '@prisma/client';
|
||||
|
||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||
|
||||
@@ -117,7 +117,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import { type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
|
||||
@@ -12,15 +13,24 @@ import { renderAuditLogs } from './render-audit-logs';
|
||||
|
||||
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
|
||||
envelopeItems: string[];
|
||||
additionalAuditLogs?: TDocumentAuditLog[];
|
||||
};
|
||||
|
||||
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
|
||||
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
|
||||
options;
|
||||
const {
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
envelopeItems,
|
||||
recipients,
|
||||
language,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
additionalAuditLogs = [],
|
||||
} = options;
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
|
||||
|
||||
const [organisationClaim, auditLogs, messages] = await Promise.all([
|
||||
const [organisationClaim, partialAuditLogs, messages] = await Promise.all([
|
||||
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
|
||||
getAuditLogs(envelope.id),
|
||||
getTranslations(documentLanguage),
|
||||
@@ -31,6 +41,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
|
||||
messages,
|
||||
});
|
||||
|
||||
const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs];
|
||||
|
||||
const auditLogPages = await renderAuditLogs({
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
|
||||
@@ -16,7 +16,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c
|
||||
import { renderCertificate } from './render-certificate';
|
||||
|
||||
export type GenerateCertificatePdfOptions = {
|
||||
envelope: Envelope & {
|
||||
/**
|
||||
* Note: completedAt is not included since it's not real at this point in time.
|
||||
*
|
||||
* If we actually need it here in the future, we will need to preserve the
|
||||
* completedAt value and pass it to the final `envelope.update` function when
|
||||
* the document is initially sealed.
|
||||
*/
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeOwner: {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { PDFPage } from '@cantoo/pdf-lib';
|
||||
import type { PDF } from '@libpdf/core';
|
||||
|
||||
import { PDF_SIZE_A4_72PPI } from '../../constants/pdf';
|
||||
|
||||
const MIN_CERT_PAGE_WIDTH = 300;
|
||||
const MIN_CERT_PAGE_HEIGHT = 300;
|
||||
|
||||
/**
|
||||
* Gets the effective page size for PDF operations.
|
||||
@@ -16,3 +22,20 @@ export const getPageSize = (page: PDFPage) => {
|
||||
|
||||
return cropBox;
|
||||
};
|
||||
|
||||
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
|
||||
if (!lastPage) {
|
||||
return PDF_SIZE_A4_72PPI;
|
||||
}
|
||||
|
||||
const width = Math.round(lastPage.width);
|
||||
const height = Math.round(lastPage.height);
|
||||
|
||||
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
|
||||
return PDF_SIZE_A4_72PPI;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export type AuditLogRecipient = {
|
||||
};
|
||||
|
||||
type GenerateAuditLogsOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
@@ -168,7 +168,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
|
||||
};
|
||||
|
||||
type RenderOverviewCardOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
|
||||
@@ -78,6 +78,7 @@ const getDevice = (userAgent?: string | null): string => {
|
||||
const textMutedForegroundLight = '#929DAE';
|
||||
const textForeground = '#000';
|
||||
const textMutedForeground = '#64748B';
|
||||
const textRejectedRed = '#dc2626';
|
||||
const textBase = 10;
|
||||
const textSm = 9;
|
||||
const textXs = 8;
|
||||
@@ -97,6 +98,8 @@ type RenderLabelAndTextOptions = {
|
||||
text: string;
|
||||
width: number;
|
||||
y?: number;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
@@ -106,13 +109,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
y,
|
||||
});
|
||||
|
||||
const labelFill = options.labelFill ?? textMutedForeground;
|
||||
const valueFill = options.valueFill ?? textMutedForeground;
|
||||
|
||||
const label = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: `${options.label}: `,
|
||||
fontStyle: fontMedium,
|
||||
fontFamily: 'Inter',
|
||||
fill: textMutedForeground,
|
||||
fill: labelFill,
|
||||
fontSize: textSm,
|
||||
});
|
||||
|
||||
@@ -124,7 +130,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
width: width - label.width(),
|
||||
fontFamily: 'Inter',
|
||||
text: options.text,
|
||||
fill: textMutedForeground,
|
||||
fill: valueFill,
|
||||
wrap: 'char',
|
||||
fontSize: textSm,
|
||||
});
|
||||
@@ -269,6 +275,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const columnWidth = width - columnPadding;
|
||||
|
||||
const isRejected = Boolean(recipient.logs.rejected);
|
||||
|
||||
if (recipient.signatureField?.secondaryId) {
|
||||
// Signature container with green border
|
||||
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
|
||||
@@ -313,7 +321,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
signatureContainer.add(typedSig);
|
||||
}
|
||||
|
||||
column.add(signatureContainer);
|
||||
// Do not add the signature container for rejected recipients.
|
||||
if (!isRejected) {
|
||||
column.add(signatureContainer);
|
||||
}
|
||||
|
||||
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
|
||||
|
||||
@@ -342,7 +353,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
// Signature ID
|
||||
const sigIdLabel = new Konva.Text({
|
||||
x: 0,
|
||||
y: signatureHeight + 10,
|
||||
y: isRejected ? 0 : signatureHeight + 10,
|
||||
text: `${i18n._(msg`Signature ID`)}:`,
|
||||
fill: textMutedForeground,
|
||||
width: columnWidth,
|
||||
@@ -376,9 +387,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
column.add(naText);
|
||||
}
|
||||
|
||||
const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed;
|
||||
|
||||
const ipLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`IP Address`),
|
||||
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -386,7 +399,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const deviceLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`Device`),
|
||||
text: getDevice(recipient.logs.completed?.userAgent),
|
||||
text: getDevice(relevantLog?.userAgent),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -400,7 +413,14 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
|
||||
const column = new Konva.Group();
|
||||
|
||||
const itemsToRender = [
|
||||
type DetailItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const itemsToRender: DetailItem[] = [
|
||||
{
|
||||
label: i18n._(msg`Sent`),
|
||||
value: recipient.logs.emailed
|
||||
@@ -429,6 +449,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
|
||||
labelFill: textRejectedRed,
|
||||
valueFill: textRejectedRed,
|
||||
});
|
||||
} else {
|
||||
itemsToRender.push({
|
||||
@@ -459,6 +481,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
text: item.value,
|
||||
width,
|
||||
y: column.getClientRect().height + (index === 0 ? 0 : 8),
|
||||
labelFill: item.labelFill,
|
||||
valueFill: item.valueFill,
|
||||
});
|
||||
column.add(labelAndText);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { MiddlewareHandler } from 'hono/types';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getIpAddress } from '../../universal/get-ip-address';
|
||||
import type { RateLimitCheckResult } from './rate-limit';
|
||||
import type { createRateLimit } from './rate-limit';
|
||||
|
||||
/**
|
||||
* Set rate limit response headers on a Hono context.
|
||||
*/
|
||||
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Hono middleware that applies rate limiting to a route.
|
||||
*
|
||||
* Uses IP address for identification. Optionally accepts an identifier
|
||||
* function for per-user/per-entity limiting.
|
||||
*/
|
||||
export const createRateLimitMiddleware = (
|
||||
limiter: ReturnType<typeof createRateLimit>,
|
||||
options?: { identifierFn?: (c: Context) => string | undefined },
|
||||
): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
let ip: string;
|
||||
|
||||
try {
|
||||
ip = getIpAddress(c.req.raw);
|
||||
} catch {
|
||||
ip = 'unknown';
|
||||
}
|
||||
|
||||
const identifier = options?.identifierFn?.(c);
|
||||
|
||||
const result = await limiter.check({ ip, identifier });
|
||||
|
||||
setRateLimitHeaders(c, result);
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header(
|
||||
'Retry-After',
|
||||
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
|
||||
);
|
||||
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for inline rate limit checks in Hono auth routes.
|
||||
*
|
||||
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
|
||||
*/
|
||||
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
|
||||
setRateLimitHeaders(c, result);
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header(
|
||||
'Retry-After',
|
||||
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
|
||||
);
|
||||
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for inline rate limit checks in tRPC routes.
|
||||
*
|
||||
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
|
||||
*/
|
||||
export const assertRateLimit = (result: RateLimitCheckResult): void => {
|
||||
if (result.isLimited) {
|
||||
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
|
||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||
message: 'Too many requests, please try again later.',
|
||||
headers: {
|
||||
'X-RateLimit-Limit': String(result.limit),
|
||||
'X-RateLimit-Remaining': String(result.remaining),
|
||||
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
|
||||
'Retry-After': retryAfter,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
type WindowUnit = 's' | 'm' | 'h' | 'd';
|
||||
type WindowStr = `${number}${WindowUnit}`;
|
||||
|
||||
type RateLimitConfig = {
|
||||
action: string;
|
||||
max: number;
|
||||
globalMax?: number;
|
||||
window: WindowStr;
|
||||
};
|
||||
|
||||
type CheckParams = {
|
||||
ip: string;
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
export type RateLimitCheckResult = {
|
||||
isLimited: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
reset: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
|
||||
*/
|
||||
export const parseWindow = (window: WindowStr): number => {
|
||||
const value = parseInt(window.slice(0, -1), 10);
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const unit = window.slice(-1) as WindowUnit;
|
||||
|
||||
const multipliers: Record<WindowUnit, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return value * multipliers[unit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the current time bucket for the given window size.
|
||||
*/
|
||||
export const getBucket = (windowMs: number): Date => {
|
||||
const now = Date.now();
|
||||
|
||||
return new Date(now - (now % windowMs));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a rate limiter with the given configuration.
|
||||
*
|
||||
* Uses bucketed counters in the database for distributed rate limiting
|
||||
* across multiple instances. Each check atomically increments the counter
|
||||
* and returns the new count.
|
||||
*/
|
||||
export const createRateLimit = (config: RateLimitConfig) => {
|
||||
const windowMs = parseWindow(config.window);
|
||||
|
||||
return {
|
||||
async check(params: CheckParams): Promise<RateLimitCheckResult> {
|
||||
const bucket = getBucket(windowMs);
|
||||
const reset = new Date(bucket.getTime() + windowMs);
|
||||
const ipLimit = config.globalMax ?? config.max;
|
||||
|
||||
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: ipLimit,
|
||||
limit: ipLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Always upsert the IP counter.
|
||||
const ipResult = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Check IP against globalMax if set, or against max if no identifier is provided.
|
||||
let ipCheckLimit = config.globalMax;
|
||||
|
||||
if (!params.identifier) {
|
||||
ipCheckLimit = config.max;
|
||||
}
|
||||
|
||||
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
|
||||
logger.warn({
|
||||
msg: 'Rate limit exceeded',
|
||||
action: config.action,
|
||||
keyType: 'ip',
|
||||
key: params.ip,
|
||||
count: ipResult.count,
|
||||
limit: ipCheckLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
isLimited: true,
|
||||
remaining: 0,
|
||||
limit: ipCheckLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Upsert the identifier counter if provided.
|
||||
if (params.identifier) {
|
||||
const identifierResult = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `id:${params.identifier}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `id:${params.identifier}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (identifierResult.count > config.max) {
|
||||
logger.warn({
|
||||
msg: 'Rate limit exceeded',
|
||||
action: config.action,
|
||||
keyType: 'identifier',
|
||||
key: params.identifier,
|
||||
count: identifierResult.count,
|
||||
limit: config.max,
|
||||
});
|
||||
|
||||
return {
|
||||
isLimited: true,
|
||||
remaining: 0,
|
||||
limit: config.max,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: Math.max(0, config.max - identifierResult.count),
|
||||
limit: config.max,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: Math.max(0, ipLimit - ipResult.count),
|
||||
limit: ipLimit,
|
||||
reset,
|
||||
};
|
||||
} catch (error) {
|
||||
// Fail-open: if the rate limit DB query fails, allow the request through.
|
||||
logger.error({
|
||||
msg: 'Rate limit check failed, failing open',
|
||||
action: config.action,
|
||||
error,
|
||||
});
|
||||
|
||||
const limit = params.identifier ? config.max : ipLimit;
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: limit,
|
||||
limit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRateLimit } from './rate-limit';
|
||||
|
||||
// ---- Auth (Tier 1 - Critical, sends emails) ----
|
||||
|
||||
export const signupRateLimit = createRateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 10,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.forgot-password',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const resendVerifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.resend-verify-email',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const request2FAEmailRateLimit = createRateLimit({
|
||||
action: 'auth.request-2fa-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Auth (Tier 2 - Unauthenticated) ----
|
||||
|
||||
export const loginRateLimit = createRateLimit({
|
||||
action: 'auth.login',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const resetPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.reset-password',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const verifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.verify-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const passkeyRateLimit = createRateLimit({
|
||||
action: 'auth.passkey',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = createRateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = createRateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiTrpcRateLimit = createRateLimit({
|
||||
action: 'api.trpc',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const aiRateLimit = createRateLimit({
|
||||
action: 'api.ai',
|
||||
max: 3,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const fileUploadRateLimit = createRateLimit({
|
||||
action: 'api.file-upload',
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
|
||||
@@ -119,6 +120,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
formValues?: TDocumentFormValues;
|
||||
@@ -521,6 +523,8 @@ export const createDocumentFromTemplate = async ({
|
||||
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner:
|
||||
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod:
|
||||
override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface GetUserByResetTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getUserByResetToken = async ({ token }: GetUserByResetTokenOptions) => {
|
||||
const result = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result || !result.user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
@@ -1,9 +1,27 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
const LEGACY_DELETED_ACCOUNT_EMAIL = 'deleted-account@documenso.com';
|
||||
|
||||
export const deletedServiceAccountEmail = () => {
|
||||
try {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
if (process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
return process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL;
|
||||
}
|
||||
|
||||
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
|
||||
|
||||
return `deleted-account@${hostname}`;
|
||||
} catch (error) {
|
||||
return LEGACY_DELETED_ACCOUNT_EMAIL;
|
||||
}
|
||||
};
|
||||
|
||||
export const deletedAccountServiceAccount = async () => {
|
||||
const serviceAccount = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: 'deleted-account@documenso.com',
|
||||
email: deletedServiceAccountEmail(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -29,3 +47,20 @@ export const deletedAccountServiceAccount = async () => {
|
||||
|
||||
return serviceAccount;
|
||||
};
|
||||
|
||||
export const migrateDeletedAccountServiceAccount = async () => {
|
||||
if (deletedServiceAccountEmail() !== LEGACY_DELETED_ACCOUNT_EMAIL) {
|
||||
console.log(
|
||||
`Migrating deleted account service account to new email: ${deletedServiceAccountEmail()}`,
|
||||
);
|
||||
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
email: LEGACY_DELETED_ACCOUNT_EMAIL,
|
||||
},
|
||||
data: {
|
||||
email: deletedServiceAccountEmail(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
const LEGACY_SERVICE_ACCOUNT_EMAIL = 'serviceaccount@documenso.com';
|
||||
|
||||
export const legacyServiceAccountEmail = () => {
|
||||
try {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
if (process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
return process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL;
|
||||
}
|
||||
|
||||
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
|
||||
|
||||
return `serviceaccount@${hostname}`;
|
||||
} catch (error) {
|
||||
return LEGACY_SERVICE_ACCOUNT_EMAIL;
|
||||
}
|
||||
};
|
||||
|
||||
export const migrateLegacyServiceAccount = async () => {
|
||||
if (legacyServiceAccountEmail() !== LEGACY_SERVICE_ACCOUNT_EMAIL) {
|
||||
console.log(`Migrating legacy service account to new email: ${legacyServiceAccountEmail()}`);
|
||||
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
email: LEGACY_SERVICE_ACCOUNT_EMAIL,
|
||||
},
|
||||
data: {
|
||||
email: legacyServiceAccountEmail(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,12 @@ export const submitSupportTicket = async ({
|
||||
organisationId,
|
||||
teamId,
|
||||
}: SubmitSupportTicketOptions) => {
|
||||
if (!plainClient) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Support ticket system is not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -52,6 +58,29 @@ export const submitSupportTicket = async ({
|
||||
})
|
||||
: null;
|
||||
|
||||
// Ensure the customer exists in Plain before creating a thread
|
||||
const plainCustomer = await plainClient.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email,
|
||||
},
|
||||
onCreate: {
|
||||
// If the user doesn't have a name, default to their email
|
||||
fullName: user.name || user.email,
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: !!user.emailVerified,
|
||||
},
|
||||
},
|
||||
// No need to update the customer if it already exists
|
||||
onUpdate: {},
|
||||
});
|
||||
|
||||
if (plainCustomer.error) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to create customer in support system: ${plainCustomer.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
const customMessage = `
|
||||
Organisation: ${organisation.name} (${organisation.id})
|
||||
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
|
||||
@@ -60,12 +89,14 @@ ${message}`;
|
||||
|
||||
const res = await plainClient.createThread({
|
||||
title: subject,
|
||||
customerIdentifier: { emailAddress: user.email },
|
||||
customerIdentifier: { customerId: plainCustomer.data.customer.id },
|
||||
components: [{ componentText: { text: customMessage } }],
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message);
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to create support ticket: ${res.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
@@ -61,7 +61,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'John Doe',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
@@ -81,7 +82,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'John Doe',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
@@ -120,7 +122,8 @@ export const generateSampleWebhookPayload = (
|
||||
role: RecipientRole.VIEWER,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
@@ -139,7 +142,8 @@ export const generateSampleWebhookPayload = (
|
||||
role: RecipientRole.SIGNER,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
@@ -168,7 +172,8 @@ export const generateSampleWebhookPayload = (
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
@@ -185,7 +190,8 @@ export const generateSampleWebhookPayload = (
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
@@ -222,7 +228,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
@@ -243,7 +250,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
@@ -270,7 +278,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer 2',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -291,7 +300,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -314,7 +324,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer 2',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -335,7 +346,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: now,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -374,7 +386,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
@@ -391,7 +404,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
@@ -425,6 +439,7 @@ export const generateSampleWebhookPayload = (
|
||||
recipientRemoved: true,
|
||||
documentCompleted: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
recipientSigningRequest: true,
|
||||
},
|
||||
},
|
||||
@@ -437,7 +452,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer 1',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -460,7 +476,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -480,5 +497,53 @@ export const generateSampleWebhookPayload = (
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.RECIPIENT_EXPIRED) {
|
||||
const expiresAt = new Date(now.getTime() - 60 * 1000); // Expired 1 minute ago
|
||||
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
status: DocumentStatus.PENDING,
|
||||
recipients: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
email: 'signer1@documenso.com',
|
||||
name: 'Signer 1',
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expiresAt,
|
||||
expirationNotifiedAt: now,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
Recipient: [
|
||||
{
|
||||
...basePayload.Recipient[0],
|
||||
email: 'signer1@documenso.com',
|
||||
name: 'Signer 1',
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expiresAt,
|
||||
expirationNotifiedAt: now,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported event type: ${event}`);
|
||||
};
|
||||
|
||||
@@ -67,7 +67,9 @@ export const listDocumentsHandler = async (req: Request) => {
|
||||
name: recipient.name,
|
||||
token: recipient.token,
|
||||
documentDeletedAt: recipient.documentDeletedAt,
|
||||
expired: recipient.expired,
|
||||
expired: recipient.expired, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: recipient.expiresAt,
|
||||
expirationNotifiedAt: recipient.expirationNotifiedAt,
|
||||
signedAt: recipient.signedAt,
|
||||
authOptions: recipient.authOptions,
|
||||
signingOrder: recipient.signingOrder,
|
||||
|
||||
+804
-246
File diff suppressed because it is too large
Load Diff
+745
-188
File diff suppressed because it is too large
Load Diff
+804
-246
File diff suppressed because it is too large
Load Diff
+804
-246
File diff suppressed because it is too large
Load Diff
+807
-249
File diff suppressed because it is too large
Load Diff
+806
-248
File diff suppressed because it is too large
Load Diff
+808
-250
File diff suppressed because it is too large
Load Diff
+806
-248
File diff suppressed because it is too large
Load Diff
+806
-248
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+806
-248
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_VIEWED', // When the document is viewed by a recipient.
|
||||
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
|
||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||
'DOCUMENT_RECIPIENT_EXPIRED', // When a recipient's signing window expires.
|
||||
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
@@ -694,6 +695,18 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's signing window expired.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
|
||||
data: z.object({
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -739,6 +752,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum DocumentEmailEvents {
|
||||
DocumentCompleted = 'documentCompleted',
|
||||
DocumentDeleted = 'documentDeleted',
|
||||
OwnerDocumentCompleted = 'ownerDocumentCompleted',
|
||||
OwnerRecipientExpired = 'ownerRecipientExpired',
|
||||
}
|
||||
|
||||
export const ZDocumentEmailSettingsSchema = z
|
||||
@@ -52,6 +53,12 @@ export const ZDocumentEmailSettingsSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to send an email to the document owner when the document is complete.')
|
||||
.default(true),
|
||||
ownerRecipientExpired: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether to send an email to the document owner when a recipient's signing window has expired.",
|
||||
)
|
||||
.default(true),
|
||||
})
|
||||
.strip()
|
||||
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
|
||||
@@ -78,6 +85,7 @@ export const extractDerivedDocumentEmailSettings = (
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
|
||||
ownerRecipientExpired: emailSettings.ownerRecipientExpired,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -89,4 +97,5 @@ export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
|
||||
documentCompleted: true,
|
||||
documentDeleted: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client
|
||||
import { z } from 'zod';
|
||||
|
||||
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
@@ -128,6 +129,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
}).extend({
|
||||
password: z.string().nullable().default(null),
|
||||
documentId: z.number().default(-1).optional(),
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
}),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
fields: ZEnvelopeFieldSchema.array(),
|
||||
|
||||
@@ -23,7 +23,9 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
||||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
@@ -50,7 +52,9 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
||||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
@@ -75,7 +79,9 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
name: true,
|
||||
token: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
expired: true, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: true,
|
||||
expirationNotifiedAt: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
|
||||
@@ -26,7 +26,8 @@ export const ZWebhookRecipientSchema = z.object({
|
||||
name: z.string(),
|
||||
token: z.string(),
|
||||
documentDeletedAt: z.date().nullable(),
|
||||
expired: z.date().nullable(),
|
||||
expiresAt: z.date().nullable(),
|
||||
expirationNotifiedAt: z.date().nullable(),
|
||||
signedAt: z.date().nullable(),
|
||||
authOptions: z.any().nullable(),
|
||||
signingOrder: z.number().nullable(),
|
||||
@@ -116,7 +117,8 @@ export const mapEnvelopeToWebhookDocumentPayload = (
|
||||
name: recipient.name,
|
||||
token: recipient.token,
|
||||
documentDeletedAt: recipient.documentDeletedAt,
|
||||
expired: recipient.expired,
|
||||
expiresAt: recipient.expiresAt,
|
||||
expirationNotifiedAt: recipient.expirationNotifiedAt,
|
||||
signedAt: recipient.signedAt,
|
||||
authOptions: recipient.authOptions,
|
||||
signingOrder: recipient.signingOrder,
|
||||
|
||||
@@ -294,8 +294,10 @@ export const formatDocumentAuditLogAction = (
|
||||
auditLog: TDocumentAuditLog,
|
||||
userId?: number,
|
||||
) => {
|
||||
const prefix =
|
||||
userId === auditLog.userId ? i18n._(msg`You`) : auditLog.name || auditLog.email || '';
|
||||
const isCurrentUser = userId === auditLog.userId;
|
||||
const user = auditLog.name || auditLog.email || '';
|
||||
|
||||
const prefix = isCurrentUser ? i18n._(msg`You`) : user || '';
|
||||
|
||||
const description = match(auditLog)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
|
||||
@@ -303,245 +305,303 @@ export const formatDocumentAuditLogAction = (
|
||||
message: `A field was added`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} added a field`,
|
||||
you: msg`You added a field`,
|
||||
user: msg`${user} added a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A field was removed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} removed a field`,
|
||||
you: msg`You removed a field`,
|
||||
user: msg`${user} removed a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A field was updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated a field`,
|
||||
you: msg`You updated a field`,
|
||||
user: msg`${user} updated a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was added`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} added a recipient`,
|
||||
you: msg`You added a recipient`,
|
||||
user: msg`${user} added a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was removed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} removed a recipient`,
|
||||
you: msg`You removed a recipient`,
|
||||
user: msg`${user} removed a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated a recipient`,
|
||||
you: msg`You updated a recipient`,
|
||||
user: msg`${user} updated a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document created`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} created the document`,
|
||||
you: msg`You created the document`,
|
||||
user: msg`${user} created the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document deleted`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} deleted the document`,
|
||||
you: msg`You deleted the document`,
|
||||
user: msg`${user} deleted the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`System auto inserted fields`,
|
||||
you: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
user: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field signed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} signed a field`,
|
||||
you: msg`You signed a field`,
|
||||
user: msg`${user} signed a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field unsigned`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} unsigned a field`,
|
||||
you: msg`You unsigned a field`,
|
||||
user: msg`${user} unsigned a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field prefilled by assistant`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} prefilled a field`,
|
||||
you: msg`You prefilled a field`,
|
||||
user: msg`${user} prefilled a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document visibility updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document visibility`,
|
||||
you: msg`You updated the document visibility`,
|
||||
user: msg`${user} updated the document visibility`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document access auth updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document access auth requirements`,
|
||||
you: msg`You updated the document access auth requirements`,
|
||||
user: msg`${user} updated the document access auth requirements`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document signing auth updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document signing auth requirements`,
|
||||
you: msg`You updated the document signing auth requirements`,
|
||||
user: msg`${user} updated the document signing auth requirements`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document`,
|
||||
you: msg`You updated the document`,
|
||||
user: msg`${user} updated the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document opened`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} opened the document`,
|
||||
you: msg`You opened the document`,
|
||||
user: msg`${user} opened the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document viewed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} viewed the document`,
|
||||
you: msg`You viewed the document`,
|
||||
user: msg`${user} viewed the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document title updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document title`,
|
||||
you: msg`You updated the document title`,
|
||||
user: msg`${user} updated the document title`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document external ID updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document external ID`,
|
||||
you: msg`You updated the document external ID`,
|
||||
user: msg`${user} updated the document external ID`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document sent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} sent the document`,
|
||||
you: msg`You sent the document`,
|
||||
user: msg`${user} sent the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document moved to team`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} moved the document to team`,
|
||||
you: msg`You moved the document to team`,
|
||||
user: msg`${user} moved the document to team`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = match(data.recipientRole)
|
||||
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
|
||||
.with(RecipientRole.VIEWER, () => msg`${userName} viewed the document`)
|
||||
.with(RecipientRole.APPROVER, () => msg`${userName} approved the document`)
|
||||
.with(RecipientRole.CC, () => msg`${userName} CC'd the document`)
|
||||
.otherwise(() => msg`${userName} completed their task`);
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
return match(data.recipientRole)
|
||||
.with(RecipientRole.SIGNER, () => ({
|
||||
anonymous: msg`Recipient signed the document`,
|
||||
you: msg`You signed the document`,
|
||||
user: msg`${user} signed the document`,
|
||||
}))
|
||||
.with(RecipientRole.VIEWER, () => ({
|
||||
anonymous: msg`Recipient viewed the document`,
|
||||
you: msg`You viewed the document`,
|
||||
user: msg`${user} viewed the document`,
|
||||
}))
|
||||
.with(RecipientRole.APPROVER, () => ({
|
||||
anonymous: msg`Recipient approved the document`,
|
||||
you: msg`You approved the document`,
|
||||
user: msg`${user} approved the document`,
|
||||
}))
|
||||
.with(RecipientRole.CC, () => ({
|
||||
anonymous: msg`Recipient CC'd the document`,
|
||||
you: msg`You CC'd the document`,
|
||||
user: msg`${user} CC'd the document`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
anonymous: msg`Recipient completed their task`,
|
||||
you: msg`You completed your task`,
|
||||
user: msg`${user} completed their task`,
|
||||
}));
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} rejected the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} requested a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} validated a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} failed to validate a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
||||
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
||||
identified: data.isResending
|
||||
? msg`${prefix} resent an email to ${data.recipientEmail}`
|
||||
: msg`${prefix} sent an email to ${data.recipientEmail}`,
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a 2FA token for the document`,
|
||||
you: msg`You requested a 2FA token for the document`,
|
||||
user: msg`${user} requested a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, () => ({
|
||||
anonymous: msg`Recipient validated a 2FA token for the document`,
|
||||
you: msg`You validated a 2FA token for the document`,
|
||||
user: msg`${user} validated a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, () => ({
|
||||
anonymous: msg`Recipient failed to validate a 2FA token for the document`,
|
||||
you: msg`You failed to validate a 2FA token for the document`,
|
||||
user: msg`${user} failed to validate a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => {
|
||||
if (data.isResending) {
|
||||
return {
|
||||
anonymous: msg({
|
||||
message: `Email resent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You resent an email to ${data.recipientEmail}`,
|
||||
user: msg`${user} resent an email to ${data.recipientEmail}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
anonymous: msg({
|
||||
message: `Email sent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You sent an email to ${data.recipientEmail}`,
|
||||
user: msg`${user} sent an email to ${data.recipientEmail}`,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document completed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg({
|
||||
message: `Document completed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
anonymous: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
you: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
user: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item created`,
|
||||
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item deleted`,
|
||||
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Document ownership delegated`,
|
||||
message: `Envelope item created`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
|
||||
you: msg`You created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Envelope item deleted`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Recipient signing window expired`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
|
||||
user: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return {
|
||||
anonymous: message,
|
||||
you: message,
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
if (isCurrentUser) {
|
||||
selectedDescription = description.you;
|
||||
} else if (user) {
|
||||
selectedDescription = description.user;
|
||||
}
|
||||
|
||||
return {
|
||||
prefix,
|
||||
description: i18n._(prefix ? description.identified : description.anonymous),
|
||||
description: i18n._(selectedDescription),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,10 @@ export const extractDerivedDocumentMeta = (
|
||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||
emailSettings:
|
||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
|
||||
// Envelope expiration.
|
||||
envelopeExpirationPeriod:
|
||||
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
|
||||
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
@@ -138,6 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
||||
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
|
||||
defaultRecipients: null,
|
||||
|
||||
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
/**
|
||||
@@ -94,3 +95,23 @@ export const mapRecipientToLegacyRecipient = (
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
||||
return z.string().email().safeParse(recipient.email).success;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the recipient's signing window has expired.
|
||||
*/
|
||||
export const isRecipientExpired = (recipient: { expiresAt: Date | null }) => {
|
||||
return Boolean(recipient.expiresAt && new Date(recipient.expiresAt) <= new Date());
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that the recipient's signing window has not expired.
|
||||
*
|
||||
* Throws an AppError with RECIPIENT_EXPIRED if the expiration date has passed.
|
||||
*/
|
||||
export const assertRecipientNotExpired = (recipient: { expiresAt: Date | null }) => {
|
||||
if (isRecipientExpired(recipient)) {
|
||||
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
|
||||
message: 'Recipient signing window has expired',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,6 +205,9 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
// emailReplyToName: null,
|
||||
|
||||
defaultRecipients: null,
|
||||
|
||||
envelopeExpirationPeriod: null,
|
||||
|
||||
aiFeaturesEnabled: null,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user