Merge branch 'main' into feat/public-completed-document-access

This commit is contained in:
ephraimduncan
2026-04-22 18:29:11 +00:00
625 changed files with 48707 additions and 14739 deletions
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => {
export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
@@ -14,7 +14,10 @@ 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;
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
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';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
id: z.number().optional(),
@@ -37,7 +37,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
@@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
@@ -222,14 +222,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 +243,7 @@ export const useEditorFields = ({
append(newField);
newFields.push(newField);
});
}
triggerFieldsUpdate();
return newFields;
@@ -1,7 +1,7 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -11,10 +11,9 @@ 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 type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -36,12 +35,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 = {
@@ -17,7 +17,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 +36,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
useEffect(() => {
setBounds(calculateBounds());
}, []);
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
@@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
}, [calculateBounds]);
useEffect(() => {
const $el =
@@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, []);
}, [elementOrSelector, calculateBounds]);
return bounds;
};
@@ -3,7 +3,10 @@ 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';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
@@ -57,23 +60,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,39 @@
import { useEffect, useState } from 'react';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
/**
* 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,86 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
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(function () {
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,51 +1,44 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { useSearchParams } from 'react-router';
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 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 { 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 +50,9 @@ type EnvelopeEditorProviderValue = {
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean;
resetForms: () => void;
relativePath: {
basePath: string;
@@ -68,12 +62,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 +92,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,61 +144,35 @@ 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();
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
/**
* 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'] = [];
setAutosaveError(true);
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
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,
});
},
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: ({ data: fields }) => {
setEnvelope((prev) => ({
...prev,
fields,
}));
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 envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
@@ -178,8 +189,7 @@ export const EnvelopeEditorProvider = ({
);
setAutosaveError(false);
},
onError: (err) => {
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -190,58 +200,137 @@ export const EnvelopeEditorProvider = ({
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 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: setFieldsAsync,
flush: flushSetFields,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
// Insert the IDs into the local fields.
envelopeFields.data.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
}
});
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
// 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);
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,
});
}
}, 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 +342,23 @@ 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 +370,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 +384,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 +488,113 @@ 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,26 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import { type Field, type 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 { 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';
};
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,10 +28,22 @@ 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'];
@@ -46,7 +61,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.
@@ -70,6 +104,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 +130,61 @@ 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,
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,35 +192,20 @@ 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 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,
+18 -1
View File
@@ -71,11 +71,28 @@ 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,
+4 -2
View File
@@ -7,6 +7,8 @@ import {
SigningStatus,
} from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
@@ -16,7 +18,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
recipient: Recipient,
recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
+37
View File
@@ -69,3 +69,40 @@ 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);
};
+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',
}),
};
@@ -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>;
+6
View File
@@ -9,6 +9,12 @@ import { DocumentSignatureType } from '@documenso/lib/utils/teams';
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,86 @@
import type { DurationLikeObject } from 'luxon';
import { Duration } from 'luxon';
import { z } from 'zod';
export const ZEnvelopeReminderDurationPeriod = z.object({
unit: z.enum(['day', 'week', 'month']),
amount: z.number().int().min(1),
});
export const ZEnvelopeReminderDisabledPeriod = z.object({
disabled: z.literal(true),
});
export const ZEnvelopeReminderPeriod = z.union([
ZEnvelopeReminderDurationPeriod,
ZEnvelopeReminderDisabledPeriod,
]);
export type TEnvelopeReminderPeriod = z.infer<typeof ZEnvelopeReminderPeriod>;
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
export const ZEnvelopeReminderSettings = z.object({
sendAfter: ZEnvelopeReminderPeriod,
repeatEvery: ZEnvelopeReminderPeriod,
});
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
sendAfter: { unit: 'day', amount: 5 },
repeatEvery: { unit: 'day', amount: 2 },
};
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
{
day: 'days',
week: 'weeks',
month: 'months',
};
export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
/**
* Resolve the next reminder timestamp from the config and the last reminder sent time.
*
* - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
if (!config) {
return null;
}
// If we haven't sent the first reminder yet, use sendAfter.
if (!lastReminderSentAt) {
if ('disabled' in config.sendAfter) {
return null;
}
const delay = getEnvelopeReminderDuration(config.sendAfter);
return new Date(sentAt.getTime() + delay.toMillis());
}
// For subsequent reminders, use repeatEvery.
if ('disabled' in config.repeatEvery) {
return null;
}
const interval = getEnvelopeReminderDuration(config.repeatEvery);
return new Date(lastReminderSentAt.getTime() + interval.toMillis());
};
-3
View File
@@ -1,13 +1,10 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES, type SupportedLanguageCodes } from './locales';
export * from './locales';
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
export type I18nLocaleData = {
/**
* The supported language extracted from the locale.
+4
View File
@@ -1,3 +1,5 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
@@ -19,3 +21,5 @@ export const APP_I18N_OPTIONS = {
sourceLang: 'en',
defaultLocale: 'en-US',
} as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
+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 }>;
+18 -1
View File
@@ -1,2 +1,19 @@
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;
};
+3
View File
@@ -13,12 +13,14 @@ export enum AppErrorCode {
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@@ -28,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
+8
View File
@@ -1,6 +1,7 @@
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';
@@ -15,7 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -29,16 +33,20 @@ 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);
+387
View File
@@ -0,0 +1,387 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import type { Job } from 'bullmq';
import { Hono } from 'hono';
import type { Context as HonoContext } from 'hono';
import IORedis from 'ioredis';
import { createRequire } from 'node:module';
import path from 'node:path';
import { prisma } from '@documenso/prisma';
import { env } from '../../utils/env';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
const QUEUE_NAME = 'documenso-jobs';
const DEFAULT_CONCURRENCY = 10;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_DELAY = 1000;
declare global {
// eslint-disable-next-line no-var
var __documenso_bullmq_provider__: BullMQJobProvider | undefined;
}
export class BullMQJobProvider extends BaseJobProvider {
private _queue: Queue;
private _worker: Worker;
private _connection: IORedis;
private _jobDefinitions: Record<string, JobDefinition> = {};
private constructor() {
super();
const redisUrl = env('NEXT_PRIVATE_REDIS_URL');
if (!redisUrl) {
throw new Error(
'[JOBS]: NEXT_PRIVATE_REDIS_URL is required when using the BullMQ jobs provider',
);
}
const prefix = env('NEXT_PRIVATE_REDIS_PREFIX') || 'documenso';
this._connection = new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
this._queue = new Queue(QUEUE_NAME, {
connection: this._connection,
prefix,
});
const concurrency = Number(env('NEXT_PRIVATE_BULLMQ_CONCURRENCY')) || DEFAULT_CONCURRENCY;
this._worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
await this.processJob(job);
},
{
connection: this._connection,
prefix,
concurrency,
},
);
this._worker.on('failed', (job, error) => {
console.error(`[JOBS]: Job ${job?.name ?? 'unknown'} failed`, error);
});
this._worker.on('error', (error) => {
console.error('[JOBS]: Worker error', error);
});
console.log(`[JOBS]: BullMQ provider initialized (concurrency: ${concurrency})`);
}
/**
* Uses globalThis to store the singleton instance so that it's shared across
* different bundles (e.g. Hono and Vite/React Router) at runtime.
*/
static getInstance() {
if (globalThis.__documenso_bullmq_provider__) {
return globalThis.__documenso_bullmq_provider__;
}
const instance = new BullMQJobProvider();
globalThis.__documenso_bullmq_provider__ = instance;
return instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
this._jobDefinitions[definition.id] = {
...definition,
enabled: definition.enabled ?? true,
};
if (definition.trigger.cron && definition.enabled !== false) {
void this._queue
.upsertJobScheduler(
definition.id,
{ pattern: definition.trigger.cron },
{
name: definition.id,
data: {
name: definition.trigger.name,
payload: {},
},
opts: {
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
},
)
.then(() => {
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
})
.catch((error) => {
console.error(`[JOBS]: Failed to register cron job ${definition.id}`, error);
});
}
}
public async triggerJob(options: SimpleTriggerJobOptions) {
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
await Promise.all(
eligibleJobs.map(async (job) => {
const backgroundJob = await prisma.backgroundJob.create({
data: {
jobId: job.id,
name: job.name,
version: job.version,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
payload: options.payload as Prisma.InputJsonValue,
},
});
await this._queue.add(
job.id,
{
name: options.name,
payload: options.payload,
backgroundJobId: backgroundJob.id,
},
{
jobId: options.id,
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
);
}),
);
}
public override getApiHandler(): (c: HonoContext) => Promise<Response | void> {
const boardApp = this.createBoardApp();
return async (c: HonoContext) => {
const reqPath = new URL(c.req.url).pathname;
if (!reqPath.startsWith('/api/jobs/board')) {
return c.text('OK', 200);
}
// Auth check — open in dev, admin-only in production.
if (env('NODE_ENV') !== 'development') {
const { getOptionalSession } = await import('@documenso/auth/server/lib/utils/get-session');
const { isAdmin } = await import('../../utils/is-admin');
const { user } = await getOptionalSession(c);
if (!user || !isAdmin(user)) {
return c.text('Unauthorized', 401);
}
}
return boardApp.fetch(c.req.raw);
};
}
private createBoardApp(): Hono {
const _require = createRequire(import.meta.url);
const uiPackagePath = path.dirname(_require.resolve('@bull-board/ui/package.json'));
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
queues: [new BullMQAdapter(this._queue)],
serverAdapter,
options: { uiBasePath: uiPackagePath },
});
serverAdapter.setBasePath('/api/jobs/board');
const app = new Hono();
app.route('/api/jobs/board', serverAdapter.registerPlugin());
return app;
}
private async processJob(job: Job) {
const definitionId = job.name;
const definition = this._jobDefinitions[definitionId];
if (!definition) {
console.error(`[JOBS]: No definition found for job ${definitionId}`);
throw new Error(`No definition found for job ${definitionId}`);
}
if (!definition.enabled) {
console.log(`[JOBS]: Skipping disabled job ${definitionId}`);
return;
}
const jobData = job.data as {
name: string;
payload: unknown;
backgroundJobId?: string;
};
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(jobData.payload);
if (!result.success) {
console.error(`[JOBS]: Payload validation failed for ${definitionId}`, result.error);
throw new Error(`Payload validation failed for ${definitionId}`);
}
}
const backgroundJobId = jobData.backgroundJobId;
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: {
id: backgroundJobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: job.attemptsMade > 0 ? job.attemptsMade : 0,
lastRetriedAt: job.attemptsMade > 0 ? new Date() : undefined,
},
})
.catch(() => null);
}
console.log(`[JOBS]: Processing job ${definitionId} with payload`, jobData.payload);
try {
await definition.handler({
payload: jobData.payload,
io: this.createJobRunIO(backgroundJobId ?? job.id ?? definitionId),
});
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
})
.catch(() => null);
}
} catch (error) {
if (backgroundJobId) {
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? DEFAULT_MAX_RETRIES) - 1;
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: isFinalAttempt ? BackgroundJobStatus.FAILED : BackgroundJobStatus.PENDING,
completedAt: isFinalAttempt ? new Date() : undefined,
},
})
.catch(() => null);
}
throw error;
}
}
private createJobRunIO(jobId: string): JobRunIO {
return {
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
let task = await prisma.backgroundJobTask.findFirst({
where: {
id: `task-${hashedKey}--${jobId}`,
jobId,
},
});
if (!task) {
task = await prisma.backgroundJobTask.create({
data: {
id: `task-${hashedKey}--${jobId}`,
name: cacheKey,
jobId,
status: BackgroundJobStatus.PENDING,
},
});
}
if (task.status === BackgroundJobStatus.COMPLETED) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return task.result as T;
}
if (task.retried >= DEFAULT_MAX_RETRIES) {
throw new Error('Task exceeded retries');
}
try {
const result = await callback();
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.COMPLETED,
result: result === null ? Prisma.JsonNull : result,
completedAt: new Date(),
},
});
return result;
} catch (err) {
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.PENDING,
retried: {
increment: 1,
},
},
});
console.log(`[JOBS:${task.id}] Task failed`, err);
throw err;
}
},
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
logger: {
debug: (...args) => console.debug(`[${jobId}]`, ...args),
error: (...args) => console.error(`[${jobId}]`, ...args),
info: (...args) => console.info(`[${jobId}]`, ...args),
log: (...args) => console.log(`[${jobId}]`, ...args),
warn: (...args) => console.warn(`[${jobId}]`, ...args),
},
// eslint-disable-next-line @typescript-eslint/require-await
wait: async () => {
throw new Error('Not implemented');
},
};
}
}
+2
View File
@@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { env } from '../../utils/env';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { BullMQJobProvider } from './bullmq';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
@@ -12,6 +13,7 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public constructor(definitions: T) {
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
.with('inngest', () => InngestJobProvider.getInstance())
.with('bullmq', () => BullMQJobProvider.getInstance())
.otherwise(() => LocalJobProvider.getInstance());
definitions.forEach((definition) => {
@@ -1,11 +1,12 @@
import { z } from 'zod';
import { zEmail } from '../../../utils/zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
email: zEmail(),
force: z.boolean().optional(),
});
@@ -0,0 +1,100 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { 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>
>;
@@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -206,6 +207,8 @@ export const run = async ({
});
}
const sentAt = new Date();
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +216,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
sentAt,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
// Compute the first reminder time based on the envelope's effective settings.
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt,
lastReminderSentAt: null,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
};
@@ -18,6 +18,7 @@ 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(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
@@ -9,6 +9,7 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -22,7 +23,7 @@ import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
@@ -1,5 +1,7 @@
import { Prisma, WebhookCallStatus } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { WebhookCallStatus } from '@prisma/client';
import { executeWebhookCall } from '@documenso/lib/server-only/webhooks/execute-webhook-call';
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
@@ -7,7 +9,7 @@ import type { TExecuteWebhookJobDefinition } from './execute-webhook';
export const run = async ({
payload,
io,
io: _io,
}: {
payload: TExecuteWebhookJobDefinition;
io: JobRunIO;
@@ -29,44 +31,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,
};
};
@@ -0,0 +1,222 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
export const run = async ({
payload,
io,
}: {
payload: TProcessSigningReminderJobDefinition;
io: JobRunIO;
}) => {
const { recipientId } = payload;
const now = new Date();
// Atomically claim this reminder by setting lastReminderSentAt and clearing
// nextReminderAt so no other sweep picks it up.
const updatedCount = await prisma.recipient.updateMany({
where: {
id: recipientId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
data: {
lastReminderSentAt: now,
nextReminderAt: null,
},
});
if (updatedCount.count === 0) {
io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
return;
}
const recipient = await prisma.recipient.findFirst({
where: { id: recipientId },
include: {
envelope: {
include: {
documentMeta: true,
user: true,
recipients: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
io.logger.warn(`Recipient ${recipientId} not found`);
return;
}
const { envelope } = recipient;
if (!envelope.documentMeta) {
io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
return;
}
// Skip if distribution method is NONE (manual link sharing, no emails).
if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
return;
}
if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailSubject = i18n._(
msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
);
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
}
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': envelope.title,
};
if (envelope.documentMeta.subject) {
emailSubject = renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
);
}
const emailMessage = envelope.documentMeta.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
},
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Compute the next reminder time (repeat interval).
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
});
}
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TProcessSigningReminderJobDefinition = z.infer<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
>;
export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
name: 'Process Signing Reminder',
version: '1.0.0',
trigger: {
name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./process-signing-reminder.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
TProcessSigningReminderJobDefinition
>;
@@ -0,0 +1,105 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
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,30 @@
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
>;
@@ -285,18 +285,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,
},
});
}
@@ -496,11 +491,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,
@@ -0,0 +1,49 @@
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
export const run = async ({
io,
}: {
payload: TSendSigningRemindersSweepJobDefinition;
io: JobRunIO;
}) => {
const now = new Date();
const recipients = await prisma.recipient.findMany({
where: {
nextReminderAt: { lte: now },
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
select: { id: true },
take: 1000,
});
if (recipients.length === 0) {
io.logger.info('No recipients need signing reminders');
return;
}
io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
await Promise.allSettled(
recipients.map(async (recipient) => {
await jobs.triggerJob({
name: 'internal.process-signing-reminder',
payload: {
recipientId: recipient.id,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSendSigningRemindersSweepJobDefinition = z.infer<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
name: 'Send Signing Reminders Sweep',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./send-signing-reminders-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
TSendSigningRemindersSweepJobDefinition
>;
@@ -46,6 +46,14 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io:
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`,
@@ -55,9 +63,7 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io:
return 'reregistered' as const;
}
const { isVerified } = await verifyEmailDomain(domain.id);
return isVerified ? ('verified' as const) : ('pending' as const);
return 'pending' as const;
}),
);
+11 -4
View File
@@ -10,6 +10,8 @@
"universal/"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
@@ -21,6 +23,9 @@
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0",
"@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
"@documenso/assets": "*",
"@documenso/email": "*",
"@documenso/prisma": "*",
@@ -39,11 +44,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",
@@ -55,7 +62,6 @@
"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"
}
}
@@ -108,32 +108,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,
},
}),
});
};
@@ -0,0 +1,102 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
import type { FindResultResponse } from '../../types/search-params';
export type AdminUnsealedDocument = {
id: string;
secondaryId: string;
title: string;
status: string;
createdAt: Date;
updatedAt: Date;
userId: number;
teamId: number;
ownerName: string | null;
ownerEmail: string;
lastSignedAt: Date | null;
};
export type AdminFindUnsealedDocumentsOptions = {
page?: number;
perPage?: number;
};
export const adminFindUnsealedDocuments = async ({
page = 1,
perPage = 20,
}: AdminFindUnsealedDocumentsOptions): Promise<FindResultResponse<AdminUnsealedDocument[]>> => {
const offset = Math.max(page - 1, 0) * perPage;
const baseQuery = kyselyPrisma.$kysely
.selectFrom('Envelope')
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.deletedAt', 'is', null)
// Must have 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)),
),
]),
);
const [data, countResult] = await Promise.all([
baseQuery
.innerJoin('User', 'User.id', 'Envelope.userId')
.select([
'Envelope.id',
'Envelope.secondaryId',
'Envelope.title',
'Envelope.status',
'Envelope.createdAt',
'Envelope.updatedAt',
'Envelope.userId',
'Envelope.teamId',
'User.name as ownerName',
'User.email as ownerEmail',
])
.select((eb) =>
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.select(sql<Date>`max("Recipient"."signedAt")`.as('lastSignedAt'))
.as('lastSignedAt'),
)
.orderBy('Envelope.createdAt', 'desc')
.limit(perPage)
.offset(offset)
.execute(),
baseQuery.select(({ fn }) => [fn.countAll().as('count')]).execute(),
]);
const count = Number(countResult[0]?.count ?? 0);
return {
data: data as unknown as AdminUnsealedDocument[],
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};
@@ -1,4 +1,4 @@
import { SigningStatus } from '@prisma/client';
import { type RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -6,9 +6,10 @@ export type UpdateRecipientOptions = {
id: number;
name: string | undefined;
email: string | undefined;
role: RecipientRole | undefined;
};
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
export const updateRecipient = async ({ id, name, email, role }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id,
@@ -26,6 +27,7 @@ export const updateRecipient = async ({ id, name, email }: UpdateRecipientOption
data: {
name,
email,
role,
},
});
};
@@ -0,0 +1,107 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { logger } from '../../utils/logger';
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
type TurnstileVerifyResponse = {
success: boolean;
'error-codes': string[];
challenge_ts?: string;
hostname?: string;
};
/**
* Verify a captcha token server-side.
*
* Currently supports Cloudflare Turnstile. This is a no-op if
* `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha
* verification an opt-in feature.
*/
export const verifyCaptchaToken = async ({
token,
ipAddress,
}: {
token?: string | null;
ipAddress?: string | null;
}) => {
const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY;
// If no secret key is configured, skip verification.
if (!secretKey) {
return;
}
if (!token) {
logger.warn({
msg: 'Captcha verification rejected: missing token',
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha token is required',
statusCode: 400,
});
}
const formData = new URLSearchParams();
formData.append('secret', secretKey);
formData.append('response', token);
if (ipAddress) {
formData.append('remoteip', ipAddress);
}
let response: Response;
try {
response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
} catch (err) {
logger.error({
msg: 'Captcha verification failed: network error calling siteverify',
err,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha verification failed',
statusCode: 400,
});
}
if (!response.ok) {
logger.error({
msg: 'Captcha verification failed: non-2xx response from siteverify',
status: response.status,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification request failed with status ${response.status}`,
statusCode: 400,
});
}
const result: TurnstileVerifyResponse = await response.json();
if (!result.success) {
logger.warn({
msg: 'Captcha verification rejected by provider',
errorCodes: result['error-codes'],
hostname: result.hostname,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`,
statusCode: 400,
});
}
};
@@ -5,14 +5,25 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
/**
* The initial data that was used to create the document data.
*
* If not provided, the current data will be used.
*/
initialData?: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
export const createDocumentData = async ({
type,
data,
initialData,
}: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
initialData: initialData || data,
},
});
};
@@ -8,7 +8,10 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
@@ -109,7 +112,9 @@ export const completeDocumentWithToken = async ({
}
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
const isRecipientsTurn = await getIsRecipientsTurnToSign({
token: recipient.token,
});
if (!isRecipientsTurn) {
throw new Error(
@@ -118,46 +123,6 @@ export const completeDocumentWithToken = async ({
}
}
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -216,6 +181,112 @@ export const completeDocumentWithToken = async ({
});
}
let fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// This should be scoped to the current recipient.
const uninsertedDateFields = fields.filter(
(field) => field.type === FieldType.DATE && !field.inserted,
);
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Auto-insert all un-inserted date fields for V2 envelopes at completion time.
if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
const formattedDate = DateTime.now()
.setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
const newDateFieldValues = {
customText: formattedDate,
inserted: true,
};
await prisma.field.updateMany({
where: {
id: {
in: uninsertedDateFields.map((field) => field.id),
},
},
data: {
...newDateFieldValues,
},
});
// Create audit log entries for each auto-inserted date field.
await prisma.documentAuditLog.createMany({
data: uninsertedDateFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
envelopeId: envelope.id,
user: {
email: recipientEmail,
name: recipientName,
},
requestMetadata,
data: {
recipientEmail: recipientEmail,
recipientId: recipient.id,
recipientName: recipientName,
recipientRole: recipient.role,
fieldId: field.secondaryId,
field: {
type: FieldType.DATE,
data: formattedDate,
},
},
}),
),
});
// Update the local fields array so the subsequent validation check passes.
fields = fields.map((field) => {
if (field.type === FieldType.DATE && !field.inserted) {
return {
...field,
...newDateFieldValues,
};
}
return field;
});
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
@@ -286,6 +357,18 @@ export const completeDocumentWithToken = async ({
});
});
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
@@ -359,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -367,16 +451,16 @@ export const completeDocumentWithToken = async ({
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
@@ -93,6 +93,13 @@ export const deleteDocument = async ({
user,
requestMetadata,
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
}
// Continue to hide the document from the user if they are a recipient.
@@ -112,13 +119,6 @@ export const deleteDocument = async ({
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
return envelope;
};
File diff suppressed because it is too large Load Diff
@@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({
title: true,
order: true,
envelopeId: true,
documentDataId: true,
documentData: true,
},
},
+275 -336
View File
@@ -1,368 +1,307 @@
import type { Prisma, User } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SigningStatus,
TeamMemberRole,
} from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { STATS_COUNT_CAP } from '../../constants/document';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { getTeamById } from '../team/get-team';
// Kysely query builder type for Envelope queries.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
// Expression builder type scoped to Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
/**
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
* envelope with the given email, plus optional extra conditions.
*/
const recipientExists = (
eb: EnvelopeExpressionBuilder,
email: string,
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
) => {
let sub = eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', email);
if (extra) {
sub = sub.where(extra);
}
return eb.exists(sub.select(sql.lit(1).as('one')));
};
/**
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
*/
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
eb.exists(
eb
.selectFrom('User')
.whereRef('User.id', '=', 'Envelope.userId')
.where('User.email', '=', email)
.select(sql.lit(1).as('one')),
);
export type GetStatsInput = {
user: Pick<User, 'id' | 'email'>;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
userId: number;
teamId: number;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
senderIds?: number[];
};
/**
* Builds a capped count from a query builder: wraps it as
* `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub`
* and clamps the result to STATS_COUNT_CAP.
*/
const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
const result = await kyselyPrisma.$kysely
.selectFrom(
qb
.clearSelect()
.select('Envelope.id')
.limit(STATS_COUNT_CAP + 1)
.as('capped'),
)
.select(({ fn }) => fn.count<number>('id').as('total'))
.executeTakeFirstOrThrow();
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
};
export const getStats = async ({
user,
userId,
teamId,
period,
search = '',
folderId,
...options
senderIds,
}: GetStatsInput) => {
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
});
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const team = await getTeamById({ userId, teamId });
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
const teamEmail = team.teamEmail?.email ?? null;
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const searchQuery = search.trim();
const hasSearch = searchQuery.length > 0;
const searchPattern = `%${searchQuery}%`;
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search, folderId }));
// ─── Base query builder ──────────────────────────────────────────────
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
const buildBaseQuery = (): EnvelopeQueryBuilder => {
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.select('Envelope.id');
// Type = DOCUMENT
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
// Folder filter
qb =
folderId !== undefined
? qb.where('Envelope.folderId', '=', folderId)
: qb.where('Envelope.folderId', 'is', null);
// Period filter
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate());
}
// Sender filter
if (senderIds && senderIds.length > 0) {
qb = qb.where('Envelope.userId', 'in', senderIds);
}
// Search filter
if (hasSearch) {
qb = qb.where(({ or, eb }) =>
or([
eb('Envelope.title', 'ilike', searchPattern),
eb('Envelope.externalId', 'ilike', searchPattern),
eb(
'Envelope.id',
'in',
eb
.selectFrom('Recipient')
.select('Recipient.envelopeId')
.where(({ or: innerOr, eb: innerEb }) =>
innerOr([
innerEb('Recipient.email', 'ilike', searchPattern),
innerEb('Recipient.name', 'ilike', searchPattern),
]),
)
.distinct()
.limit(1000),
),
]),
);
}
return qb;
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
// ─── Shared filter helpers ───────────────────────────────────────────
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((v) => sql.lit(v)),
),
eb('Envelope.userId', '=', user.id),
recipientExists(eb, user.email),
]);
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
const branches = [
eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]),
];
if (teamEmail) {
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
branches.push(
recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
);
}
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
return eb.or(branches);
};
if (stat.status === ExtendedDocumentStatus.REJECTED) {
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
}
});
// ─── Per-status query builders ───────────────────────────────────────
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
// DRAFT: team-owned drafts visible to the user
const draftQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// PENDING: team-owned pending + team-email signed-pending docs
const pendingQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(
recipientExists(eb, teamEmail, (reb) =>
reb.and([
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
]),
),
);
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// COMPLETED: team-owned completed + team-email received completed
const completedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// REJECTED: team-owned rejected + team-email rejected docs
const rejectedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(
recipientExists(eb, teamEmail, (reb) =>
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
);
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
? buildBaseQuery()
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
.where((eb) =>
eb.and([
visibilityFilter(eb),
recipientExists(eb, teamEmail, (reb) =>
reb.and([
reb('Recipient.documentDeletedAt', 'is', null),
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
]),
),
]),
)
: null;
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
return stats;
};
type GetCountsOption = {
user: Pick<User, 'id' | 'email'>;
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: user.id,
createdAt,
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
createdAt,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
createdAt,
user: {
email: {
not: user.email,
},
},
OR: [
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
};
type GetTeamCountsOption = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
currentUserEmail: string;
userId: number;
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] =
senderIds.length > 0
? {
in: senderIds,
}
: undefined;
const searchFilter: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
};
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = {
AND: [
{ deletedAt: null },
{
OR: [
match(options.currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({
visibility: {
equals: DocumentVisibility.EVERYONE,
},
})),
{
OR: [
{ userId: options.userId },
{ recipients: { some: { email: options.currentUserEmail } } },
],
},
],
},
],
};
ownerCountsWhereInput = {
...ownerCountsWhereInput,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
ownerCountsWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
OR: [
{
teamId,
},
{
user: {
email: teamEmail,
},
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
hasSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}
return Promise.all([
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: ownerCountsWhereInput,
}),
notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [],
]);
};
@@ -7,6 +7,7 @@ import {
OrganisationType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
@@ -25,12 +26,17 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
id: EnvelopeIdOptions;
@@ -213,45 +219,49 @@ export const resendDocument = async ({
}),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: email,
name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}),
);
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
return envelope;
};
@@ -1,16 +1,12 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { Envelope, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import {
buildTeamWhereQuery,
formatDocumentsPath,
getHighestTeamRoleInGroup,
} from '@documenso/lib/utils/teams';
import { formatDocumentsPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@@ -23,105 +19,83 @@ export const searchDocumentsWithKeyword = async ({
userId,
limit = 20,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const filters: Prisma.EnvelopeWhereInput[] = [
// Documents owned by the user matching title, externalId, or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
},
});
// Documents where the user is a recipient (completed or pending).
{
status: { in: [DocumentStatus.COMPLETED, DocumentStatus.PENDING] },
recipients: { some: { email: user.email } },
title: { contains: query, mode: 'insensitive' },
deletedAt: null,
},
];
// Team documents the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
OR: [
{
title: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
recipients: {
some: {
email: {
contains: query,
mode: 'insensitive',
},
},
},
userId: userId,
deletedAt: null,
},
{
status: DocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
},
{
status: DocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
],
OR: filters,
},
include: {
recipients: true,
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
token: true,
},
},
team: {
select: {
url: true,
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
},
},
@@ -129,29 +103,24 @@ export const searchDocumentsWithKeyword = async ({
orderBy: {
createdAt: 'desc',
},
take: limit,
// Over-fetch to compensate for post-query visibility filtering on team documents.
take: limit * 3,
});
const isOwner = (envelope: Envelope, user: User) => envelope.userId === user.id;
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = envelopes
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || isOwner(envelope, user)) {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamMemberRole = getHighestTeamRoleInGroup(
envelope.team.teamGroups.filter((tg) => tg.teamId === envelope.teamId),
);
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([envelope.visibility, teamMemberRole])
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
@@ -159,34 +128,29 @@ export const searchDocumentsWithKeyword = async ({
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.slice(0, limit)
.map((envelope) => {
const { recipients, ...documentWithoutRecipient } = envelope;
let documentPath;
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (isOwner(envelope, user)) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else if (envelope.teamId && envelope.team.teamGroups.length > 0) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
let path: string;
if (
envelope.userId === user.id ||
(envelope.teamId && teamGroupsByTeamId.has(envelope.teamId))
) {
path = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else {
documentPath = getSigningLink(recipients, user);
const signingToken = envelope.recipients.find((r) => r.email === user.email)?.token;
path = `/sign/${signingToken}`;
}
return {
...documentWithoutRecipient,
team: {
id: envelope.teamId,
url: envelope.team.url,
},
path: documentPath,
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
return results;
};
@@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@@ -18,6 +18,7 @@ import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@@ -159,10 +160,12 @@ export const sendDocument = async ({
);
if (recipientsWithMissingFields.length > 0) {
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
const missingRecipientDescriptions = recipientsWithMissingFields
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -205,7 +208,7 @@ export const sendDocument = async ({
});
}
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient);
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
@@ -372,6 +375,7 @@ const injectFormValuesIntoDocument = async (
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
recipient: Pick<Recipient, 'email'>,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
@@ -384,6 +388,18 @@ export const extractFieldAutoInsertValues = (
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert email fields if the recipient has a valid email.
if (
field.type === FieldType.EMAIL &&
isRecipientEmailValidForSending(recipient) &&
recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL
) {
return {
fieldId,
customText: recipient.email,
};
}
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
@@ -1,5 +1,4 @@
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -70,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
// Only set sentAt if not already set (email may have been sent before they opened).
...(!recipient.sentAt ? { sentAt: new Date() } : {}),
},
});
@@ -93,6 +94,9 @@ export const viewedDocument = async ({
});
});
// Don't schedule reminders for manually distributed documents —
// there's no email pathway to send them through.
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
@@ -60,6 +60,15 @@ export const verifyEmbeddingPresignToken = async ({
where: {
id: tokenId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!apiToken) {
@@ -69,7 +78,7 @@ export const verifyEmbeddingPresignToken = async ({
}
// This should never happen but we need to narrow types
if (!apiToken.userId) {
if (!apiToken.userId || !apiToken.user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
@@ -119,5 +128,10 @@ export const verifyEmbeddingPresignToken = async ({
return {
...apiToken,
userId,
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
};
};
@@ -0,0 +1,181 @@
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
type UnsafeCreateEnvelopeItemsOptions = {
files: {
clientId?: string;
file: File;
orderOverride?: number;
}[];
envelope: Envelope & {
envelopeItems: EnvelopeItem[];
recipients: Recipient[];
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
/**
* Create envelope items.
*
* It is assumed all prior validation has been completed.
*/
export const UNSAFE_createEnvelopeItems = async ({
files,
envelope,
user,
apiRequestMetadata,
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId: documentData.id,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
);
return await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItemsToCreate.map((item) => ({
id: item.id,
envelopeId: envelope.id,
title: item.title,
documentDataId: item.documentDataId,
order: item.order,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems.map((item) => {
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
return {
...item,
clientId,
};
});
});
};
@@ -0,0 +1,67 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
type UnsafeDeleteEnvelopeItemOptions = {
envelopeId: string;
envelopeItemId: string;
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_deleteEnvelopeItem = async ({
envelopeId,
envelopeItemId,
user,
apiRequestMetadata,
}: UnsafeDeleteEnvelopeItemOptions) => {
const result = await prisma.$transaction(async (tx) => {
const deletedEnvelopeItem = await tx.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId,
},
select: {
id: true,
title: true,
documentData: {
select: {
id: true,
},
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
});
await prisma.documentData.delete({
where: {
id: result.documentData.id,
envelopeItem: {
is: null,
},
},
});
};
@@ -0,0 +1,233 @@
import type { Envelope, Field, Recipient } from '@prisma/client';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
type UnsafeReplaceEnvelopeItemPdfOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'>;
/**
* Recipients used to resolve placeholder field assignments.
* When provided and placeholders are found in the replacement PDF,
* fields will be auto-created for matching recipients.
*/
recipients: Recipient[];
/**
* The ID of the envelope item which we will be replacing the PDF for.
*/
envelopeItemId: string;
/**
* The ID of the old document data we will be deleting.
*/
oldDocumentDataId: string;
/**
* The data we will be replacing.
*/
data: {
title?: string;
order?: number;
file: File;
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
type UnsafeReplaceEnvelopeItemPdfResult = {
updatedItem: {
id: string;
title: string;
envelopeId: string;
order: number;
documentDataId: string;
};
/**
* The full list of fields for the envelope after the replacement.
*
* Only returned when fields were created or deleted during the replacement,
* otherwise `undefined`.
*/
fields: Field[] | undefined;
};
export const UNSAFE_replaceEnvelopeItemPdf = async ({
envelope,
recipients,
envelopeItemId,
oldDocumentDataId,
data,
user,
apiRequestMetadata,
}: UnsafeReplaceEnvelopeItemPdfOptions): Promise<UnsafeReplaceEnvelopeItemPdfResult> => {
let buffer = Buffer.from(await data.file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
name: data.file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
data: {
documentDataId: newDocumentData.id,
title: data.title,
order: data.order,
},
});
// Todo: Audit log if we're updating the title or order.
// Delete fields that reference pages beyond the new PDF's page count.
const outOfBoundsFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId,
page: {
gt: filePageCount,
},
},
select: {
id: true,
},
});
const deletedFieldIds = outOfBoundsFields.map((f) => f.id);
if (deletedFieldIds.length > 0) {
await tx.field.deleteMany({
where: {
id: {
in: deletedFieldIds,
},
},
});
didFieldsChange = true;
}
if (recipients.length > 0 && placeholders.length > 0) {
const orderedRecipients = [...recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
const fieldsToCreate = convertPlaceholdersToFieldInputs(
placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
updatedItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: updatedItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
didFieldsChange = true;
}
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED,
envelopeId: envelope.id,
data: {
envelopeItemId: updatedItem.id,
envelopeItemTitle: updatedItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return updatedItem;
});
// Delete the old DocumentData (now orphaned).
await prisma.documentData.delete({
where: {
id: oldDocumentDataId,
},
});
let fields: Field[] | undefined = undefined;
if (didFieldsChange) {
try {
fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
});
} catch (err) {
// Do nothing.
console.error(err);
}
}
return {
updatedItem: updatedEnvelopeItem,
fields,
};
};
@@ -0,0 +1,101 @@
import type { EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
type UnsafeUpdateEnvelopeItemsOptions = {
envelopeId: string;
envelopeType: EnvelopeType;
existingEnvelopeItems: Pick<EnvelopeItem, 'id' | 'title' | 'order'>[];
data: {
envelopeItemId: string;
order?: number;
title?: string;
}[];
user: {
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_updateEnvelopeItems = async ({
envelopeId,
envelopeType,
existingEnvelopeItems,
data,
user,
apiRequestMetadata,
}: UnsafeUpdateEnvelopeItemsOptions) => {
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
where: {
envelopeId,
id: envelopeItemId,
},
data: {
order,
title,
},
select: {
id: true,
order: true,
title: true,
envelopeId: true,
},
}),
),
);
// Write audit logs for DOCUMENT type envelopes when changes are detected.
if (envelopeType === 'DOCUMENT') {
const auditLogs = data.flatMap((item) => {
const existing = existingEnvelopeItems.find((e) => e.id === item.envelopeItemId);
if (!existing) {
return [];
}
const changes: { field: string; from: string; to: string }[] = [];
if (item.title !== undefined && item.title !== existing.title) {
changes.push({
field: 'title',
from: existing.title,
to: item.title,
});
}
if (changes.length === 0) {
return [];
}
return [
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED,
envelopeId,
data: {
envelopeItemId: item.envelopeItemId,
changes,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
];
});
if (auditLogs.length > 0) {
await prisma.documentAuditLog.createMany({
data: auditLogs,
});
}
}
return updatedEnvelopeItems;
};
@@ -97,6 +97,14 @@ export type CreateEnvelopeOptions = {
data: string;
type?: TEnvelopeAttachmentType;
}>;
/**
* Whether to bypass adding default recipients.
*
* Defaults to false.
*/
bypassDefaultRecipients?: boolean;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
@@ -110,6 +118,7 @@ export const createEnvelope = async ({
meta,
requestMetadata,
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
const {
type,
@@ -198,7 +207,7 @@ export const createEnvelope = async ({
const titleToUse = item.title || title;
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
@@ -256,18 +265,6 @@ export const createEnvelope = async ({
// for uploads from the frontend
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
});
const secondaryId =
type === EnvelopeType.DOCUMENT
? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId);
const getValidatedDelegatedOwner = async () => {
if (
!settings.delegateDocumentOwnership ||
@@ -302,7 +299,18 @@ export const createEnvelope = async ({
return delegatedOwner;
};
const delegatedOwner = await getValidatedDelegatedOwner();
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
}),
type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId)
: incrementTemplateId().then((v) => v.formattedTemplateId),
getValidatedDelegatedOwner(),
]);
const envelopeOwnerId = delegatedOwner?.id ?? userId;
const createdEnvelope = await prisma.$transaction(async (tx) => {
@@ -355,9 +363,10 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const defaultRecipients =
settings.defaultRecipients && !bypassDefaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({
@@ -572,7 +581,7 @@ export const createEnvelope = async ({
});
}
// Only create audit logs and webhook events for documents.
// Only create audit logs for documents.
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -609,17 +618,28 @@ export const createEnvelope = async ({
}),
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
if (type === EnvelopeType.DOCUMENT) {
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
};
@@ -1,4 +1,5 @@
import { DocumentSource, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import pMap from 'p-map';
import { omit } from 'remeda';
import { prisma } from '@documenso/prisma';
@@ -18,9 +19,25 @@ export interface DuplicateEnvelopeOptions {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
overrides?: {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
includeFields?: boolean;
};
}
export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => {
export const duplicateEnvelope = async ({
id,
userId,
teamId,
overrides,
}: DuplicateEnvelopeOptions) => {
const {
duplicateAsTemplate = false,
includeRecipients = true,
includeFields = true,
} = overrides ?? {};
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: null,
@@ -35,6 +52,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
title: true,
userId: true,
internalVersion: true,
templateType: true,
publicTitle: true,
publicDescription: true,
envelopeItems: {
include: {
documentData: {
@@ -68,29 +88,42 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
});
}
const { legacyNumberId, secondaryId } =
envelope.type === EnvelopeType.DOCUMENT
? await incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
if (duplicateAsTemplate && envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only documents can be saved as templates',
});
}
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
targetType === EnvelopeType.DOCUMENT
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
legacyNumberId: documentId,
secondaryId: formattedDocumentId,
}))
: await incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
: incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
legacyNumberId: templateId,
secondaryId: formattedTemplateId,
}));
})),
prisma.documentMeta.create({
data: {
...omit(envelope.documentMeta, ['id']),
emailSettings: envelope.documentMeta.emailSettings || undefined,
},
}),
]);
const createdDocumentMeta = await prisma.documentMeta.create({
data: {
...omit(envelope.documentMeta, ['id']),
emailSettings: envelope.documentMeta.emailSettings || undefined,
},
});
const duplicatedTemplateType =
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
? 'PRIVATE'
: (envelope.templateType ?? undefined);
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
type: envelope.type,
type: targetType,
internalVersion: envelope.internalVersion,
userId,
teamId,
@@ -98,8 +131,11 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
templateType: duplicatedTemplateType,
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
targetType === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},
include: {
recipients: true,
@@ -136,34 +172,41 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
);
for (const recipient of envelope.recipients) {
await prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
if (includeRecipients) {
await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: includeFields
? {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
}
: undefined,
},
},
},
});
}),
{ concurrency: 5 },
);
}
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
@@ -189,7 +232,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
id: duplicatedEnvelope.id,
envelope: duplicatedEnvelope,
legacyId: {
type: envelope.type,
type: duplicatedEnvelope.type,
id: legacyNumberId,
},
};
@@ -1,12 +1,8 @@
import type {
DocumentSource,
DocumentStatus,
Envelope,
EnvelopeType,
Prisma,
} from '@prisma/client';
import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { prisma } from '@documenso/prisma';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
@@ -28,8 +24,77 @@ export type FindEnvelopesOptions = {
};
query?: string;
folderId?: string;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
*/
useWindowedCount?: boolean;
};
/**
* The number of pages ahead of the current page we'll scan for pagination.
*
* Instead of COUNT(*) over the entire result set (which must scan all qualifying rows),
* we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres
* stop early once it has enough rows.
*/
const COUNT_WINDOW_SIZE = 100;
/**
* Cap for the recipient search subquery. When searching by recipient email/name,
* we pre-compute matching envelope IDs up to this limit to prevent pathological
* heap scans on broad searches.
*/
const RECIPIENT_SEARCH_CAP = 1000;
// Kysely query builder type for Envelope queries.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
// Expression builder type scoped to Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
/**
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
* envelope with the given email, plus optional extra conditions.
*/
const recipientExists = (
eb: EnvelopeExpressionBuilder,
email: string,
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
) => {
let sub = eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', email);
if (extra) {
sub = sub.where(extra);
}
return eb.exists(sub.select(sql.lit(1).as('one')));
};
/**
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
*/
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
eb.exists(
eb
.selectFrom('User')
.whereRef('User.id', '=', 'Envelope.userId')
.where('User.email', '=', email)
.select(sql.lit(1).as('one')),
);
/**
* Find envelopes visible to the requesting user within a team.
*
* Unlike `findDocuments` (used by the UI), being a recipient does NOT override
* document visibility. A user will only see an envelope if its visibility level
* is within their role's threshold, or they are the document owner.
*/
export const findEnvelopes = async ({
userId,
teamId,
@@ -42,134 +107,175 @@ export const findEnvelopes = async ({
orderBy,
query = '',
folderId,
useWindowedCount = true,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
where: { id: userId },
select: { id: true, email: true, name: true },
});
const team = await getTeamById({
userId,
teamId,
});
const team = await getTeamById({ userId, teamId });
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const searchQuery = query.trim();
const hasSearch = searchQuery.length > 0;
const searchPattern = `%${searchQuery}%`;
const searchFilter: Prisma.EnvelopeWhereInput = query
? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
],
}
: {};
const teamEmail = team.teamEmail?.email ?? null;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole];
const visibilityFilter: Prisma.EnvelopeWhereInput = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
// ─── Build Kysely query ──────────────────────────────────────────────
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.createdAt']);
if (team.teamEmail) {
teamEmailFilters.push(
{
user: {
email: team.teamEmail.email,
},
},
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
// Folder filter
qb =
folderId !== undefined
? qb.where('Envelope.folderId', '=', folderId)
: qb.where('Envelope.folderId', 'is', null);
// Exclude soft-deleted envelopes
qb = qb.where('Envelope.deletedAt', 'is', null);
// Type filter (enum cast)
if (type) {
qb = qb.where('Envelope.type', '=', sql.lit(type));
}
// Template filter
if (templateId) {
qb = qb.where('Envelope.templateId', '=', templateId);
}
// Source filter (enum cast)
if (source) {
qb = qb.where('Envelope.source', '=', sql.lit(source));
}
// Status filter (enum cast)
if (status) {
qb = qb.where('Envelope.status', '=', sql.lit(status));
}
// Search filter: title, externalId, or recipient match via capped subquery
if (hasSearch) {
qb = qb.where(({ or, eb }) =>
or([
eb('Envelope.title', 'ilike', searchPattern),
eb('Envelope.externalId', 'ilike', searchPattern),
eb(
'Envelope.id',
'in',
eb
.selectFrom('Recipient')
.select('Recipient.envelopeId')
.where(({ or: innerOr, eb: innerEb }) =>
innerOr([
innerEb('Recipient.email', 'ilike', searchPattern),
innerEb('Recipient.name', 'ilike', searchPattern),
]),
)
.distinct()
.limit(RECIPIENT_SEARCH_CAP),
),
]),
);
}
const whereClause: Prisma.EnvelopeWhereInput = {
AND: [
{
OR: [
{
teamId: team.id,
...visibilityFilter,
},
{
userId,
},
...teamEmailFilters,
],
},
{
folderId: folderId ?? null,
deletedAt: null,
},
searchFilter,
],
};
// ─── Access control ──────────────────────────────────────────────────
//
// An envelope is visible if ANY of:
// 1. It belongs to this team AND (meets the visibility threshold OR the requesting user is the owner)
// 2. (If team email) The sender's email matches the team email
// 3. (If team email) A recipient's email matches the team email
if (type) {
whereClause.type = type;
}
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((v) => sql.lit(v)),
),
// Owner always sees their own docs within this team
eb('Envelope.userId', '=', user.id),
]);
if (templateId) {
whereClause.templateId = templateId;
}
qb = qb.where((eb) => {
const accessBranches: Expression<SqlBool>[] = [
// Team docs that pass visibility (or are owned by the user)
eb.and([eb('Envelope.teamId', '=', team.id), visibilityFilter(eb)]),
];
if (source) {
whereClause.source = source;
}
if (teamEmail) {
// Docs sent by the team email user
accessBranches.push(senderEmailIs(eb, teamEmail));
// Docs received by the team email
accessBranches.push(recipientExists(eb, teamEmail));
}
if (status) {
whereClause.status = status;
}
return eb.or(accessBranches);
});
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.envelope.count({
where: whereClause,
}),
// ─── Execute: paginated data + count ──────────────────────────────────
const offset = Math.max(page - 1, 0) * perPage;
const dataQuery = qb
.orderBy(`Envelope.${orderByColumn}`, orderByDirection)
.limit(perPage)
.offset(offset);
// Count query: either windowed (fast, capped) or full (exact, for API consumers).
const baseCountQuery = qb.clearSelect().select('Envelope.id');
const countQuery = useWindowedCount
? kyselyPrisma.$kysely
.selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed'))
.select(({ fn }) => fn.count<number>('id').as('total'))
: kyselyPrisma.$kysely
.selectFrom(baseCountQuery.as('filtered'))
.select(({ fn }) => fn.count<number>('id').as('total'));
const [dataResult, countResult] = await Promise.all([
dataQuery.execute(),
countQuery.executeTakeFirstOrThrow(),
]);
const ids = dataResult.map((row) => row.id);
const totalCount = useWindowedCount
? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage)
: Number(countResult.total ?? 0);
// ─── Hydrate with Prisma ─────────────────────────────────────────────
if (ids.length === 0) {
return {
data: [],
count: totalCount,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(totalCount / perPage),
} satisfies FindResultResponse<never[]>;
}
const data = await prisma.envelope.findMany({
where: { id: { in: ids } },
orderBy: { [orderByColumn]: orderByDirection },
include: {
user: { select: { id: true, name: true, email: true } },
recipients: { orderBy: { id: 'asc' } },
team: { select: { id: true, url: true } },
},
});
// Preserve ordering from the Kysely query
const idOrder = new Map(ids.map((id, index) => [id, index]));
data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
const maskedData = data.map((envelope) =>
maskRecipientTokensForDocument({
document: envelope,
@@ -189,9 +295,9 @@ export const findEnvelopes = async ({
return {
data: mappedData,
count,
count: totalCount,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
totalPages: Math.ceil(totalCount / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};
@@ -0,0 +1,110 @@
import type { EnvelopeType } from '@prisma/client';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
export type GetEditorEnvelopeByIdOptions = {
id: EnvelopeIdOptions;
/**
* The validated team ID.
*/
userId: number;
/**
* The unvalidated team ID.
*/
teamId: number;
/**
* The type of envelope to get.
*
* Set to null to bypass check.
*/
type: EnvelopeType | null;
};
export const getEditorEnvelopeById = async ({
id,
userId,
teamId,
type,
}: GetEditorEnvelopeByIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
organisationId: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
envelopeAttachments: {
select: {
id: true,
type: true,
label: true,
data: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
...envelope,
attachments: envelope.envelopeAttachments,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
@@ -1,5 +1,4 @@
import type { Prisma } from '@prisma/client';
import type { EnvelopeType } from '@prisma/client';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -146,7 +146,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
...recipient,
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
const autoInsertValue = extractFieldAutoInsertValues(field, recipient);
if (!autoInsertValue) {
return field;
@@ -80,6 +80,7 @@ export const ZEnvelopeForSigningResponse = z.object({
id: true,
title: true,
order: true,
documentDataId: true,
}).array(),
team: TeamSchema.pick({
@@ -1,6 +1,5 @@
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -12,9 +11,15 @@ import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@@ -203,9 +208,13 @@ export const updateEnvelope = async ({
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT) {
if (
!isTitleSame &&
envelope.status !== DocumentStatus.DRAFT &&
envelope.status !== DocumentStatus.PENDING
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the envelope has been sent',
message: 'Envelope title can only be updated while in draft or pending status',
});
}
@@ -309,8 +318,8 @@ export const updateEnvelope = async ({
// return envelope;
// }
return await prisma.$transaction(async (tx) => {
const updatedEnvelope = await tx.envelope.update({
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const result = await tx.envelope.update({
where: {
id: envelope.id,
},
@@ -331,6 +340,10 @@ export const updateEnvelope = async ({
},
},
},
include: {
documentMeta: true,
recipients: true,
},
});
if (envelope.type === EnvelopeType.DOCUMENT) {
@@ -339,6 +352,29 @@ export const updateEnvelope = async ({
});
}
return updatedEnvelope;
return result;
});
// Recompute reminders for active recipients when reminder settings change.
if (meta && 'reminderSettings' in meta) {
await recomputeNextReminderForEnvelope(envelope.id);
}
if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
userId,
teamId,
});
}
// deconstruct to remove the recipients and documentMeta from the returned object since they aren't needed and can be large.
const {
recipients: _recipients,
documentMeta: _documentMeta,
...finalEnvelope
} = updatedEnvelope;
return finalEnvelope;
};
@@ -308,7 +308,7 @@ export const createEnvelopeFields = async ({
continue;
}
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
@@ -8,6 +8,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
FIELD_META_DEFAULT_VALUES,
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@@ -143,7 +144,7 @@ export const setFieldsForDocument = async ({
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: undefined;
: FIELD_META_DEFAULT_VALUES[field.type];
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
@@ -6,6 +6,7 @@ import { validateNumberField } from '@documenso/lib/advanced-fields-validation/v
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import {
FIELD_META_DEFAULT_VALUES,
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@@ -116,7 +117,9 @@ export const setFieldsForTemplate = async ({
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map(async (field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: FIELD_META_DEFAULT_VALUES[field.type];
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
@@ -111,32 +111,27 @@ export const addUserToOrganisation = async ({
});
}
await prisma.$transaction(
async (tx) => {
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
},
await prisma.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
},
},
{ timeout: 30_000 },
);
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
};
@@ -5,7 +5,10 @@ import type { Organisation, Prisma } from '@prisma/client';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -127,8 +130,10 @@ export const createOrganisationMemberInvites = async ({
const totalMemberCountWithInvites =
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Handle billing for seat based plans.
// Enforce the seat cap and sync billing for seat based plans.
if (subscription) {
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
+22 -5
View File
@@ -13,14 +13,31 @@ const MIN_CERT_PAGE_HEIGHT = 300;
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
let mediaBox;
let cropBox;
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
try {
mediaBox = page.getMediaBox();
} catch {
// MediaBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
return cropBox;
try {
cropBox = page.getCropBox();
} catch {
// CropBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
if (mediaBox && cropBox) {
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
}
// If either box is missing or invalid, fall back to MediaBox if available, otherwise CropBox, or default to A4 size.
return mediaBox || cropBox || PDF_SIZE_A4_72PPI;
};
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
+36
View File
@@ -1,9 +1,45 @@
import { FieldType } from '@prisma/client';
import type { Recipient } from '@prisma/client';
import path from 'node:path';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
/**
* Ensure all required fonts are registered in the skia-canvas FontLibrary.
*
* Fonts are registered once per process and retained — calling this multiple
* times is a no-op after the first invocation.
*/
export const ensureFontLibrary = () => {
const fontPath = path.join(process.cwd(), 'public/fonts');
if (!FontLibrary.has('Caveat')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
});
}
if (!FontLibrary.has('Inter')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
}
if (!FontLibrary.has('Noto Sans')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
}
};
type RecipientPlaceholderInfo = {
email: string;
name: string;
@@ -2,13 +2,12 @@
import '../konva/skia-backend';
import Konva from 'konva';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
type InsertFieldInPDFV2Options = {
pageWidth: number;
@@ -21,16 +20,7 @@ export const insertFieldInPDFV2 = async ({
pageHeight,
fields,
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
ensureFontLibrary();
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
let layer: Konva.Layer | null = new Konva.Layer();
@@ -9,7 +9,6 @@ import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match } from 'ts-pattern';
import { P } from 'ts-pattern';
@@ -21,6 +20,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs';
import { ensureFontLibrary } from './helpers';
export type AuditLogRecipient = {
id: number;
@@ -575,13 +575,7 @@ export async function renderAuditLogs({
i18n,
hidePoweredBy,
}: GenerateAuditLogsOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
ensureFontLibrary();
const minimumMargin = 10;
@@ -9,7 +9,6 @@ import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -22,6 +21,7 @@ import {
} from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
import { ensureFontLibrary } from './helpers';
type ColumnWidths = [number, number, number];
@@ -724,13 +724,7 @@ export async function renderCertificate({
pageWidth,
pageHeight,
}: GenerateCertificateOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
ensureFontLibrary();
const minimumMargin = 10;
@@ -4,8 +4,8 @@ import { createRateLimit } from './rate-limit';
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
max: 3,
window: '3h',
});
export const forgotPasswordRateLimit = createRateLimit({
@@ -21,14 +21,7 @@ export type SetTemplateRecipientsOptions = {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
recipients: RecipientData[];
};
export const setTemplateRecipients = async ({
@@ -183,7 +176,10 @@ export const setTemplateRecipients = async ({
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@@ -199,7 +195,7 @@ export const setTemplateRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
@@ -218,3 +214,17 @@ export const setTemplateRecipients = async ({
})),
};
};
type RecipientData = {
id?: number;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
@@ -0,0 +1,112 @@
import {
DocumentDistributionMethod,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import {
ZEnvelopeReminderSettings,
resolveNextReminderAt,
} from '../../constants/envelope-reminder';
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
let settings = options.reminderSettings;
if (settings === undefined) {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: { documentMeta: { select: { reminderSettings: true } } },
});
settings = envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt,
lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
});
};
/**
* Recompute `nextReminderAt` for all active (unsigned, sent) recipients
* of a given envelope. Call when document-level reminder settings change.
*/
export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
documentMeta: {
select: { reminderSettings: true, distributionMethod: true },
},
},
});
// No reminders for manually distributed documents.
const isEmailDistribution =
envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
const settings =
isEmailDistribution && envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
const recipients = await prisma.recipient.findMany({
where: {
envelopeId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
sentAt: { not: null },
role: { not: RecipientRole.CC },
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
});
await Promise.all(
recipients.map(async (recipient) => {
if (!recipient.sentAt) {
return;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipient.id },
data: { nextReminderAt },
});
}),
);
};
@@ -52,36 +52,35 @@ export const createTeamEmailVerification = async ({
});
}
await prisma.$transaction(
async (tx) => {
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await prisma.$transaction(async (tx) => {
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
});
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team);
},
{ timeout: 30_000 },
);
// Send email outside the transaction to avoid holding a connection
// open during network I/O.
await sendTeamEmailVerificationEmail(data.email, token, team);
} catch (err) {
console.error(err);
+25 -28
View File
@@ -78,38 +78,35 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
(member) => member.id,
);
await prisma.$transaction(
async (tx) => {
await tx.team.delete({
where: {
id: teamId,
},
});
await prisma.$transaction(async (tx) => {
await tx.team.delete({
where: {
id: teamId,
},
});
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
});
},
});
});
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
},
members: membersToNotify,
organisationId: team.organisationId,
},
});
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
},
members: membersToNotify,
organisationId: team.organisationId,
},
{ timeout: 30_000 },
);
});
};
type SendTeamDeleteEmailOptions = {
@@ -0,0 +1,46 @@
import type { TeamGroup } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type GetUserTeamIdsOptions = {
userId: number;
};
/**
* Pre-resolve all team groups a user has access to via their organisation group memberships,
* keyed by team ID.
*
* This is significantly cheaper than joining team groups inline in a Prisma `findMany`
* because it avoids deep EXISTS subqueries and redundant LEFT JOINs per row.
*/
export const getUserTeamGroups = async ({
userId,
}: GetUserTeamIdsOptions): Promise<Map<number, TeamGroup[]>> => {
const teamGroups = await prisma.teamGroup.findMany({
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
});
const map = new Map<number, TeamGroup[]>();
for (const tg of teamGroups) {
const existing = map.get(tg.teamId);
if (existing) {
existing.push(tg);
} else {
map.set(tg.teamId, [tg]);
}
}
return map;
};
@@ -35,30 +35,27 @@ export const resendTeamEmailVerification = async ({
});
}
await prisma.$transaction(
async (tx) => {
const { emailVerification } = team;
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError('VerificationNotFound', {
message: 'No team email verification exists for this team.',
});
}
if (!emailVerification) {
throw new AppError('VerificationNotFound', {
message: 'No team email verification exists for this team.',
});
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
await prisma.teamEmailVerification.update({
where: {
teamId,
},
{ timeout: 30_000 },
);
data: {
token,
expiresAt,
},
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
};
@@ -10,9 +10,11 @@ import { getSiteSetting } from '../site-settings/get-site-setting';
import { SITE_SETTINGS_TELEMETRY_ID } from '../site-settings/schemas/telemetry';
import { upsertSiteSetting } from '../site-settings/upsert-site-setting';
const HAS_LICENSE_KEY = !!process.env.NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY;
const TELEMETRY_KEY = process.env.NEXT_PRIVATE_TELEMETRY_KEY;
const TELEMETRY_HOST = process.env.NEXT_PRIVATE_TELEMETRY_HOST;
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY;
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY || HAS_LICENSE_KEY;
const NODE_ID_FILENAME = '.documenso-node-id';
const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
@@ -43,9 +45,12 @@ export class TelemetryClient {
*/
public static async start(): Promise<void> {
if (TELEMETRY_DISABLED) {
console.log(
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
);
if (!HAS_LICENSE_KEY) {
console.log(
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
);
}
return;
}
@@ -1,6 +1,3 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
@@ -18,18 +15,16 @@ import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
@@ -48,12 +43,10 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { getEmailContext } from '../email/get-email-context';
import { incrementDocumentId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
@@ -156,15 +149,12 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: directTemplateEnvelope.teamId,
},
const settings = await getTeamSettings({
userId: directTemplateEnvelope.userId,
teamId: directTemplateEnvelope.teamId,
});
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
const { recipients, directLink } = directTemplateEnvelope;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
@@ -318,20 +308,12 @@ export const createDocumentFromDirectTemplate = async ({
const titleToUse = item.title || directTemplateEnvelope.title;
const duplicatedFile = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
@@ -723,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -755,37 +738,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
createdEnvelope.id
}`,
documentName: createdEnvelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
createdEnvelope,
token: createdDirectRecipient.token,
@@ -793,6 +745,18 @@ export const createDocumentFromDirectTemplate = async ({
};
});
const emailSettings = extractDerivedDocumentEmailSettings(documentMeta);
if (emailSettings.ownerDocumentCreated) {
await jobs.triggerJob({
name: 'send.document.created.from.direct.template.email',
payload: {
envelopeId: createdEnvelope.id,
recipientId,
},
});
}
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
@@ -61,6 +61,7 @@ import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
type FinalRecipient = Pick<
Recipient,
@@ -312,29 +313,43 @@ export const createDocumentFromTemplate = async ({
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const templateInclude = {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
} as const;
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
const [teamTemplate, organisationTemplate] = await Promise.all([
prisma.envelope.findFirst({
where: envelopeWhereInput,
include: templateInclude,
}),
prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id,
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: templateInclude,
}),
]);
const template = teamTemplate ?? organisationTemplate;
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -528,7 +543,7 @@ export const createDocumentFromTemplate = async ({
}),
});
return await prisma.$transaction(async (tx) => {
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -541,7 +556,7 @@ export const createDocumentFromTemplate = async ({
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
teamId,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {
@@ -761,13 +776,25 @@ export const createDocumentFromTemplate = async ({
throw new Error('Document not found');
}
await triggerWebhook({
return { envelope, createdEnvelope };
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
await Promise.allSettled([
triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}),
triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_USED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
}),
]);
return envelope;
});
return envelope;
};
@@ -1,9 +1,14 @@
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteTemplateOptions = {
id: EnvelopeIdOptions;
@@ -19,7 +24,21 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
teamId,
});
return await prisma.envelope.delete({
const templateToDelete = await prisma.envelope.findUniqueOrThrow({
where: envelopeWhereInput,
include: { documentMeta: true, recipients: true },
});
const deletedTemplate = await prisma.envelope.delete({
where: envelopeWhereInput,
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_DELETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(templateToDelete)),
userId,
teamId,
});
return deletedTemplate;
};
@@ -0,0 +1,84 @@
import { EnvelopeType, type Prisma, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { type FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
};
export const findOrganisationTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: team.organisationId,
},
};
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({ where }),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -24,8 +24,6 @@ export const findTemplates = async ({
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@@ -34,63 +32,55 @@ export const findTemplates = async ({
},
});
whereFilter.push(
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
},
{ userId, teamId },
],
},
);
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
};
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
include: {
team: {
select: {
id: true,
url: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
}),
prisma.envelope.count({ where }),
]);
return {
@@ -0,0 +1,136 @@
import type { Prisma, TeamMemberRole } from '@prisma/client';
import { EnvelopeType, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type GetOrganisationTemplateByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
/**
* Get an organisation template by ID.
*
* This validates that the caller's team belongs to the same organisation as the template's team,
* that the template is of type ORGANISATION, and that the template's visibility is permitted
* for the caller's role on their own team.
*/
export const getOrganisationTemplateById = async ({
id,
userId,
teamId,
}: GetOrganisationTemplateByIdOptions) => {
const [callerTeam, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: callerTeam.organisationId,
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation template not found',
});
}
return {
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
/**
* Build a where input for querying an organisation template.
*
* Matches a TEMPLATE envelope with templateType ORGANISATION belonging to any team
* within the provided organisation, respecting the caller's team role visibility.
*/
export const getOrganisationTemplateWhereInput = ({
id,
organisationId,
teamRole,
}: {
id: EnvelopeIdOptions;
organisationId: string;
teamRole: TeamMemberRole;
}): Prisma.EnvelopeWhereInput => {
return {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId,
},
};
};
@@ -90,6 +90,7 @@ export const getTemplateByDirectLinkToken = async ({
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
documentDataId: item.documentDataId,
})),
};
};
@@ -0,0 +1,134 @@
import type { Prisma } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { formatTemplatesPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchTemplatesWithKeywordOptions = {
query: string;
userId: number;
limit?: number;
};
export const searchTemplatesWithKeyword = async ({
query,
userId,
limit = 20,
}: SearchTemplatesWithKeywordOptions) => {
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const titleOrRecipientMatch: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
};
const filters: Prisma.EnvelopeWhereInput[] = [
// Templates owned by the user matching title or recipient email.
{
userId,
deletedAt: null,
...titleOrRecipientMatch,
},
];
// Team templates the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
...titleOrRecipientMatch,
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
OR: filters,
},
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
distinct: ['id'],
orderBy: {
createdAt: 'desc',
},
// Over-fetch to compensate for post-query visibility filtering on team templates.
take: limit * 3,
});
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
})
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return results;
};
@@ -77,13 +77,13 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
ipAddress: requestMetadata?.ipAddress,
},
});
});
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
},
});
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
},
});
return {
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { assertNotPrivateUrl } from './assert-webhook-url';
const fakeLookup = (addresses: Array<{ address: string; family: number }>) => {
return vi.fn().mockResolvedValue(addresses);
};
const fakeLookupSingle = (address: string, family: number) => {
return vi.fn().mockResolvedValue({ address, family });
};
describe('assertNotPrivateUrl', () => {
describe('static URL checks', () => {
it('should throw for localhost URLs', async () => {
await expect(assertNotPrivateUrl('http://localhost:3000')).rejects.toThrow(AppError);
});
it('should throw for 127.0.0.1', async () => {
await expect(assertNotPrivateUrl('http://127.0.0.1')).rejects.toThrow(AppError);
});
it('should throw for private IPs before DNS lookup', async () => {
await expect(assertNotPrivateUrl('http://10.0.0.1')).rejects.toThrow(AppError);
await expect(assertNotPrivateUrl('http://192.168.1.1')).rejects.toThrow(AppError);
});
it('should throw with WEBHOOK_INVALID_REQUEST error code', async () => {
try {
await assertNotPrivateUrl('http://localhost');
expect.unreachable('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(AppError);
expect((err as AppError).code).toBe(AppErrorCode.WEBHOOK_INVALID_REQUEST);
}
});
});
describe('DNS resolution checks', () => {
it('should throw when hostname resolves to a private IPv4 address', async () => {
const lookup = fakeLookup([{ address: '127.0.0.1', family: 4 }]);
await expect(
assertNotPrivateUrl('https://evil.example.com', { lookup }),
).rejects.toThrow(AppError);
});
it('should throw when hostname resolves to a private IPv6 address', async () => {
const lookup = fakeLookup([{ address: '::1', family: 6 }]);
await expect(
assertNotPrivateUrl('https://evil.example.com', { lookup }),
).rejects.toThrow(AppError);
});
it('should throw when any resolved address is private', async () => {
const lookup = fakeLookup([
{ address: '8.8.8.8', family: 4 },
{ address: '127.0.0.1', family: 4 },
]);
await expect(
assertNotPrivateUrl('https://evil.example.com', { lookup }),
).rejects.toThrow(AppError);
});
it('should allow hostnames that resolve to public addresses', async () => {
const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }]);
await expect(
assertNotPrivateUrl('https://example.com', { lookup }),
).resolves.toBeUndefined();
});
it('should handle a single address result (non-array)', async () => {
const lookup = fakeLookupSingle('10.0.0.1', 4);
await expect(
assertNotPrivateUrl('https://evil.example.com', { lookup }),
).rejects.toThrow(AppError);
});
it('should handle a single public address result', async () => {
const lookup = fakeLookupSingle('93.184.216.34', 4);
await expect(
assertNotPrivateUrl('https://example.com', { lookup }),
).resolves.toBeUndefined();
});
});
describe('IP address URLs skip DNS', () => {
it('should not perform DNS lookup for IP address URLs', async () => {
const lookup = vi.fn();
await assertNotPrivateUrl('http://8.8.8.8', { lookup });
expect(lookup).not.toHaveBeenCalled();
});
});
describe('DNS failure handling', () => {
it('should silently allow when DNS lookup throws', async () => {
const lookup = vi.fn().mockRejectedValue(new Error('ENOTFOUND'));
await expect(
assertNotPrivateUrl('https://nonexistent.example.com', { lookup }),
).resolves.toBeUndefined();
});
it('should re-throw AppError even within the catch block', async () => {
const lookup = fakeLookup([{ address: '192.168.0.1', family: 4 }]);
await expect(
assertNotPrivateUrl('https://evil.example.com', { lookup }),
).rejects.toThrow(AppError);
});
it('should silently allow when DNS lookup times out (returns null)', async () => {
const lookup = vi.fn().mockReturnValue(new Promise(() => {}));
// withTimeout races the lookup against a 250ms timer and returns null
// if the lookup doesn't settle in time, so the function returns early.
await expect(
assertNotPrivateUrl('https://slow.example.com', { lookup }),
).resolves.toBeUndefined();
}, 10_000);
});
});
@@ -0,0 +1,131 @@
import { lookup } from 'node:dns/promises';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { withTimeout } from '../../utils/timeout';
import { isPrivateUrl } from './is-private-url';
const ZIpSchema = z.string().ip();
const WEBHOOK_DNS_LOOKUP_TIMEOUT_MS = 250;
type TLookupAddress = {
address: string;
family: number;
};
type TLookupFn = (
hostname: string,
options: {
all: true;
verbatim: true;
},
) => Promise<TLookupAddress[] | TLookupAddress>;
const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/\.+$/, '');
const toAddressUrl = (address: string) =>
address.includes(':') ? `http://[${address}]` : `http://${address}`;
/**
* Parse the NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS environment variable into
* a Set of lowercased hostnames/IPs that are allowed to resolve to private
* addresses. The Set is built once at module load and never changes.
*
* Empty or unset = no bypasses (safe default).
*/
const webhookSSRFBypassHosts = (): Set<string> => {
const raw = process.env['NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS'] ?? '';
const hosts = new Set<string>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim().toLowerCase();
if (trimmed.length > 0) {
hosts.add(trimmed);
}
}
return hosts;
};
const WEBHOOK_SSRF_BYPASS_HOSTS = webhookSSRFBypassHosts();
/**
* Check whether the hostname of the given URL is present in the SSRF bypass
* list. Matches against URL.hostname which covers both DNS names and raw IP
* addresses uniformly.
*/
const isBypassedHost = (url: string): boolean => {
if (WEBHOOK_SSRF_BYPASS_HOSTS.size === 0) {
return false;
}
try {
const hostname = normalizeHostname(new URL(url).hostname);
return WEBHOOK_SSRF_BYPASS_HOSTS.has(hostname);
} catch {
return false;
}
};
/**
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
*
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*/
export const assertNotPrivateUrl = async (
url: string,
options?: {
lookup?: TLookupFn;
},
) => {
if (isBypassedHost(url)) {
return;
}
if (isPrivateUrl(url)) {
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
message: 'Webhook URL resolves to a private or loopback address',
});
}
try {
const hostname = normalizeHostname(new URL(url).hostname);
if (hostname.length === 0 || ZIpSchema.safeParse(hostname).success) {
return;
}
const resolveHostname = options?.lookup ?? lookup;
const lookupResult = await withTimeout(
resolveHostname(hostname, {
all: true,
verbatim: true,
}),
WEBHOOK_DNS_LOOKUP_TIMEOUT_MS,
);
if (!lookupResult) {
return;
}
const addresses = Array.isArray(lookupResult) ? lookupResult : [lookupResult];
if (addresses.some(({ address }) => isPrivateUrl(toAddressUrl(address)))) {
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
message: 'Webhook URL resolves to a private or loopback address',
});
}
} catch (err) {
if (err instanceof AppError) {
throw err;
}
return;
}
};
@@ -0,0 +1,60 @@
import type { Prisma } from '@prisma/client';
import { fetchWithTimeout } from '../../utils/timeout';
import { assertNotPrivateUrl } from './assert-webhook-url';
const WEBHOOK_TIMEOUT_MS = 10_000;
export type WebhookCallResult = {
success: boolean;
responseCode: number;
responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput;
responseHeaders: Record<string, string>;
};
const parseBody = (text: string): Prisma.InputJsonValue => {
try {
return JSON.parse(text);
} catch {
return text;
}
};
export const executeWebhookCall = async (options: {
url: string;
body: unknown;
secret: string | null;
}): Promise<WebhookCallResult> => {
const { url, body, secret } = options;
try {
await assertNotPrivateUrl(url);
const response = await fetchWithTimeout(url, {
method: 'POST',
body: JSON.stringify(body),
redirect: 'manual',
timeoutMs: WEBHOOK_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const text = await response.text();
return {
success: response.ok,
responseCode: response.status,
responseBody: parseBody(text),
responseHeaders: Object.fromEntries(response.headers.entries()),
};
} catch (err) {
return {
success: false,
responseCode: 0,
responseBody: err instanceof Error ? err.message : 'Unknown error',
responseHeaders: {},
};
}
};
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { isPrivateUrl } from './is-private-url';
describe('isPrivateUrl', () => {
describe('localhost', () => {
it('should detect localhost', () => {
expect(isPrivateUrl('http://localhost')).toBe(true);
expect(isPrivateUrl('http://localhost:3000')).toBe(true);
expect(isPrivateUrl('https://localhost/path')).toBe(true);
});
it('should detect localhost with trailing dot', () => {
expect(isPrivateUrl('http://localhost.')).toBe(true);
});
it('should be case insensitive', () => {
expect(isPrivateUrl('http://LOCALHOST')).toBe(true);
expect(isPrivateUrl('http://Localhost:8080')).toBe(true);
});
});
describe('IPv4 loopback', () => {
it('should detect 127.0.0.1', () => {
expect(isPrivateUrl('http://127.0.0.1')).toBe(true);
expect(isPrivateUrl('http://127.0.0.1:8080')).toBe(true);
});
it('should detect the full 127.x.x.x range', () => {
expect(isPrivateUrl('http://127.0.0.2')).toBe(true);
expect(isPrivateUrl('http://127.255.255.255')).toBe(true);
});
});
describe('IPv4 private ranges', () => {
it('should detect 10.x.x.x', () => {
expect(isPrivateUrl('http://10.0.0.1')).toBe(true);
expect(isPrivateUrl('http://10.255.255.255')).toBe(true);
});
it('should detect 172.16.0.0/12', () => {
expect(isPrivateUrl('http://172.16.0.1')).toBe(true);
expect(isPrivateUrl('http://172.31.255.255')).toBe(true);
});
it('should not flag 172.x outside the /12 range', () => {
expect(isPrivateUrl('http://172.15.0.1')).toBe(false);
expect(isPrivateUrl('http://172.32.0.1')).toBe(false);
});
it('should detect 192.168.x.x', () => {
expect(isPrivateUrl('http://192.168.0.1')).toBe(true);
expect(isPrivateUrl('http://192.168.255.255')).toBe(true);
});
it('should detect link-local 169.254.x.x', () => {
expect(isPrivateUrl('http://169.254.1.1')).toBe(true);
});
it('should detect 0.0.0.0', () => {
expect(isPrivateUrl('http://0.0.0.0')).toBe(true);
});
});
describe('IPv6', () => {
it('should detect ::1 loopback', () => {
expect(isPrivateUrl('http://[::1]')).toBe(true);
expect(isPrivateUrl('http://[::1]:3000')).toBe(true);
});
it('should detect :: unspecified', () => {
expect(isPrivateUrl('http://[::]')).toBe(true);
});
it('should detect link-local fe80:', () => {
expect(isPrivateUrl('http://[fe80::1]')).toBe(true);
});
it('should detect unique local fc/fd', () => {
expect(isPrivateUrl('http://[fc00::1]')).toBe(true);
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
});
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
// of the checks handle. This is fine because dns.lookup never returns
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
});
});
describe('public URLs', () => {
it('should allow public hostnames', () => {
expect(isPrivateUrl('https://example.com')).toBe(false);
expect(isPrivateUrl('https://api.documenso.com/webhook')).toBe(false);
});
it('should allow public IP addresses', () => {
expect(isPrivateUrl('http://8.8.8.8')).toBe(false);
expect(isPrivateUrl('http://1.1.1.1')).toBe(false);
expect(isPrivateUrl('http://203.0.113.1')).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for invalid URLs', () => {
expect(isPrivateUrl('not-a-url')).toBe(false);
expect(isPrivateUrl('')).toBe(false);
});
});
});
@@ -0,0 +1,82 @@
import { z } from 'zod';
const ZIpSchema = z.string().ip();
/**
* Check whether a URL points to a known private/loopback address.
*
* Performs a synchronous check against known private hostnames and IP ranges.
* Works regardless of the URL protocol.
*/
export const isPrivateUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
// Strip IPv6 brackets.
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
const normalizedHost = bare.replace(/\.+$/, '');
if (normalizedHost === 'localhost') {
return true;
}
const parsedIp = ZIpSchema.safeParse(normalizedHost);
if (!parsedIp.success) {
return false;
}
if (normalizedHost === '::1' || normalizedHost === '::') {
return true;
}
if (normalizedHost === '0.0.0.0') {
return true;
}
if (normalizedHost.startsWith('127.')) {
return true;
}
if (normalizedHost.startsWith('10.')) {
return true;
}
if (normalizedHost.startsWith('192.168.')) {
return true;
}
if (normalizedHost.startsWith('169.254.')) {
return true;
}
if (normalizedHost.startsWith('fe80:')) {
return true;
}
if (normalizedHost.startsWith('fc') || normalizedHost.startsWith('fd')) {
return true;
}
// 172.16.0.0/12
if (normalizedHost.startsWith('172.')) {
const second = parseInt(normalizedHost.split('.')[1], 10);
if (second >= 16 && second <= 31) {
return true;
}
}
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (v4Mapped) {
return isPrivateUrl(`http://${v4Mapped[1]}`);
}
return false;
} catch {
return false;
}
};

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