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