chore: merge main, resolve biome formatting conflicts

Reconcile toolbar-filters changes with main's Kysely-based query rewrite
and biome reformatting. Notable:

- find-documents.ts: rebase on main's Kysely impl while preserving PR
  semantics (status[] / source[] filters via OR-of-predicates,
  period 'all' bypass).
- get-stats.ts: skip period filter when 'all'.
- find-templates.ts: preserve PR templateType array + query filter on
  top of main's where/include structure.
- find-documents-internal: drop preloaded team plumbing (main resolves
  team internally) and switch appMetaTags to msg`...`.
- Restore packages/lib/translations/ from HEAD (autogenerated).
This commit is contained in:
ephraimduncan
2026-05-12 12:12:37 +00:00
1936 changed files with 92316 additions and 47005 deletions
@@ -1,6 +1,5 @@
import { match } from 'ts-pattern';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { match } from 'ts-pattern';
import type { TCheckboxFieldMeta } from '../types/field-meta';
@@ -45,17 +44,13 @@ export const validateCheckboxField = (
switch (validation.value) {
case '=':
lengthCondition = isSigningPage
? values.length !== validationLength
: values.length < validationLength;
lengthCondition = isSigningPage ? values.length !== validationLength : values.length < validationLength;
break;
case '>=':
lengthCondition = values.length < validationLength;
break;
case '<=':
lengthCondition = isSigningPage
? values.length > validationLength
: values.length < validationLength;
lengthCondition = isSigningPage ? values.length > validationLength : values.length < validationLength;
break;
}
+17 -9
View File
@@ -3,7 +3,7 @@ import type { EnvelopeItem } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DocumentVersion = 'original' | 'signed' | 'pending';
type DownloadPDFProps = {
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
@@ -14,16 +14,25 @@ type DownloadPDFProps = {
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
*/
version?: DocumentVersion;
};
export const downloadPDF = async ({
envelopeItem,
token,
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const versionToFilenameSuffix = (version: DocumentVersion): string => {
switch (version) {
case 'signed':
return '_signed.pdf';
case 'pending':
return '_pending.pdf';
case 'original':
return '.pdf';
}
};
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -34,10 +43,9 @@ export const downloadPDF = async ({
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
downloadFile({
filename: `${baseTitle}${suffix}`,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => {
export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
@@ -1,7 +1,15 @@
import { posthog } from 'posthog-js';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
let posthogPromise: Promise<typeof import('posthog-js')> | null = null;
const getPosthog = async () => {
if (!posthogPromise) {
posthogPromise = import('posthog-js');
}
return posthogPromise;
};
export function useAnalytics() {
// const featureFlags = useFeatureFlags();
const isPostHogEnabled = extractPostHogConfig();
@@ -17,7 +25,9 @@ export function useAnalytics() {
return;
}
posthog.capture(event, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.capture(event, properties);
});
};
/**
@@ -31,7 +41,9 @@ export function useAnalytics() {
return;
}
posthog.captureException(error, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.captureException(error, properties);
});
};
/**
@@ -5,10 +5,7 @@ type SaveRequest<T, R> = {
onResponse?: (response: R) => void;
};
export const useAutoSave = <T, R = void>(
onSave: (data: T) => Promise<R>,
options: { delay?: number } = {},
) => {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
@@ -11,8 +11,7 @@ export type UseCopyShareLinkOptions = {
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } =
trpc.document.share.useMutation();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } = trpc.document.share.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
@@ -18,9 +18,7 @@ export function useCopyToClipboard(): [CopiedValue, CopyFn] {
// Try to save to clipboard then save it in the state if worked
try {
isClipboardApiSupported
? await handleClipboardApiCopy(text, blobType)
: await handleWriteTextCopy(text);
isClipboardApiSupported ? await handleClipboardApiCopy(text, blobType) : await handleWriteTextCopy(text);
setCopiedText(await text);
return true;
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useCallback } from 'react';
export const useDocumentElement = () => {
/**
@@ -14,7 +13,8 @@ export const useDocumentElement = () => {
const target = event.target;
const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
target.closest<HTMLElement>(pageSelector) ??
document.elementsFromPoint(event.clientX, event.clientY).find((el) => el.matches(pageSelector));
if (!$page) {
return null;
@@ -28,19 +28,9 @@ export const useDocumentElement = () => {
* as a percentage of the page width and height.
*/
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
const {
top: pageTop,
left: pageLeft,
height: pageHeight,
width: pageWidth,
} = getBoundingClientRect(page);
const { top: pageTop, left: pageLeft, height: pageHeight, width: pageWidth } = getBoundingClientRect(page);
const {
top: fieldTop,
left: fieldLeft,
height: fieldHeight,
width: fieldWidth,
} = getBoundingClientRect(field);
const { top: fieldTop, left: fieldLeft, height: fieldHeight, width: fieldWidth } = getBoundingClientRect(field);
return {
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
@@ -57,31 +47,28 @@ export const useDocumentElement = () => {
* @param mouseWidth The artifical width of the mouse.
* @param mouseHeight The artifical height of the mouse.
*/
const isWithinPageBounds = useCallback(
(event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
const isWithinPageBounds = useCallback((event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
if (!$page) {
return false;
}
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
const { top, left, height, width } = $page.getBoundingClientRect();
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
return true;
},
[],
);
return true;
}, []);
return {
getPage,
@@ -1,15 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
@@ -37,7 +35,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
@@ -61,16 +59,13 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
};
export const useEditorFields = ({
envelope,
handleFieldsUpdate,
}: EditorFieldsProps): UseEditorFieldsResponse => {
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
@@ -123,9 +118,7 @@ export const useEditorFields = ({
}
const foundField = localFields.find((field) => field.formId === formId);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === foundField?.recipientId,
);
const recipient = envelope.recipients.find((recipient) => recipient.id === foundField?.recipientId);
if (recipient) {
setSelectedRecipient(recipient.id);
@@ -222,14 +215,16 @@ export const useEditorFields = ({
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
const totalPages = getPdfPagesCount();
const newFields: TLocalField[] = [];
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return newFields;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === field.page) {
return;
continue;
}
const newField: TLocalField = {
@@ -241,7 +236,7 @@ export const useEditorFields = ({
append(newField);
newFields.push(newField);
});
}
triggerFieldsUpdate();
return newFields;
@@ -317,9 +312,7 @@ export const useEditorFields = ({
};
};
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
const restrictFieldPosValues = (field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
@@ -1,20 +1,14 @@
import { useId } from 'react';
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useId } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -36,12 +30,12 @@ export const ZEditorRecipientsFormSchema = z.object({
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
@@ -49,9 +43,7 @@ type UseEditorRecipientsResponse = {
resetForm: (options?: ResetFormOptions) => void;
};
export const useEditorRecipients = ({
envelope,
}: EditorRecipientsProps): UseEditorRecipientsResponse => {
export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEditorRecipientsResponse => {
const initialId = useId();
const generateDefaultValues = (options?: ResetFormOptions) => {
@@ -84,8 +76,7 @@ export const useEditorRecipients = ({
return {
signers,
signingOrder: documentMeta?.signingOrder ?? envelope.documentMeta.signingOrder,
allowDictateNextSigner:
documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
allowDictateNextSigner: documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
};
};
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useCallback, useEffect, useState } from 'react';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
@@ -17,7 +16,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
return { top: 0, left: 0, width: 0, height: 0 };
}
if (withScroll) {
@@ -36,7 +35,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
useEffect(() => {
setBounds(calculateBounds());
}, []);
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
@@ -48,7 +47,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
}, [calculateBounds]);
useEffect(() => {
const $el =
@@ -69,7 +68,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, []);
}, [elementOrSelector, calculateBounds]);
return bounds;
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -15,7 +15,9 @@ export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
@@ -1,13 +1,9 @@
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_CONTENT_SELECTOR, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { Field } from '@prisma/client';
import { useCallback, useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
) => {
export const useFieldPageCoords = (field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
@@ -16,9 +12,7 @@ export const useFieldPageCoords = (
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const $page = document.querySelector<HTMLElement>(`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`);
if (!$page) {
return;
@@ -57,23 +51,65 @@ export const useFieldPageCoords = (
};
}, [calculateCoords]);
// Watch for the page element to appear in the DOM (e.g. after a virtual list
// scroll) and recalculate coords. Also attach a ResizeObserver once the page
// element exists.
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
if (!$page) {
return;
let resizeObserver: ResizeObserver | null = null;
let observedElement: HTMLElement | null = null;
const attachResizeObserver = ($page: HTMLElement) => {
if ($page === observedElement) {
return;
}
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
calculateCoords();
});
resizeObserver.observe($page);
observedElement = $page;
};
// Try to attach immediately if the page already exists.
const existingPage = document.querySelector<HTMLElement>(pageSelector);
if (existingPage) {
attachResizeObserver(existingPage);
}
const observer = new ResizeObserver(() => {
// Watch for DOM mutations to detect when the page element appears (e.g.
// after the virtual list scrolls to a new page and renders it).
// Scope to the PDF viewer content container to avoid firing on unrelated
// DOM changes elsewhere in the document.
const mutationObserver = new MutationObserver(() => {
const $page = document.querySelector<HTMLElement>(pageSelector);
if (!$page) {
return;
}
if ($page === observedElement) {
return;
}
calculateCoords();
attachResizeObserver($page);
});
observer.observe($page);
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
mutationObserver.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
mutationObserver.disconnect();
resizeObserver?.disconnect();
observedElement = null;
};
}, [calculateCoords, field.page]);
@@ -0,0 +1,35 @@
import { PDF_VIEWER_CONTENT_SELECTOR, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { useEffect, useState } from 'react';
/**
* Returns whether the PDF page element for the given page number is currently
* present in the DOM. With virtual list rendering only pages near the viewport
* are mounted, so this hook lets consumers skip rendering when their page is
* virtualised away.
*/
export const useIsPageInDom = (pageNumber: number) => {
const [isPageInDom, setIsPageInDom] = useState(false);
useEffect(() => {
const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
setIsPageInDom(document.querySelector(selector) !== null);
const observer = new MutationObserver(() => {
setIsPageInDom(document.querySelector(selector) !== null);
});
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
observer.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [pageNumber]);
return isPageInDom;
};
@@ -1,135 +1,85 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { useEffect, useMemo, useRef } from 'react';
import type { PageRenderData } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
() => ({
scale: 1,
width: pageWidth,
height: pageHeight,
}),
[pageWidth, pageHeight],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
() => ({
scale,
width: pageWidth * scale,
height: pageHeight * scale,
}),
[pageWidth, pageHeight, scale],
);
/**
* Viewport with the device pixel ratio applied so we can render the PDF
* in a higher resolution.
*/
const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
[page, rotate, scale],
);
useEffect(() => {
const { current: container } = konvaContainer;
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
if (!container || imageLoadingState !== 'loaded') {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
stage.current = new Konva.Stage({
container,
id: `page-${pageNumber}`,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
if (!canvas || !kContainer) {
return;
}
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
stage.current.add(pageLayer.current);
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
const renderContext: RenderParameters = {
canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
void document.fonts.ready.then(() => {
pageLayer.current?.batchDraw();
});
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return () => {
stage.current?.destroy();
stage.current = null;
};
}, [imageLoadingState, scaledViewport]);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}
};
@@ -1,5 +1,5 @@
import * as React from 'react';
import type { Dispatch, SetStateAction } from 'react';
import * as React from 'react';
function dispatchStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
@@ -25,20 +25,13 @@ const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) =>
return () => window.removeEventListener('storage', callback);
};
export function useSessionStorage<T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] {
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 store = React.useSyncExternalStore(useSessionStorageSubscribe, getSnapshot, getServerSnapshot);
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
(v) => {
@@ -1,51 +1,43 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import {
DEFAULT_EDITOR_CONFIG,
type EnvelopeEditorConfig,
type TEditorEnvelope,
} from '@documenso/lib/types/envelope-editor';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorFields } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number,
) => {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(...args: Args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
};
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
editorConfig: EnvelopeEditorConfig;
envelope: TEditorEnvelope;
isEmbedded: boolean;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
@@ -57,8 +49,9 @@ type EnvelopeEditorProviderValue = {
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean;
resetForms: () => void;
relativePath: {
basePath: string;
@@ -68,12 +61,20 @@ type EnvelopeEditorProviderValue = {
templateRootPath: string;
};
navigateToStep: (step: EnvelopeEditorStep) => void;
syncEnvelope: () => Promise<void>;
registerExternalFlush: (key: string, flush: () => Promise<void>) => () => void;
registerPendingMutation: (promise: Promise<unknown>) => void;
organisationEmails?: { id: string; email: string }[];
};
interface EnvelopeEditorProviderProps {
children: React.ReactNode;
initialEnvelope: TEnvelope;
editorConfig?: EnvelopeEditorConfig;
initialEnvelope: TEditorEnvelope;
organisationEmails?: { id: string; email: string }[];
}
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
@@ -90,14 +91,49 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({
children,
editorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope,
organisationEmails,
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
externalFlushCallbacksRef.current.set(key, flush);
return () => {
externalFlushCallbacksRef.current.delete(key);
};
}, []);
const registerPendingMutation = useCallback((promise: Promise<unknown>) => {
pendingMutationsRef.current.add(promise);
void promise.finally(() => {
pendingMutationsRef.current.delete(promise);
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
@@ -107,23 +143,48 @@ export const EnvelopeEditorProvider = ({
envelope,
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
setEnvelope({
...envelope,
...response,
documentMeta: {
...envelope.documentMeta,
...input.meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (input.meta?.emailSettings ||
null) as unknown as TDocumentEmailSettings | null,
},
});
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
const setFieldsMutation = trpc.envelope.field.set.useMutation();
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
/**
* Handles debouncing the recipients updates to the server.
*
* Will set the local envelope recipients and fields after the update is complete.
*/
const {
triggerSave: setRecipientsDebounced,
flush: flushSetRecipients,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
try {
let recipients: TEditorEnvelope['recipients'] = [];
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
setEnvelope((prev) => ({
...prev,
recipients,
fields: prev.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
setAutosaveError(false);
},
onError: (err) => {
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -134,19 +195,57 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive',
duration: 7500,
});
},
});
}
}, 1000);
const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
setRecipientsDebounced(localRecipients);
await flushSetRecipients();
};
/**
* Handles debouncing the fields updates to the server.
*
* Will set the local envelope fields after the update is complete.
*/
const {
triggerSave: setFieldsDebounced,
flush: flushSetFields,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
}
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: ({ data: fields }) => {
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
},
onError: (err) => {
// Insert the IDs into the local fields.
fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -157,91 +256,74 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
fields: prev.fields.filter((field) =>
recipients.some((recipient) => recipient.id === field.recipientId),
),
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) =>
recipients.some((recipient) => recipient.id === field.recipientId),
),
);
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const {
triggerSave: setRecipientsDebounced,
flush: setRecipientsAsync,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
await envelopeRecipientSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients,
});
}, 1000);
const {
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
// Insert the IDs into the local fields.
envelopeFields.data.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}
}, 2000);
const setFieldsAsync = async (localFields: TLocalField[]) => {
setFieldsDebounced(localFields);
await flushSetFields();
};
/**
* Handles debouncing the envelope updates to the server.
*
* Will set the local envelope after the update is complete.
*/
const {
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
triggerSave: updateEnvelopeDebounced,
flush: flushUpdateEnvelope,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
data,
meta,
})
: {};
setEnvelope((prev) => ({
...prev,
...data,
authOptions: {
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
},
...response,
documentMeta: {
...prev.documentMeta,
...meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
},
}));
setAutosaveError(false);
} catch (err) {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
}
}, 1000);
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
updateEnvelopeDebounced(envelopeUpdates);
await flushUpdateEnvelope();
};
/**
* Updates the local envelope and debounces the update to the server.
*
* Use this when you want to update the local envelope immediately while debouncing
* the actual update to the server.
*/
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({
@@ -253,35 +335,22 @@ export const EnvelopeEditorProvider = ({
},
}));
setEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
updateEnvelopeDebounced(envelopeUpdates);
};
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) => getRecipientColor(envelope.recipients.findIndex((r) => r.id === recipientId)),
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
const { refetch: reloadEnvelope } = trpc.envelope.editor.get.useQuery(
{
envelopeId: envelope.id,
},
{
initialData: envelope,
enabled: !isEmbedded,
gcTime: 0,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -293,6 +362,11 @@ export const EnvelopeEditorProvider = ({
const syncEnvelope = async () => {
await flushAutosave();
// Bypass syncing for embedded mode.
if (isEmbedded) {
return;
}
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
@@ -302,55 +376,95 @@ export const EnvelopeEditorProvider = ({
recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta,
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
const isAutosaving = useMemo(() => {
return (
envelopeFieldSetMutationQuery.isPending ||
envelopeRecipientSetMutationQuery.isPending ||
envelopeUpdateMutationQuery.isPending ||
isFieldsMutationPending ||
isRecipientsMutationPending ||
isEnvelopeMutationPending
);
}, [
envelopeFieldSetMutationQuery.isPending,
envelopeRecipientSetMutationQuery.isPending,
envelopeUpdateMutationQuery.isPending,
isFieldsMutationPending,
isRecipientsMutationPending,
isEnvelopeMutationPending,
]);
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url);
let documentRootPath = formatDocumentsPath(envelope.team.url);
let templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
let envelopePath = `${basePath}/${envelope.id}`;
let editorPath = `${basePath}/${envelope.id}/edit`;
if (editorConfig.embedded) {
let embeddedEditorPath =
editorConfig.embedded.mode === 'edit'
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
: `/embed/v2/authoring/envelope/create`;
embeddedEditorPath += `?token=${editorConfig.embedded.presignToken}`;
envelopePath = embeddedEditorPath;
editorPath = embeddedEditorPath;
documentRootPath = embeddedEditorPath;
templateRootPath = embeddedEditorPath;
}
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
envelopePath,
editorPath,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
if (step === 'upload') {
newParams.delete('step');
} else {
newParams.set('step', step);
}
return newParams;
});
};
const resetForms = () => {
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
// Flush all registered external flushes (e.g., upload page's debounced item updates).
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
await Promise.all(externalFlushes.map(async (flush) => flush()));
// Await all registered pending mutations (e.g., in-flight creates/deletes).
// Use allSettled so a single failed mutation doesn't prevent awaiting the rest.
if (pendingMutationsRef.current.size > 0) {
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
};
return (
<EnvelopeEditorContext.Provider
value={{
editorConfig,
envelope,
isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
@@ -366,9 +480,110 @@ export const EnvelopeEditorProvider = ({
isAutosaving,
relativePath,
syncEnvelope,
navigateToStep,
resetForms,
registerExternalFlush,
registerPendingMutation,
organisationEmails,
}}
>
{children}
</EnvelopeEditorContext.Provider>
);
};
type MapLocalRecipientsToRecipientsOptions = {
envelope: TEditorEnvelope;
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
};
const mapLocalRecipientsToRecipients = ({
envelope,
localRecipients,
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
if (recipient.id && recipient.id < min) {
return recipient.id;
}
return min;
}, -1);
return localRecipients.map((recipient) => {
const foundRecipient = envelope.recipients.find((r) => r.id === recipient.id);
let recipientId = recipient.id;
if (recipientId === undefined) {
recipientId = smallestRecipientId;
smallestRecipientId--;
}
return {
id: recipientId,
envelopeId: envelope.id,
email: recipient.email,
name: recipient.name,
token: foundRecipient?.token || '',
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
expired: foundRecipient?.expired || null,
signedAt: foundRecipient?.signedAt || null,
authOptions: recipient.actionAuth.length > 0 ? { actionAuth: recipient.actionAuth, accessAuth: [] } : null,
signingOrder: recipient.signingOrder ?? null,
rejectionReason: foundRecipient?.rejectionReason || null,
role: recipient.role,
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
expiresAt: foundRecipient?.expiresAt || null,
expirationNotifiedAt: foundRecipient?.expirationNotifiedAt || null,
};
});
};
type MapLocalFieldsToFieldsOptions = {
localFields: TLocalField[];
envelope: TEditorEnvelope;
};
const mapLocalFieldsToFields = ({
envelope,
localFields,
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
let smallestFieldId = localFields.reduce((min, field) => {
if (field.id && field.id < min) {
return field.id;
}
return min;
}, -1);
return localFields.map((field) => {
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
let fieldId = field.id;
if (fieldId === undefined) {
fieldId = smallestFieldId;
smallestFieldId--;
}
return {
...field,
formId: field.formId,
id: fieldId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
type: field.type,
recipientId: field.recipientId,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
secondaryId: foundField?.secondaryId || '',
inserted: foundField?.inserted || false,
customText: foundField?.customText || '',
fieldMeta: field.fieldMeta || null,
};
});
};
@@ -1,23 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { Field, Recipient } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
type FileData =
| {
status: 'loading' | 'error';
}
| {
file: Uint8Array;
status: 'loaded';
};
/**
* The signature data for an inserted signature field.
*
* Loaded separately from the envelope to avoid bloating the envelope.get response
* with potentially large base64 image payloads.
*/
export type EnvelopeRenderFieldSignature = {
fieldId: number;
signatureImageAsBase64: string | null;
typedSignature: string | null;
};
export type PageRenderData = {
scale: number;
pageIndex: number;
pageNumber: number;
pageWidth: number;
pageHeight: number;
imageLoadingState: ImageLoadingState;
};
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
@@ -25,16 +38,29 @@ type EnvelopeRenderOverrideSettings = {
showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderItem = {
id: string;
title: string;
order: number;
envelopeId: string;
/**
* The PDF data to render.
*
* If it's a string we assume it's a URL to the PDF file.
*/
data: Uint8Array | string;
};
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[];
signatures: EnvelopeRenderFieldSignature[];
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -46,7 +72,26 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
/**
* The envelope item version to render.
*/
version: DocumentDataVersion;
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
/**
* The envelope items to render.
*
* If data is optional then we build the URL based of the IDs.
*/
envelopeItems: {
id: string;
title: string;
order: number;
envelopeId: string;
documentDataId: string;
data?: Uint8Array | string;
}[];
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@@ -55,6 +100,15 @@ interface EnvelopeRenderProviderProps {
*/
fields?: Field[];
/**
* Optional inserted signature data for signature fields.
*
* Fetched separately from the envelope to keep the envelope response lean.
* If a signature field has no entry here, the renderer will fall back to
* showing the field type placeholder.
*/
signatures?: EnvelopeRenderFieldSignature[];
/**
* Optional recipient used to determine the color of the fields and hover
* previews.
@@ -70,6 +124,13 @@ interface EnvelopeRenderProviderProps {
*/
token: string | undefined;
/**
* The presign token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
presignToken?: string | undefined;
/**
* Custom override settings for generic page renderers.
*/
@@ -89,89 +150,62 @@ export const useCurrentEnvelopeRender = () => {
};
/**
* Manages fetching and storing PDF files to render on the client.
* Manages fetching the data required to render an envelope and it's items.
*/
export const EnvelopeRenderProvider = ({
children,
envelope,
envelopeItems: envelopeItemsFromProps,
fields,
signatures,
token,
presignToken,
recipients = [],
version,
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
() =>
[...envelopeItemsFromProps]
.sort((a, b) => a.order - b.order)
.map((item) => {
const pdfUrl = getDocumentDataUrl({
envelopeId: envelope.id,
envelopeItemId: item.id,
documentDataId: item.documentDataId,
version,
token,
presignToken,
});
const data = item.data || pdfUrl;
return {
...item,
data,
};
}),
[envelopeItemsFromProps, envelope.id, token, version, presignToken],
);
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
return;
}
const [currentItemId, setCurrentItemId] = useState<string | null>(envelopeItems[0]?.id ?? null);
if (!files[envelopeItem.id]) {
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'loading',
},
}));
}
try {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const file = await blob.arrayBuffer();
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
file: new Uint8Array(file),
status: 'loaded',
},
}));
} catch (error) {
console.error(error);
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'error',
},
}));
}
};
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
},
[files],
);
const currentItem = useMemo((): EnvelopeRenderItem | null => {
return envelopeItems.find((item) => item.id === currentItemId) ?? null;
}, [currentItemId, envelopeItems]);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null);
setCurrentItemId(foundItem?.id ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
setCurrentItem(null);
setCurrentItemId(null);
}
if (!currentItem && envelopeItems.length > 0) {
@@ -179,41 +213,24 @@ export const EnvelopeRenderProvider = ({
}
}, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
);
const recipientIds = useMemo(() => recipients.map((recipient) => recipient.id).sort(), [recipients]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) => getRecipientColor(recipientIds.findIndex((id) => id === recipientId)),
[recipientIds],
);
return (
<EnvelopeRenderContext.Provider
value={{
getPdfBuffer,
version,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
signatures: signatures ?? [],
recipients,
getRecipientColorKey,
renderError,
@@ -1,11 +1,7 @@
import type { I18n, Messages } from '@lingui/core';
import { setupI18n } from '@lingui/core';
import {
APP_I18N_OPTIONS,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '../../constants/i18n';
import { APP_I18N_OPTIONS, isValidLanguageCode, SUPPORTED_LANGUAGE_CODES } from '../../constants/i18n';
import { env } from '../../utils/env';
import { remember } from '../../utils/remember';
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { type Messages, setupI18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { useState } from 'react';
import type { I18nLocaleData } from '../../constants/i18n';
@@ -1,7 +1,6 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import type React from 'react';
import { createContext, useContext } from 'react';
type OrganisationProviderValue = OrganisationSession;
@@ -27,7 +26,5 @@ export const useOptionalCurrentOrganisation = () => {
};
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
return (
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
);
return <OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>;
};
+21 -7
View File
@@ -1,13 +1,11 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React from 'react';
import type { Session } from '@prisma/client';
import { useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { trpc } from '@documenso/trpc/client';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import type { Session } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { SKIP_QUERY_BATCH_META } from '../../constants/trpc';
@@ -71,11 +69,27 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
const organisations = await trpc.organisation.internal.getOrganisationSession
.query(undefined, SKIP_QUERY_BATCH_META.trpc)
.catch(() => {
.catch((e) => {
const errorMessage = typeof e.message === 'string' ? e.message.toLowerCase() : '';
const isNetworkError = errorMessage.includes('networkerror') || errorMessage.includes('failed to fetch');
// If the error is a transient network/abort error (e.g. page refresh while
// fetch was in-flight), return null to signal we should skip the state update.
if (isNetworkError) {
return null;
}
// Todo: (RR7) Log
return [];
});
// Skip session update if the organisation fetch was aborted due to a transient
// network error (e.g. page refresh while fetch was in-flight).
if (organisations === null) {
return;
}
setSession({
session: newSession.session,
user: newSession.user,
+6 -13
View File
@@ -1,11 +1,7 @@
import type { Recipient } from '@prisma/client';
import {
DocumentDistributionMethod,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { DocumentDistributionMethod, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType {
COMPLETED = 'completed',
@@ -16,7 +12,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
recipient: Recipient,
recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -27,10 +23,7 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
if (recipient.readStatus === ReadStatus.OPENED && recipient.signingStatus === SigningStatus.NOT_SIGNED) {
return RecipientStatusType.OPENED;
}
@@ -45,7 +38,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
+6 -12
View File
@@ -1,10 +1,8 @@
import { env } from '@documenso/lib/utils/env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -22,11 +20,9 @@ export const API_V2_URL = '/api/v2';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const USE_INTERNAL_URL_BROWSERLESS = () =>
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const IS_AI_FEATURES_CONFIGURED = () =>
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
/**
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation
@@ -34,8 +30,6 @@ export const IS_AI_FEATURES_CONFIGURED = () =>
*
* @deprecated This is a temporary flag and will be removed once Konva-based generation is stable.
*/
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
+72 -3
View File
@@ -1,8 +1,23 @@
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
@@ -19,9 +34,7 @@ export const IS_MICROSOFT_SSO_ENABLED = Boolean(
);
export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
env('NEXT_PRIVATE_OIDC_CLIENT_SECRET'),
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') && env('NEXT_PRIVATE_OIDC_CLIENT_ID') && env('NEXT_PRIVATE_OIDC_CLIENT_SECRET'),
);
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
@@ -69,3 +82,59 @@ export const getCookieDomain = () => {
return url.hostname;
};
/**
* Get allowed signup domains from env var.
* Returns empty array if not set (meaning all domains allowed).
*/
export const getAllowedSignupDomains = (): string[] => {
const domains = env('NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS');
if (!domains) {
return [];
}
return domains
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
};
/**
* Check if email domain is allowed for signup.
* Returns true if no domain restriction is configured.
*/
export const isEmailDomainAllowedForSignup = (email: string): boolean => {
const allowedDomains = getAllowedSignupDomains();
if (allowedDomains.length === 0) {
return true;
}
const emailDomain = email.toLowerCase().split('@').pop();
if (!emailDomain) {
return false;
}
return allowedDomains.includes(emailDomain);
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
*/
export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
return false;
}
const flagMap = {
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP',
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP',
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP',
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNUP',
} as const;
return env(flagMap[provider]) !== 'true';
};
+13 -3
View File
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { SubscriptionStatus } from '@prisma/client';
export enum STRIPE_PLAN_TYPE {
@@ -12,7 +13,16 @@ export enum STRIPE_PLAN_TYPE {
export const FREE_TIER_DOCUMENT_QUOTA = 5;
export const SUBSCRIPTION_STATUS_MAP = {
[SubscriptionStatus.ACTIVE]: 'Active',
[SubscriptionStatus.INACTIVE]: 'Inactive',
[SubscriptionStatus.PAST_DUE]: 'Past Due',
[SubscriptionStatus.ACTIVE]: msg({
message: 'Active',
context: 'Subscription status',
}),
[SubscriptionStatus.INACTIVE]: msg({
message: 'Inactive',
context: 'Subscription status',
}),
[SubscriptionStatus.PAST_DUE]: msg({
message: 'Past Due',
context: 'Subscription status',
}),
};
+11
View File
@@ -0,0 +1,11 @@
/**
* Maximum length (in characters) of the user-supplied custom CSS for branding.
* Bound enforced at the TRPC request boundary on both the organisation and
* team settings update routes. The sanitiser is run after this check; this
* limit is purely a request-size guard.
*
* 256 KB — generous enough for hand-written branding CSS and the occasional
* compiled-from-Tailwind-or-similar paste, while still keeping a request
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
+18
View File
@@ -8,12 +8,15 @@ export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd',
'dd/MM/yyyy',
'dd-MM-yyyy',
'MM/dd/yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy hh:mm a',
'dd/MM/yyyy HH:mm',
'dd-MM-yyyy hh:mm a',
'dd-MM-yyyy HH:mm',
'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
@@ -52,6 +55,16 @@ export const DATE_FORMATS = [
label: 'DD/MM/YYYY HH:mm AM/PM',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'DDMMYYYY_DASH_TIME',
label: 'DD-MM-YYYY HH:mm',
value: 'dd-MM-yyyy HH:mm',
},
{
key: 'DDMMYYYY_DASH_TIME_12H',
label: 'DD-MM-YYYY HH:mm AM/PM',
value: 'dd-MM-yyyy hh:mm a',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
@@ -117,6 +130,11 @@ export const DATE_FORMATS = [
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy',
},
{
key: 'DDMMYYYY_DASH',
label: 'DD-MM-YYYY',
value: 'dd-MM-yyyy',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
@@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
[DOCUMENT_EMAIL_TYPE.REMINDER]: {
description: 'Signing Reminder',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
@@ -1,8 +1,7 @@
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import type { TDocumentVisibility } from '../types/document-visibility';
type DocumentVisibilityTypeData = {
+9 -4
View File
@@ -1,14 +1,19 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
/**
* Workaround for E2E tests to not import `msg`.
*/
import { DocumentSignatureType } from '@documenso/lib/utils/teams';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
export { DocumentSignatureType };
/**
* Maximum count returned per status bucket in document stats. The server clamps
* each count to this value; the UI should display "10,000+" when it sees it.
*/
export const STATS_COUNT_CAP = 10_000;
export const DOCUMENT_STATUS: {
[status in DocumentStatus]: { description: MessageDescriptor };
} = {
@@ -0,0 +1,61 @@
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());
};
+104
View File
@@ -0,0 +1,104 @@
import type { DurationLikeObject } from 'luxon';
import { Duration } from 'luxon';
import { z } from 'zod';
export const ZEnvelopeReminderDurationPeriod = z.object({
unit: z.enum(['day', 'week', 'month']),
amount: z.number().int().min(1),
});
export const ZEnvelopeReminderDisabledPeriod = z.object({
disabled: z.literal(true),
});
export const ZEnvelopeReminderPeriod = z.union([ZEnvelopeReminderDurationPeriod, ZEnvelopeReminderDisabledPeriod]);
export type TEnvelopeReminderPeriod = z.infer<typeof ZEnvelopeReminderPeriod>;
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
export const ZEnvelopeReminderSettings = z.object({
sendAfter: ZEnvelopeReminderPeriod,
repeatEvery: ZEnvelopeReminderPeriod,
});
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
sendAfter: { unit: 'day', amount: 5 },
repeatEvery: { unit: 'day', amount: 2 },
};
/**
* Hard upper bound on the window in which automated reminders may be sent,
* measured from the moment the signing request was first sent to the
* recipient. Prevents runaway reminder chains for recipients with no
* expiration set who never sign.
*/
export const MAX_REMINDER_WINDOW_DAYS = 30;
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
day: 'days',
week: 'weeks',
month: 'months',
};
export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
/**
* Resolve the next reminder timestamp from the config and the last reminder sent time.
*
* - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
* any computed reminder beyond that point returns null so reminders stop.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
if (!config) {
return null;
}
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
let candidate: Date;
// If we haven't sent the first reminder yet, use sendAfter.
if (!lastReminderSentAt) {
if ('disabled' in config.sendAfter) {
return null;
}
const delay = getEnvelopeReminderDuration(config.sendAfter);
candidate = new Date(sentAt.getTime() + delay.toMillis());
} else {
// For subsequent reminders, use repeatEvery.
if ('disabled' in config.repeatEvery) {
return null;
}
const interval = getEnvelopeReminderDuration(config.repeatEvery);
candidate = new Date(lastReminderSentAt.getTime() + interval.toMillis());
}
// Stop if the candidate is past the hard cap measured from sentAt.
if (candidate.getTime() > maxReminderAt.getTime()) {
return null;
}
return candidate;
};
+16 -35
View File
@@ -1,22 +1,9 @@
import { z } from 'zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
'fr',
'es',
'it',
'nl',
'pl',
'pt-BR',
'ja',
'ko',
'zh',
] as const;
import { SUPPORTED_LANGUAGE_CODES, type SupportedLanguageCodes } from './locales';
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
export type SupportedLanguageCodes = (typeof SUPPORTED_LANGUAGE_CODES)[number];
export * from './locales';
export type I18nLocaleData = {
/**
@@ -30,61 +17,55 @@ export type I18nLocaleData = {
locales: string[];
};
export const APP_I18N_OPTIONS = {
supportedLangs: SUPPORTED_LANGUAGE_CODES,
sourceLang: 'en',
defaultLocale: 'en-US',
} as const;
type SupportedLanguage = {
full: string;
short: string;
full: MessageDescriptor;
};
export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
de: {
full: 'German',
short: 'de',
full: msg`German`,
},
en: {
full: 'English',
short: 'en',
full: msg`English`,
},
fr: {
full: 'French',
short: 'fr',
full: msg`French`,
},
es: {
full: 'Spanish',
short: 'es',
full: msg`Spanish`,
},
it: {
full: 'Italian',
short: 'it',
full: msg`Italian`,
},
nl: {
short: 'nl',
full: 'Dutch',
full: msg`Dutch`,
},
pl: {
short: 'pl',
full: 'Polish',
full: msg`Polish`,
},
'pt-BR': {
short: 'pt-BR',
full: 'Portuguese (Brazil)',
full: msg`Portuguese (Brazil)`,
},
ja: {
short: 'ja',
full: 'Japanese',
full: msg`Japanese`,
},
ko: {
short: 'ko',
full: 'Korean',
full: msg`Korean`,
},
zh: {
short: 'zh',
full: 'Chinese',
full: msg`Chinese`,
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'nl', 'pl', 'pt-BR', 'ja', 'ko', 'zh'] as const;
export type SupportedLanguageCodes = (typeof SUPPORTED_LANGUAGE_CODES)[number];
export const APP_I18N_OPTIONS = {
supportedLangs: SUPPORTED_LANGUAGE_CODES,
sourceLang: 'en',
defaultLocale: 'en-US',
} as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@@ -6,19 +6,13 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { OrganisationMemberRole } from '@prisma/client';
export const ORGANISATION_MEMBER_ROLE_MAP: Record<
keyof typeof OrganisationMemberRole,
MessageDescriptor
> = {
export const ORGANISATION_MEMBER_ROLE_MAP: Record<keyof typeof OrganisationMemberRole, MessageDescriptor> = {
ADMIN: msg`Admin`,
MANAGER: msg`Manager`,
MEMBER: msg`Member`,
};
export const EXTENDED_ORGANISATION_MEMBER_ROLE_MAP: Record<
keyof typeof OrganisationMemberRole,
MessageDescriptor
> = {
export const EXTENDED_ORGANISATION_MEMBER_ROLE_MAP: Record<keyof typeof OrganisationMemberRole, MessageDescriptor> = {
ADMIN: msg`Organisation Admin`,
MANAGER: msg`Organisation Manager`,
MEMBER: msg`Organisation Member`,
+2 -2
View File
@@ -1,7 +1,7 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
export const ORGANISATION_URL_ROOT_REGEX = new RegExp('^/t/[^/]+/?$');
export const ORGANISATION_URL_REGEX = new RegExp('^/t/[^/]+');
export const ORGANISATION_URL_ROOT_REGEX = /^\/t\/[^/]+\/?$/;
export const ORGANISATION_URL_REGEX = /^\/t\/[^/]+/;
export const ORGANISATION_INTERNAL_GROUPS: {
organisationRole: OrganisationMemberRole;
+22
View File
@@ -0,0 +1,22 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
export const PDF_VIEWER_ERROR_MESSAGES = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
default: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
},
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
+16 -1
View File
@@ -1,2 +1,17 @@
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
// Keep these two constants in sync.
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
export const PDF_VIEWER_CONTENT_SELECTOR = '[data-pdf-content]';
export const getPdfPagesCount = () => {
const pageCountAttr = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR)?.getAttribute('data-page-count');
const totalPages = Number(pageCountAttr);
if (!Number.isInteger(totalPages) || totalPages < 1) {
return 0;
}
return totalPages;
};
+5 -6
View File
@@ -8,9 +8,8 @@ export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, MessageDe
MEMBER: msg`Member`,
};
export const EXTENDED_TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, MessageDescriptor> =
{
ADMIN: msg`Team Admin`,
MANAGER: msg`Team Manager`,
MEMBER: msg`Team Member`,
};
export const EXTENDED_TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, MessageDescriptor> = {
ADMIN: msg`Team Admin`,
MANAGER: msg`Team Manager`,
MEMBER: msg`Team Member`,
};
+3 -7
View File
@@ -1,7 +1,7 @@
import { DocumentVisibility, OrganisationGroupType, TeamMemberRole } from '@prisma/client';
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+/?$');
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
export const TEAM_URL_ROOT_REGEX = /^\/t\/[^/]+\/?$/;
export const TEAM_URL_REGEX = /^\/t\/[^/]+/;
export const LOWEST_TEAM_ROLE = TeamMemberRole.MEMBER;
@@ -34,11 +34,7 @@ export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
} satisfies Record<string, TeamMemberRole[]>;
export const TEAM_DOCUMENT_VISIBILITY_MAP = {
[TeamMemberRole.ADMIN]: [
DocumentVisibility.ADMIN,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.EVERYONE,
],
[TeamMemberRole.ADMIN]: [DocumentVisibility.ADMIN, DocumentVisibility.MANAGER_AND_ABOVE, DocumentVisibility.EVERYONE],
[TeamMemberRole.MANAGER]: [DocumentVisibility.MANAGER_AND_ABOVE, DocumentVisibility.EVERYONE],
[TeamMemberRole.MEMBER]: [DocumentVisibility.EVERYONE],
} satisfies Record<TeamMemberRole, DocumentVisibility[]>;
+58
View File
@@ -0,0 +1,58 @@
import type { TCssVarsSchema } from '../types/css-vars';
/**
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*
* KEEP THIS FILE IN SYNC WITH `packages/ui/styles/theme.css`.
*
* These are the light-mode default values for the CSS custom properties
* defined under `:root` in the theme stylesheet, exposed here as hex strings
* so they can be used as defaults for colour-picker UI components and other
* places that don't render through CSS variables.
*
* If you change a value in `theme.css`, update it here too. There is NO
* automated check linking the two files; they have drifted historically
* and will drift again unless you update both.
*
* Computed via `colord({ h, s, l }).toHex()` — see the inline HSL comments
* for the source-of-truth values from `theme.css`.
*
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*/
export const DEFAULT_BRAND_COLORS = {
background: '#ffffff', // 0 0% 100%
foreground: '#0f172a', // 222.2 47.4% 11.2%
muted: '#f1f5f9', // 210 40% 96.1%
mutedForeground: '#64748b', // 215.4 16.3% 46.9%
popover: '#ffffff', // 0 0% 100%
popoverForeground: '#0f172a', // 222.2 47.4% 11.2%
card: '#ffffff', // 0 0% 100%
cardBorder: '#e2e8f0', // 214.3 31.8% 91.4%
cardForeground: '#0f172a', // 222.2 47.4% 11.2%
fieldCard: '#e2f8d3', // 95 74% 90%
fieldCardBorder: '#a2e771', // 95.08 71.08% 67.45%
fieldCardForeground: '#0f172a', // 222.2 47.4% 11.2%
widget: '#f7f7f7', // 0 0% 97%
widgetForeground: '#f2f2f2', // 0 0% 95%
border: '#e2e8f0', // 214.3 31.8% 91.4%
input: '#e2e8f0', // 214.3 31.8% 91.4%
primary: '#a2e771', // 95.08 71.08% 67.45%
primaryForeground: '#162c07', // 95.08 71.08% 10%
secondary: '#f1f5f9', // 210 40% 96.1%
secondaryForeground: '#0f172a', // 222.2 47.4% 11.2%
accent: '#f1f5f9', // 210 40% 96.1%
accentForeground: '#0f172a', // 222.2 47.4% 11.2%
destructive: '#ff0000', // 0 100% 50%
destructiveForeground: '#f8fafc', // 210 40% 98%
ring: '#a2e771', // 95.08 71.08% 67.45%
warning: '#e1cb05', // 54 96% 45%
envelopeEditorBackground: '#f8fafc', //210 40% 98.04%
// `cardBorderTint` is intentionally excluded from the colour-picker UI:
// unlike the rest of these tokens it is consumed via `rgb(var(--token))`
// (not `hsl(...)`) and stored as raw RGB triplets in `theme.css`. It does
// not flow through `toNativeCssVars` and is not user-customisable from the
// branding form. `radius` is a length, not a colour, so it lives in
// `DEFAULT_BRAND_RADIUS` below.
} as const satisfies Record<keyof Omit<TCssVarsSchema, 'radius' | 'cardBorderTint'>, string>;
export const DEFAULT_BRAND_RADIUS = '0.5rem';
+65 -32
View File
@@ -5,36 +5,52 @@ import { z } from 'zod';
* Generic application error codes.
*/
export enum AppErrorCode {
'ALREADY_EXISTS' = 'ALREADY_EXISTS',
'EXPIRED_CODE' = 'EXPIRED_CODE',
'INVALID_BODY' = 'INVALID_BODY',
'INVALID_REQUEST' = 'INVALID_REQUEST',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
ALREADY_EXISTS = 'ALREADY_EXISTS',
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_IMPLEMENTED = 'NOT_IMPLEMENTED',
NOT_SETUP = 'NOT_SETUP',
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
RETRY_EXCEPTION = 'RETRY_EXCEPTION',
SCHEMA_FAILED = 'SCHEMA_FAILED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
TWO_FACTOR_AUTH_FAILED = 'TWO_FACTOR_AUTH_FAILED',
WEBHOOK_INVALID_REQUEST = 'WEBHOOK_INVALID_REQUEST',
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
{
[AppErrorCode.ALREADY_EXISTS]: { 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 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
};
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 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
};
export const ZAppErrorJsonSchema = z.object({
code: z.string(),
@@ -62,6 +78,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 +101,8 @@ export class AppError extends Error {
*/
statusCode?: number;
headers?: Record<string, string>;
name = 'AppError';
/**
@@ -95,6 +118,7 @@ export class AppError extends Error {
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
this.headers = options?.headers;
}
/**
@@ -130,8 +154,7 @@ export class AppError extends Error {
const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR;
const validMessage: string | undefined = typeof message === 'string' ? message : undefined;
const validUserMessage: string | undefined =
typeof userMessage === 'string' ? userMessage : undefined;
const validUserMessage: string | undefined = typeof userMessage === 'string' ? userMessage : undefined;
const validStatusCode = typeof statusCode === 'number' ? statusCode : undefined;
@@ -203,15 +226,25 @@ export class AppError extends Error {
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
status: 400 | 401 | 403 | 404 | 500 | 501;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_LEGACY,
() => 400 as const,
)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const);
return {
+18
View File
@@ -1,8 +1,10 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
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,8 +12,15 @@ 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 { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@@ -24,13 +33,22 @@ export const jobsClient = new JobClient([
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_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,
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
+7 -7
View File
@@ -36,16 +36,18 @@ 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>;
};
export interface JobRunIO {
// stableRun<T extends Json | void>(cacheKey: string, callback: (io: JobRunIO) => T | Promise<T>): Promise<T>;
runTask<T extends Json | void | undefined>(
cacheKey: string,
callback: () => Promise<T>,
): Promise<T>;
runTask<T extends Json | void | undefined>(cacheKey: string, callback: () => Promise<T>): Promise<T>;
triggerJob(cacheKey: string, options: SimpleTriggerJobOptions): Promise<unknown>;
wait(cacheKey: string, ms: number): Promise<void>;
logger: {
@@ -57,6 +59,4 @@ export interface JobRunIO {
};
}
export const defineJob = <N extends string, T = unknown>(
job: JobDefinition<N, T>,
): JobDefinition<N, T> => job;
export const defineJob = <N extends string, T = unknown>(job: JobDefinition<N, T>): JobDefinition<N, T> => job;
+10
View File
@@ -16,4 +16,14 @@ export abstract class BaseJobProvider {
public getApiHandler(): (req: HonoContext) => Promise<Response | void> {
throw new Error('Not implemented');
}
/**
* Start the cron scheduler for any registered cron jobs.
*
* No-op for providers that handle cron scheduling externally (e.g. Inngest).
* Must be called explicitly at application startup.
*/
public startCron(): void {
// No-op by default — providers override if needed.
}
}
+382
View File
@@ -0,0 +1,382 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { prisma } from '@documenso/prisma';
import { serveStatic } from '@hono/node-server/serve-static';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import type { Job } from 'bullmq';
import { Queue, Worker } from 'bullmq';
import type { Context as HonoContext } from 'hono';
import { Hono } from 'hono';
import IORedis from 'ioredis';
import { env } from '../../utils/env';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
const QUEUE_NAME = 'documenso-jobs';
const DEFAULT_CONCURRENCY = 10;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_DELAY = 1000;
declare global {
// eslint-disable-next-line no-var
var __documenso_bullmq_provider__: BullMQJobProvider | undefined;
}
export class BullMQJobProvider extends BaseJobProvider {
private _queue: Queue;
private _worker: Worker;
private _connection: IORedis;
private _jobDefinitions: Record<string, JobDefinition> = {};
private constructor() {
super();
const redisUrl = env('NEXT_PRIVATE_REDIS_URL');
if (!redisUrl) {
throw new Error('[JOBS]: NEXT_PRIVATE_REDIS_URL is required when using the BullMQ jobs provider');
}
const prefix = env('NEXT_PRIVATE_REDIS_PREFIX') || 'documenso';
this._connection = new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
this._queue = new Queue(QUEUE_NAME, {
connection: this._connection,
prefix,
});
const concurrency = Number(env('NEXT_PRIVATE_BULLMQ_CONCURRENCY')) || DEFAULT_CONCURRENCY;
this._worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
await this.processJob(job);
},
{
connection: this._connection,
prefix,
concurrency,
},
);
this._worker.on('failed', (job, error) => {
console.error(`[JOBS]: Job ${job?.name ?? 'unknown'} failed`, error);
});
this._worker.on('error', (error) => {
console.error('[JOBS]: Worker error', error);
});
console.log(`[JOBS]: BullMQ provider initialized (concurrency: ${concurrency})`);
}
/**
* Uses globalThis to store the singleton instance so that it's shared across
* different bundles (e.g. Hono and Vite/React Router) at runtime.
*/
static getInstance() {
if (globalThis.__documenso_bullmq_provider__) {
return globalThis.__documenso_bullmq_provider__;
}
const instance = new BullMQJobProvider();
globalThis.__documenso_bullmq_provider__ = instance;
return instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
this._jobDefinitions[definition.id] = {
...definition,
enabled: definition.enabled ?? true,
};
if (definition.trigger.cron && definition.enabled !== false) {
void this._queue
.upsertJobScheduler(
definition.id,
{ pattern: definition.trigger.cron },
{
name: definition.id,
data: {
name: definition.trigger.name,
payload: {},
},
opts: {
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
},
)
.then(() => {
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
})
.catch((error) => {
console.error(`[JOBS]: Failed to register cron job ${definition.id}`, error);
});
}
}
public async triggerJob(options: SimpleTriggerJobOptions) {
const eligibleJobs = Object.values(this._jobDefinitions).filter((job) => job.trigger.name === options.name);
await Promise.all(
eligibleJobs.map(async (job) => {
const backgroundJob = await prisma.backgroundJob.create({
data: {
jobId: job.id,
name: job.name,
version: job.version,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
payload: options.payload as Prisma.InputJsonValue,
},
});
await this._queue.add(
job.id,
{
name: options.name,
payload: options.payload,
backgroundJobId: backgroundJob.id,
},
{
jobId: options.id,
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
);
}),
);
}
public override getApiHandler(): (c: HonoContext) => Promise<Response | void> {
const boardApp = this.createBoardApp();
return async (c: HonoContext) => {
const reqPath = new URL(c.req.url).pathname;
if (!reqPath.startsWith('/api/jobs/board')) {
return c.text('OK', 200);
}
// Auth check — open in dev, admin-only in production.
if (env('NODE_ENV') !== 'development') {
const { getOptionalSession } = await import('@documenso/auth/server/lib/utils/get-session');
const { isAdmin } = await import('../../utils/is-admin');
const { user } = await getOptionalSession(c);
if (!user || !isAdmin(user)) {
return c.text('Unauthorized', 401);
}
}
return boardApp.fetch(c.req.raw);
};
}
private createBoardApp(): Hono {
const _require = createRequire(import.meta.url);
const uiPackagePath = path.dirname(_require.resolve('@bull-board/ui/package.json'));
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
queues: [new BullMQAdapter(this._queue)],
serverAdapter,
options: { uiBasePath: uiPackagePath },
});
serverAdapter.setBasePath('/api/jobs/board');
const app = new Hono();
app.route('/api/jobs/board', serverAdapter.registerPlugin());
return app;
}
private async processJob(job: Job) {
const definitionId = job.name;
const definition = this._jobDefinitions[definitionId];
if (!definition) {
console.error(`[JOBS]: No definition found for job ${definitionId}`);
throw new Error(`No definition found for job ${definitionId}`);
}
if (!definition.enabled) {
console.log(`[JOBS]: Skipping disabled job ${definitionId}`);
return;
}
const jobData = job.data as {
name: string;
payload: unknown;
backgroundJobId?: string;
};
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(jobData.payload);
if (!result.success) {
console.error(`[JOBS]: Payload validation failed for ${definitionId}`, result.error);
throw new Error(`Payload validation failed for ${definitionId}`);
}
}
const backgroundJobId = jobData.backgroundJobId;
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: {
id: backgroundJobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: job.attemptsMade > 0 ? job.attemptsMade : 0,
lastRetriedAt: job.attemptsMade > 0 ? new Date() : undefined,
},
})
.catch(() => null);
}
console.log(`[JOBS]: Processing job ${definitionId} with payload`, jobData.payload);
try {
await definition.handler({
payload: jobData.payload,
io: this.createJobRunIO(backgroundJobId ?? job.id ?? definitionId),
});
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
})
.catch(() => null);
}
} catch (error) {
if (backgroundJobId) {
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? DEFAULT_MAX_RETRIES) - 1;
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: isFinalAttempt ? BackgroundJobStatus.FAILED : BackgroundJobStatus.PENDING,
completedAt: isFinalAttempt ? new Date() : undefined,
},
})
.catch(() => null);
}
throw error;
}
}
private createJobRunIO(jobId: string): JobRunIO {
return {
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
let task = await prisma.backgroundJobTask.findFirst({
where: {
id: `task-${hashedKey}--${jobId}`,
jobId,
},
});
if (!task) {
task = await prisma.backgroundJobTask.create({
data: {
id: `task-${hashedKey}--${jobId}`,
name: cacheKey,
jobId,
status: BackgroundJobStatus.PENDING,
},
});
}
if (task.status === BackgroundJobStatus.COMPLETED) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return task.result as T;
}
if (task.retried >= DEFAULT_MAX_RETRIES) {
throw new Error('Task exceeded retries');
}
try {
const result = await callback();
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.COMPLETED,
result: result === null ? Prisma.JsonNull : result,
completedAt: new Date(),
},
});
return result;
} catch (err) {
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.PENDING,
retried: {
increment: 1,
},
},
});
console.log(`[JOBS:${task.id}] Task failed`, err);
throw err;
}
},
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
logger: {
debug: (...args) => console.debug(`[${jobId}]`, ...args),
error: (...args) => console.error(`[${jobId}]`, ...args),
info: (...args) => console.info(`[${jobId}]`, ...args),
log: (...args) => console.log(`[${jobId}]`, ...args),
warn: (...args) => console.warn(`[${jobId}]`, ...args),
},
// eslint-disable-next-line @typescript-eslint/require-await
wait: async () => {
throw new Error('Not implemented');
},
};
}
}
+13
View File
@@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { env } from '../../utils/env';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { BullMQJobProvider } from './bullmq';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
@@ -12,6 +13,7 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public constructor(definitions: T) {
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
.with('inngest', () => InngestJobProvider.getInstance())
.with('bullmq', () => BullMQJobProvider.getInstance())
.otherwise(() => LocalJobProvider.getInstance());
definitions.forEach((definition) => {
@@ -26,4 +28,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();
}
}
+15 -12
View File
@@ -1,7 +1,6 @@
import type { Context as HonoContext } from 'hono';
import type { Context, Handler, InngestFunction } from 'inngest';
import type { Context, Handler, InngestFunction, Logger } from 'inngest';
import { Inngest as InngestClient } from 'inngest';
import type { Logger } from 'inngest';
import { serve as createHonoPagesRoute } from 'inngest/hono';
import { env } from '../../utils/env';
@@ -12,8 +11,7 @@ export class InngestJobProvider extends BaseJobProvider {
private static _instance: InngestJobProvider;
private _client: InngestClient;
private _functions: Array<InngestFunction<InngestFunction.Options, Handler.Any, Handler.Any>> =
[];
private _functions: Array<InngestFunction<InngestFunction.Options, Handler.Any, Handler.Any>> = [];
private constructor(options: { client: InngestClient }) {
super();
@@ -22,29 +20,31 @@ export class InngestJobProvider extends BaseJobProvider {
}
static getInstance() {
if (!this._instance) {
if (!InngestJobProvider._instance) {
const client = new InngestClient({
id: env('NEXT_PRIVATE_INNGEST_APP_ID') || 'documenso-app',
eventKey: env('INNGEST_EVENT_KEY') || env('NEXT_PRIVATE_INNGEST_EVENT_KEY'),
logger: console,
});
this._instance = new InngestJobProvider({ client });
InngestJobProvider._instance = new InngestJobProvider({ client });
}
return this._instance;
return InngestJobProvider._instance;
}
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);
@@ -89,7 +89,10 @@ export class InngestJobProvider extends BaseJobProvider {
return {
wait: step.sleep,
logger: {
...ctx.logger,
info: ctx.logger.info,
debug: ctx.logger.debug,
error: ctx.logger.error,
warn: ctx.logger.warn,
log: ctx.logger.info,
},
runTask: async (cacheKey, callback) => {
+160 -17
View File
@@ -1,8 +1,8 @@
import { sha256 } from '@noble/hashes/sha256';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import type { Context as HonoContext } from 'hono';
import { prisma } from '@documenso/prisma';
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 { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { sign } from '../../server-only/crypto/sign';
@@ -16,21 +16,44 @@ 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();
}
static getInstance() {
if (!this._instance) {
this._instance = new LocalJobProvider();
if (!LocalJobProvider._instance) {
LocalJobProvider._instance = new LocalJobProvider();
}
return this._instance;
return LocalJobProvider._instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
@@ -38,12 +61,137 @@ 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) {
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
const eligibleJobs = Object.values(this._jobDefinitions).filter((job) => job.trigger.name === options.name);
await Promise.all(
eligibleJobs.map(async (job) => {
@@ -94,11 +242,7 @@ export class LocalJobProvider extends BaseJobProvider {
const definition = this._jobDefinitions[options.name];
if (
typeof jobId !== 'string' ||
typeof signature !== 'string' ||
typeof options !== 'object'
) {
if (typeof jobId !== 'string' || typeof signature !== 'string' || typeof options !== 'object') {
return c.text('Bad request', 400);
}
@@ -167,8 +311,7 @@ export class LocalJobProvider extends BaseJobProvider {
const taskHasExceededRetries = error instanceof BackgroundTaskExceededRetriesError;
const jobHasExceededRetries =
backgroundJob.retried >= backgroundJob.maxRetries &&
!(error instanceof BackgroundTaskFailedError);
backgroundJob.retried >= backgroundJob.maxRetries && !(error instanceof BackgroundTaskFailedError);
if (taskHasExceededRetries || jobHasExceededRetries) {
backgroundJob = await prisma.backgroundJob.update({
@@ -1,17 +1,16 @@
import { z } from 'zod';
import { zEmail } from '../../../utils/zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
email: zEmail(),
force: z.boolean().optional(),
});
export type TSendConfirmationEmailJobDefinition = z.infer<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA
>;
export type TSendConfirmationEmailJobDefinition = z.infer<typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
@@ -1,12 +1,10 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -17,13 +15,7 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendDocumentCancelledEmailsJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmailsJobDefinition; io: JobRunIO }) => {
const { documentId, cancellationReason } = payload;
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID = 'send.document.cancelled.emails';
@@ -0,0 +1,94 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
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 { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { TSendDocumentCreatedFromDirectTemplateEmailJobDefinition } from './send-document-created-from-direct-template-email';
export const run = async ({ payload }: { payload: TSendDocumentCreatedFromDirectTemplateEmailJobDefinition }) => {
const { envelopeId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
url: true,
},
},
documentMeta: true,
},
});
if (!envelope) {
throw new Error('Envelope not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Recipient not found');
}
const [recipient] = envelope.recipients;
const { user: templateOwner } = envelope;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url ?? '')}/${envelope.id}`;
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: recipient.email,
recipientRole: recipient.role,
documentLink,
documentName: envelope.title,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID =
'send.document.created.from.direct.template.email';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentCreatedFromDirectTemplateEmailJobDefinition = z.infer<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Created From Direct Template Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-created-from-direct-template-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA>
>;
@@ -1,10 +1,8 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -80,41 +78,38 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
await io.runTask(`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`, async () => {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
});
}
};
@@ -2,8 +2,7 @@ import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID =
'send.organisation-member-joined.email';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-member-joined.email';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
@@ -1,10 +1,8 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -75,40 +73,37 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
await io.runTask(`send-organisation-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
});
}
};
@@ -0,0 +1,107 @@
import { mailer } from '@documenso/email/mailer';
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
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
>;
@@ -1,11 +1,7 @@
import { sendResetPassword } from '../../../server-only/auth/send-reset-password';
import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-password-reset-success-email';
export const run = async ({
payload,
}: {
payload: TSendPasswordResetSuccessEmailJobDefinition;
}) => {
export const run = async ({ payload }: { payload: TSendPasswordResetSuccessEmailJobDefinition }) => {
await sendResetPassword({
userId: payload.userId,
});
@@ -1,11 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -17,13 +15,7 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
export const run = async ({
payload,
io,
}: {
payload: TSendRecipientSignedEmailJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJobDefinition; io: JobRunIO }) => {
const { documentId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
@@ -66,9 +58,7 @@ export const run = async ({
throw new Error('Document has no recipients');
}
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigned;
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigned;
if (!isRecipientSignedEmailEnabled) {
return;
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.signed.email';
@@ -9,9 +9,7 @@ const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TSendRecipientSignedEmailJobDefinition = z.infer<
typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export type TSendRecipientSignedEmailJobDefinition = z.infer<typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = {
id: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,
@@ -1,13 +1,11 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -20,13 +18,7 @@ import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRejectionEmailsJobDefinition } from './send-rejection-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendSigningRejectionEmailsJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmailsJobDefinition; io: JobRunIO }) => {
const { documentId, recipientId } = payload;
const [envelope, recipient] = await Promise.all([
@@ -66,9 +58,7 @@ export const run = async ({
const { user: documentOwner } = envelope;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
const isEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest;
if (!isEmailEnabled) {
return;
@@ -124,9 +114,7 @@ export const run = async ({
const ownerTemplate = createElement(DocumentRejectedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${
envelope.id
}`,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${envelope.id}`,
rejectionReason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID = 'send.signing.rejected.emails';
@@ -1,5 +1,7 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import {
DocumentSource,
@@ -9,19 +11,13 @@ import {
RecipientRole,
SendStatus,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -31,13 +27,7 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningEmailJobDefinition } from './send-signing-email';
export const run = async ({
payload,
io,
}: {
payload: TSendSigningEmailJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefinition; io: JobRunIO }) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
const [user, envelope, recipient] = await Promise.all([
@@ -93,15 +83,14 @@ export const run = async ({
return;
}
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const customEmail = envelope?.documentMeta;
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@@ -113,9 +102,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
@@ -131,9 +118,7 @@ export const run = async ({
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} this document created by your direct template`);
}
if (organisationType === OrganisationType.ORGANISATION) {
@@ -164,9 +149,7 @@ export const run = async ({
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? team?.teamEmail?.email || user.email
: user.email,
organisationType === OrganisationType.ORGANISATION ? team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
@@ -196,16 +179,15 @@ export const run = async ({
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
subject: renderCustomEmailTemplate(documentMeta?.subject || emailSubject, customEmailTemplate),
html,
text,
});
});
}
const sentAt = new Date();
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +195,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
sentAt,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
// Compute the first reminder time based on the envelope's effective settings.
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt,
lastReminderSentAt: null,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
};
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';
@@ -12,9 +12,7 @@ const SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendSigningEmailJobDefinition = z.infer<
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA
>;
export type TSendSigningEmailJobDefinition = z.infer<typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
id: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
@@ -29,7 +27,4 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
TSendSigningEmailJobDefinition
>;
} as const satisfies JobDefinition<typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_ID, TSendSigningEmailJobDefinition>;
@@ -2,13 +2,7 @@ import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamDeletedEmailJobDefinition } from './send-team-deleted-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamDeletedEmailJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSendTeamDeletedEmailJobDefinition; io: JobRunIO }) => {
const { team, members, organisationId } = payload;
for (const member of members) {
@@ -19,9 +19,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
),
});
export type TSendTeamDeletedEmailJobDefinition = z.infer<
typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export type TSendTeamDeletedEmailJobDefinition = z.infer<typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
@@ -4,13 +4,7 @@ import { AppError, AppErrorCode } from '../../../errors/app-error';
import type { JobRunIO } from '../../client/_internal/job';
import type { TBackportSubscriptionClaimJobDefinition } from './backport-subscription-claims';
export const run = async ({
payload,
io,
}: {
payload: TBackportSubscriptionClaimJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TBackportSubscriptionClaimJobDefinition; io: JobRunIO }) => {
const { subscriptionClaimId, flags } = payload;
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_ID = 'internal.backport-subscription-claims';
@@ -18,14 +18,14 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
embedSigning: z.literal(true).optional(),
embedSigningWhiteLabel: z.literal(true).optional(),
cfr21: z.literal(true).optional(),
hipaa: z.literal(true).optional(),
signingReminders: z.literal(true).optional(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
});
export type TBackportSubscriptionClaimJobDefinition = z.infer<
typeof BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA
>;
export type TBackportSubscriptionClaimJobDefinition = z.infer<typeof BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA>;
export const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION = {
id: BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_ID,
@@ -1,15 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { createElement } from 'react';
import { z } from 'zod';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -22,18 +21,12 @@ import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({
payload,
io,
}: {
payload: TBulkSendTemplateJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
const template = await getTemplateById({
@@ -81,7 +74,7 @@ export const run = async ({
const results = {
success: 0,
failed: 0,
errors: Array<string>(),
errors: [] as string[],
};
// Process each row
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
@@ -14,9 +14,7 @@ const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TBulkSendTemplateJobDefinition = z.infer<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
>;
export type TBulkSendTemplateJobDefinition = z.infer<typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA>;
export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
@@ -31,7 +29,4 @@ export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
TBulkSendTemplateJobDefinition
>;
} as const satisfies JobDefinition<typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID, TBulkSendTemplateJobDefinition>;
@@ -0,0 +1,35 @@
import { prisma } from '@documenso/prisma';
import { DateTime } from 'luxon';
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,25 @@
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>;
@@ -1,17 +1,12 @@
import { Prisma, WebhookCallStatus } from '@prisma/client';
import { executeWebhookCall } from '@documenso/lib/server-only/webhooks/execute-webhook-call';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { WebhookCallStatus } from '@prisma/client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TExecuteWebhookJobDefinition } from './execute-webhook';
export const run = async ({
payload,
io,
}: {
payload: TExecuteWebhookJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io: _io }: { payload: TExecuteWebhookJobDefinition; io: JobRunIO }) => {
const { event, webhookId, data } = payload;
const webhook = await prisma.webhook.findUniqueOrThrow({
@@ -29,44 +24,28 @@ export const run = async ({
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payloadData),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
const result = await executeWebhookCall({ url, body: payloadData, secret });
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
status: result.success ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
requestBody: payloadData as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
responseCode: result.responseCode,
responseBody: result.responseBody,
responseHeaders: result.responseHeaders,
webhookId: webhook.id,
},
});
if (!response.ok) {
throw new Error(`Webhook execution failed with status ${response.status}`);
if (!result.success) {
throw new Error(`Webhook execution failed with status ${result.responseCode}`);
}
return {
success: response.ok,
status: response.status,
success: true,
status: result.responseCode,
};
};
@@ -2,7 +2,7 @@ import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const EXECUTE_WEBHOOK_JOB_DEFINITION_ID = 'internal.execute-webhook';
@@ -28,7 +28,4 @@ export const EXECUTE_WEBHOOK_JOB_DEFINITION = {
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
TExecuteWebhookJobDefinition
>;
} as const satisfies JobDefinition<typeof EXECUTE_WEBHOOK_JOB_DEFINITION_ID, TExecuteWebhookJobDefinition>;
@@ -0,0 +1,47 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@prisma/client';
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,28 @@
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,83 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } 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,29 @@
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
>;
@@ -0,0 +1,207 @@
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
export const run = async ({ payload, io }: { payload: TProcessSigningReminderJobDefinition; io: JobRunIO }) => {
const { recipientId } = payload;
const now = new Date();
// Atomically claim this reminder by setting lastReminderSentAt and clearing
// nextReminderAt so no other sweep picks it up. The expiration filter
// guards against races where the expiration sweep hasn't yet flagged
// a recipient whose deadline has already passed.
const updatedCount = await prisma.recipient.updateMany({
where: {
id: recipientId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
data: {
lastReminderSentAt: now,
nextReminderAt: null,
},
});
if (updatedCount.count === 0) {
io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
return;
}
const recipient = await prisma.recipient.findFirst({
where: { id: recipientId },
include: {
envelope: {
include: {
documentMeta: true,
user: true,
recipients: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
io.logger.warn(`Recipient ${recipientId} not found`);
return;
}
const { envelope } = recipient;
if (!envelope.documentMeta) {
io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
return;
}
// Skip if distribution method is NONE (manual link sharing, no emails).
if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
return;
}
if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`);
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`);
}
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': envelope.title,
};
if (envelope.documentMeta.subject) {
emailSubject = renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
);
}
const emailMessage = envelope.documentMeta.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
},
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Compute the next reminder time (repeat interval).
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
});
}
};
@@ -0,0 +1,29 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TProcessSigningReminderJobDefinition = z.infer<typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA>;
export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
name: 'Process Signing Reminder',
version: '1.0.0',
trigger: {
name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./process-signing-reminder.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
TProcessSigningReminderJobDefinition
>;
@@ -0,0 +1,102 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentSweepJobDefinition } from './seal-document-sweep';
export const run = async ({ io }: { payload: TSealDocumentSweepJobDefinition; io: JobRunIO }) => {
const now = DateTime.now();
const fifteenMinutesAgo = now.minus({ minutes: 15 }).toJSDate();
const sixHoursAgo = now.minus({ hours: 6 }).toJSDate();
// Find all PENDING envelopes that should have been sealed but weren't.
//
// A document is ready to seal when either:
// 1. All recipients are SIGNED or have role CC (normal completion)
// 2. Any recipient has REJECTED (rejection triggers immediate seal)
//
// We only look at documents where the last action was between 15 minutes
// and 6 hours ago. The lower bound avoids racing with the normal seal-document
// job that fires on completion. The upper bound stops us from endlessly retrying
// documents that are stuck due to a deeper issue (e.g. corrupt PDF).
const unsealedEnvelopes = await kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.secondaryId'])
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.deletedAt', 'is', null)
// Ensure there is at least one recipient.
.where((eb) => eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')))
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
.where((eb) =>
eb.or([
// Case 1: All recipients are either SIGNED or CC.
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
),
),
// Case 2: Any recipient has rejected.
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
]),
)
// Exclude envelopes where a recipient signed/rejected within the last 15 minutes
// to avoid racing with the standard completion flow.
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', fifteenMinutesAgo),
),
),
)
// Exclude envelopes where all activity is older than 6 hours.
// These are likely stuck due to a deeper issue and should not be retried.
.where((eb) =>
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', sixHoursAgo),
),
)
.limit(100)
.execute();
if (unsealedEnvelopes.length === 0) {
io.logger.info('No unsealed documents found');
return;
}
io.logger.info(`Found ${unsealedEnvelopes.length} unsealed documents`);
await Promise.allSettled(
unsealedEnvelopes.map(async (envelope) => {
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
io.logger.info(`Triggering seal for document ${documentId} (${envelope.id})`);
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
isResealing: true,
},
});
}),
);
};
@@ -0,0 +1,25 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID = 'internal.seal-document-sweep';
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSealDocumentSweepJobDefinition = z.infer<typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA>;
export const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION = {
id: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
name: 'Seal Document Sweep',
version: '1.0.0',
trigger: {
name: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./seal-document-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID, TSealDocumentSweepJobDefinition>;
@@ -1,23 +1,16 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { PDFDocument } from '@cantoo/pdf-lib';
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 { PDF } from '@libpdf/core';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { nanoid } from 'nanoid';
import { groupBy } from 'remeda';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
@@ -29,14 +22,8 @@ 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,
type TDocumentAuditLog,
} from '../../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
@@ -47,13 +34,7 @@ import { mapDocumentIdToSecondaryId } from '../../../utils/envelope';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
export const run = async ({
payload,
io,
}: {
payload: TSealDocumentJobDefinition;
io: JobRunIO;
}) => {
export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition; io: JobRunIO }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
@@ -112,8 +93,7 @@ export const run = async ({
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every(
(recipient) =>
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
(recipient) => recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
);
if (!isComplete) {
@@ -130,9 +110,7 @@ export const run = async ({
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const recipientsWithoutCCers = envelope.recipients.filter(
(recipient) => recipient.role !== RecipientRole.CC,
);
const recipientsWithoutCCers = envelope.recipients.filter((recipient) => recipient.role !== RecipientRole.CC);
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipientsWithoutCCers.find(
@@ -204,9 +182,7 @@ export const run = async ({
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const { envelopeItem, pdfData } of prefetchedItems) {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
const envelopeItemFields = envelope.envelopeItems.find((item) => item.id === envelopeItem.id)?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
@@ -285,18 +261,13 @@ export const run = async ({
await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({
await tx.envelopeItem.update({
where: {
id: newDocumentDataId,
},
});
await tx.documentData.update({
where: {
id: oldDocumentDataId,
envelopeId: envelope.id,
documentDataId: oldDocumentDataId,
},
data: {
data: newData.data,
documentDataId: newDocumentDataId,
},
});
}
@@ -349,9 +320,7 @@ export const run = async ({
});
await triggerWebhook({
event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
event: isRejected ? WebhookTriggerEvents.DOCUMENT_REJECTED : WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
userId: updatedEnvelope.userId,
teamId: updatedEnvelope.teamId ?? undefined,
@@ -496,11 +465,14 @@ const decorateAndSignPdf = async ({
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBytes),
});
const { documentData: newDocumentData } = await putPdfFileServerSide(
{
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBytes),
},
envelopeItem.documentData.initialData,
);
return {
oldDocumentDataId: envelopeItem.documentData.id,
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
import type { JobDefinition } from '../../client/_internal/job';
const SEAL_DOCUMENT_JOB_DEFINITION_ID = 'internal.seal-document';
@@ -28,7 +28,4 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEAL_DOCUMENT_JOB_DEFINITION_ID,
TSealDocumentJobDefinition
>;
} as const satisfies JobDefinition<typeof SEAL_DOCUMENT_JOB_DEFINITION_ID, TSealDocumentJobDefinition>;
@@ -0,0 +1,48 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
export const run = async ({ io }: { payload: TSendSigningRemindersSweepJobDefinition; io: JobRunIO }) => {
const now = new Date();
const recipients = await prisma.recipient.findMany({
where: {
nextReminderAt: { lte: now },
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
// Skip recipients whose signing deadline has passed. `expiresAt`
// is the source of truth — the expiration sweep asynchronously
// sets `expirationNotifiedAt`, so filtering on `expiresAt` also
// covers the window before the expiration sweep runs.
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
select: { id: true },
take: 1000,
});
if (recipients.length === 0) {
io.logger.info('No recipients need signing reminders');
return;
}
io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
await Promise.allSettled(
recipients.map(async (recipient) => {
await jobs.triggerJob({
name: 'internal.process-signing-reminder',
payload: {
recipientId: recipient.id,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSendSigningRemindersSweepJobDefinition = z.infer<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
name: 'Send Signing Reminders Sweep',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./send-signing-reminders-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
TSendSigningRemindersSweepJobDefinition
>;
@@ -0,0 +1,91 @@
import { reregisterEmailDomain } from '@documenso/ee/server-only/lib/reregister-email-domain';
import { verifyEmailDomain } from '@documenso/ee/server-only/lib/verify-email-domain';
import { prisma } from '@documenso/prisma';
import { DateTime } from 'luxon';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSyncEmailDomainsJobDefinition } from './sync-email-domains';
const BATCH_SIZE = 10;
const AUTO_REREGISTER_AFTER_HOURS = 48;
export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io: JobRunIO }) => {
const pendingDomains = await prisma.emailDomain.findMany({
where: {
status: 'PENDING',
},
select: {
id: true,
domain: true,
createdAt: true,
lastVerifiedAt: true,
},
orderBy: {
lastVerifiedAt: { sort: 'asc', nulls: 'first' },
},
});
if (pendingDomains.length === 0) {
io.logger.info('No pending email domains to sync');
return;
}
io.logger.info(`Found ${pendingDomains.length} pending email domains to sync`);
let verifiedCount = 0;
let reregisteredCount = 0;
let errorCount = 0;
const reregisterCutoff = DateTime.now().minus({ hours: AUTO_REREGISTER_AFTER_HOURS }).toJSDate();
for (let i = 0; i < pendingDomains.length; i += BATCH_SIZE) {
const batch = pendingDomains.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (domain) => {
const shouldReregister = domain.createdAt < reregisterCutoff;
const { isVerified } = await verifyEmailDomain(domain.id);
if (isVerified) {
io.logger.info(`Domain "${domain.domain}" is verified`);
return 'verified' as const;
}
if (shouldReregister) {
io.logger.info(
`Domain "${domain.domain}" has been pending since ${domain.createdAt.toISOString()}, attempting re-registration`,
);
await reregisterEmailDomain({ emailDomainId: domain.id });
return 'reregistered' as const;
}
return 'pending' as const;
}),
);
for (const result of results) {
if (result.status === 'rejected') {
errorCount++;
io.logger.error(`Failed to process email domain: ${String(result.reason)}`);
} else if (result.value === 'verified') {
verifiedCount++;
} else if (result.value === 'reregistered') {
reregisteredCount++;
}
}
// Small delay between batches to respect SES API rate limits.
if (i + BATCH_SIZE < pendingDomains.length) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
}
}
io.logger.info(
`Sync complete: ${verifiedCount} verified, ${reregisteredCount} re-registered, ${errorCount} errors out of ${pendingDomains.length} pending domains`,
);
};
@@ -0,0 +1,25 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SYNC_EMAIL_DOMAINS_JOB_DEFINITION_ID = 'internal.sync-email-domains';
const SYNC_EMAIL_DOMAINS_JOB_DEFINITION_SCHEMA = z.object({});
export type TSyncEmailDomainsJobDefinition = z.infer<typeof SYNC_EMAIL_DOMAINS_JOB_DEFINITION_SCHEMA>;
export const SYNC_EMAIL_DOMAINS_JOB_DEFINITION = {
id: SYNC_EMAIL_DOMAINS_JOB_DEFINITION_ID,
name: 'Sync Email Domains',
version: '1.0.0',
trigger: {
name: SYNC_EMAIL_DOMAINS_JOB_DEFINITION_ID,
schema: SYNC_EMAIL_DOMAINS_JOB_DEFINITION_SCHEMA,
cron: '0 * * * *', // Every hour, on the hour.
},
handler: async ({ payload, io }) => {
const handler = await import('./sync-email-domains.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<typeof SYNC_EMAIL_DOMAINS_JOB_DEFINITION_ID, TSyncEmailDomainsJobDefinition>;
+18 -11
View File
@@ -10,17 +10,20 @@
"universal/"
],
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@aws-sdk/client-s3": "^3.936.0",
"@aws-sdk/client-sesv2": "^3.936.0",
"@aws-sdk/cloudfront-signer": "^3.935.0",
"@aws-sdk/s3-request-presigner": "^3.936.0",
"@aws-sdk/signature-v4-crt": "^3.936.0",
"@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0",
"@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
"@documenso/assets": "*",
"@documenso/email": "*",
"@documenso/prisma": "*",
@@ -39,11 +42,13 @@
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"bullmq": "^5.71.1",
"csv-parse": "^6.1.0",
"inngest": "^3.45.1",
"inngest": "^3.54.0",
"ioredis": "^5.10.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.8",
"kysely": "0.28.16",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
@@ -52,10 +57,11 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.0",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -66,6 +72,7 @@
"devDependencies": {
"@playwright/browser-chromium": "1.56.1",
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
"@types/pg": "^8.15.6",
"vitest": "^4.0.18"
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
import { PlainClient } from '@team-plain/typescript-sdk';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
+2 -6
View File
@@ -1,17 +1,13 @@
import { prisma } from '@documenso/prisma';
import type { User } from '@prisma/client';
import { UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: Pick<
User,
'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'
>;
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;
@@ -1,12 +1,10 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -108,32 +106,29 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};
+1 -2
View File
@@ -1,6 +1,5 @@
import { type User, UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
@@ -25,9 +25,7 @@ export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
throw new Error('User has no backup codes');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
'utf-8',
);
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString('utf-8');
const data = JSON.parse(secret);
@@ -6,8 +6,6 @@ type IsTwoFactorAuthenticationEnabledOptions = {
user: User;
};
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
export const isTwoFactorAuthenticationEnabled = ({ user }: IsTwoFactorAuthenticationEnabledOptions) => {
return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string';
};
+3 -6
View File
@@ -1,10 +1,9 @@
import { type User } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { User } from '@prisma/client';
import { base32 } from '@scure/base';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
import { prisma } from '@documenso/prisma';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
@@ -14,9 +13,7 @@ type SetupTwoFactorAuthenticationOptions = {
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
}: SetupTwoFactorAuthenticationOptions) => {
export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {

Some files were not shown because too many files have changed in this diff Show More