From 2984af769cc12ae695b433683a035fb4e38787ea Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 Jan 2025 12:29:38 +1100 Subject: [PATCH 1/6] feat: add text align option to fields (#1610) ## Description Adds the ability to align text to the left, center or right for relevant fields. Previously text was always centered which can be less desirable. See attached debug document which has left, center and right text alignments set for fields. image ## Related Issue N/A ## Changes Made - Added text align option - Update the insert in pdf method to support different alignments - Added a debug mode to field insertion ## Testing Performed - Ran manual tests using the debug mode --- .../app/(signing)/sign/[token]/date-field.tsx | 23 +++++++-- .../(signing)/sign/[token]/email-field.tsx | 23 +++++++-- .../app/(signing)/sign/[token]/name-field.tsx | 23 +++++++-- .../(signing)/sign/[token]/number-field.tsx | 45 +++++++++++------- .../app/(signing)/sign/[token]/text-field.tsx | 29 +++++++++--- .../server-only/pdf/insert-field-in-pdf.ts | 47 ++++++++++++++++++- packages/lib/types/field-meta.ts | 10 ++++ .../field-item-advanced-settings.tsx | 6 +++ .../date-field.tsx | 28 +++++++++++ .../email-field.tsx | 28 +++++++++++ .../initials-field.tsx | 23 +++++++++ .../name-field.tsx | 28 +++++++++++ .../number-field.tsx | 25 +++++++++- .../text-field.tsx | 30 +++++++++++- 14 files changed, 332 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index b62eaf652..9d03ee690 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -16,6 +16,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones' import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZDateFieldMeta } from '@documenso/lib/types/field-meta'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -23,6 +24,7 @@ import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { SigningFieldContainer } from './signing-field-container'; @@ -59,6 +61,9 @@ export const DateField = ({ isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); @@ -150,9 +155,21 @@ export const DateField = ({ )} {field.inserted && ( -

- {localDateString} -

+
+

+ {localDateString} +

+
)} ); diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index 9300aef63..f3d664e23 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -11,6 +11,7 @@ import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -18,6 +19,7 @@ import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredSigningContext } from './provider'; @@ -48,6 +50,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const onSign = async (authOptions?: TRecipientActionAuth) => { @@ -128,9 +133,21 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} ); diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index bc83e5a49..1a0756d60 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -11,6 +11,7 @@ import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZNameFieldMeta } from '@documenso/lib/types/field-meta'; import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -18,6 +19,7 @@ import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; @@ -56,6 +58,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const [showFullNameModal, setShowFullNameModal] = useState(false); @@ -172,9 +177,21 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx index ffd90df64..07846468c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx @@ -52,8 +52,19 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu const [isPending, startTransition] = useTransition(); const [showRadioModal, setShowRadioModal] = useState(false); - const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; - const isReadOnly = parsedFieldMeta?.readOnly; + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const defaultValue = parsedFieldMeta?.value; const [localNumber, setLocalNumber] = useState( parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', @@ -71,16 +82,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); - const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - - const { - mutateAsync: removeSignedFieldWithToken, - isPending: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - - const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; - const handleNumberChange = (e: React.ChangeEvent) => { const text = e.target.value; setLocalNumber(text); @@ -208,7 +209,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu useEffect(() => { if ( (!field.inserted && defaultValue && localNumber) || - (!field.inserted && isReadOnly && defaultValue) + (!field.inserted && parsedFieldMeta?.readOnly && defaultValue) ) { void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -260,9 +261,21 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 0c4088d75..3f2229e0c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -62,7 +62,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null; + const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const shouldAutoSignField = @@ -261,11 +262,23 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text )} {field.inserted && ( -

- {field.customText.length < 20 - ? field.customText - : field.customText.substring(0, 15) + '...'} -

+
+

+ {field.customText.length < 20 + ? field.customText + : field.customText.substring(0, 15) + '...'} +

+
)} @@ -281,6 +294,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text className={cn('mt-2 w-full rounded-md', { 'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200': userInputHasErrors, + 'text-left': parsedFieldMeta?.textAlign === 'left', + 'text-center': + !parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center', + 'text-right': parsedFieldMeta?.textAlign === 'right', })} value={localText} onChange={handleTextChange} diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index af760c7f4..dc2308978 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,7 +1,7 @@ // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; import type { PDFDocument } from 'pdf-lib'; -import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib'; +import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib'; import { P, match } from 'ts-pattern'; import { @@ -36,6 +36,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu ); const isSignatureField = isSignatureFieldType(field.type); + const isDebugMode = + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true'; pdf.registerFontkit(fontkit); @@ -83,6 +86,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu const fieldX = pageWidth * (Number(field.positionX) / 100); const fieldY = pageHeight * (Number(field.positionY) / 100); + // Draw debug box if debug mode is enabled + if (isDebugMode) { + let debugX = fieldX; + let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates + + if (pageRotationInDegrees !== 0) { + const adjustedPosition = adjustPositionForRotation( + pageWidth, + pageHeight, + debugX, + debugY, + pageRotationInDegrees, + ); + + debugX = adjustedPosition.xPos; + debugY = adjustedPosition.yPos; + } + + page.drawRectangle({ + x: debugX, + y: debugY, + width: fieldWidth, + height: fieldHeight, + borderColor: rgb(1, 0, 0), // Red + borderWidth: 1, + rotate: degrees(pageRotationInDegrees), + }); + } + const font = await pdf.embedFont( isSignatureField ? fontCaveat : fontNoto, isSignatureField ? { features: { calt: false } } : undefined, @@ -278,6 +310,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu const meta = Parser ? Parser.safeParse(field.fieldMeta) : null; const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null; + const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center'; const longestLineInTextForWidth = field.customText .split('\n') .sort((a, b) => b.length - a.length)[0]; @@ -293,7 +326,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); - let textX = fieldX + (fieldWidth - textWidth) / 2; + // Add padding similar to web display (roughly 0.5rem equivalent in PDF units) + const padding = 8; // PDF points, roughly equivalent to 0.5rem + + // Calculate X position based on text alignment with padding + let textX = fieldX + padding; // Left alignment starts after padding + if (textAlign === 'center') { + textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding + } else if (textAlign === 'right') { + textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding + } + let textY = fieldY + (fieldHeight - textHeight) / 2; // Invert the Y axis since PDFs use a bottom-left coordinate system diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index f4e4da8f3..674cccb4b 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -11,9 +11,14 @@ export const ZBaseFieldMeta = z.object({ export type TBaseFieldMeta = z.infer; +export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']); + +export type TFieldTextAlignSchema = z.infer; + export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('initials'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TInitialsFieldMeta = z.infer; @@ -21,6 +26,7 @@ export type TInitialsFieldMeta = z.infer; export const ZNameFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('name'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TNameFieldMeta = z.infer; @@ -28,6 +34,7 @@ export type TNameFieldMeta = z.infer; export const ZEmailFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('email'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TEmailFieldMeta = z.infer; @@ -35,6 +42,7 @@ export type TEmailFieldMeta = z.infer; export const ZDateFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('date'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TDateFieldMeta = z.infer; @@ -44,6 +52,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({ text: z.string().optional(), characterLimit: z.number().optional(), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TTextFieldMeta = z.infer; @@ -55,6 +64,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({ minValue: z.number().optional(), maxValue: z.number().optional(), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TNumberFieldMeta = z.infer; diff --git a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx index a9123486b..e30763a7f 100644 --- a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx +++ b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx @@ -71,21 +71,25 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { return { type: 'initials', fontSize: 14, + textAlign: 'left', }; case FieldType.NAME: return { type: 'name', fontSize: 14, + textAlign: 'left', }; case FieldType.EMAIL: return { type: 'email', fontSize: 14, + textAlign: 'left', }; case FieldType.DATE: return { type: 'date', fontSize: 14, + textAlign: 'left', }; case FieldType.TEXT: return { @@ -97,6 +101,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { fontSize: 14, required: false, readOnly: false, + textAlign: 'left', }; case FieldType.NUMBER: return { @@ -110,6 +115,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { required: false, readOnly: false, fontSize: 14, + textAlign: 'left', }; case FieldType.RADIO: return { diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx index c3108b20b..99fbba491 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx @@ -5,6 +5,13 @@ import { validateFields as validateDateFields } from '@documenso/lib/advanced-fi import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type DateFieldAdvancedSettingsProps = { fieldState: DateFieldMeta; @@ -66,6 +73,27 @@ export const DateFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx index 0b6c644eb..92ddafd3c 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx @@ -5,6 +5,13 @@ import { validateFields as validateEmailFields } from '@documenso/lib/advanced-f import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type EmailFieldAdvancedSettingsProps = { fieldState: EmailFieldMeta; @@ -48,6 +55,27 @@ export const EmailFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx index b117d0913..472d0c4ff 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx @@ -6,6 +6,8 @@ import { type TInitialsFieldMeta as InitialsFieldMeta } from '@documenso/lib/typ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../select'; + type InitialsFieldAdvancedSettingsProps = { fieldState: InitialsFieldMeta; handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void; @@ -48,6 +50,27 @@ export const InitialsFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx index d6159e0d5..e9b10e13c 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx @@ -5,6 +5,13 @@ import { validateFields as validateNameFields } from '@documenso/lib/advanced-fi import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type NameFieldAdvancedSettingsProps = { fieldState: NameFieldMeta; @@ -48,6 +55,27 @@ export const NameFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx index cf193c6e3..60d1cf538 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx @@ -38,12 +38,12 @@ export const NumberFieldAdvancedSettings = ({ const [showValidation, setShowValidation] = useState(false); const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => { - const userValue = field === 'value' ? value : fieldState.value ?? 0; + const userValue = field === 'value' ? value : (fieldState.value ?? 0); const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue ?? 0); const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue ?? 0); const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly); const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required); - const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat ?? ''; + const numberFormat = field === 'numberFormat' ? String(value) : (fieldState.numberFormat ?? ''); const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14); const valueErrors = validateNumberField(String(userValue), { @@ -135,6 +135,27 @@ export const NumberFieldAdvancedSettings = ({ /> +
+ + + +
+
{ - const text = field === 'text' ? String(value) : fieldState.text ?? ''; + const text = field === 'text' ? String(value) : (fieldState.text ?? ''); const limit = field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit ?? 0); const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14); @@ -112,6 +119,27 @@ export const TextFieldAdvancedSettings = ({ />
+
+ + + +
+
Date: Tue, 28 Jan 2025 02:05:40 +0000 Subject: [PATCH 2/6] fix: admin leaderboard query sorting (#1548) --- .../leaderboard/data-table-leaderboard.tsx | 37 ++++- .../server-only/admin/get-signing-volume.ts | 154 ++++++++---------- 2 files changed, 102 insertions(+), 89 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx b/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx index 596f0051d..84855b15f 100644 --- a/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx +++ b/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react'; import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; -import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react'; +import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; @@ -54,7 +54,15 @@ export const LeaderboardTable = ({ onClick={() => handleColumnSort('name')} > {_(msg`Name`)} - + {sortBy === 'name' ? ( + sortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )}
), accessorKey: 'name', @@ -80,7 +88,15 @@ export const LeaderboardTable = ({ onClick={() => handleColumnSort('signingVolume')} > {_(msg`Signing Volume`)} - + {sortBy === 'signingVolume' ? ( + sortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )}
), accessorKey: 'signingVolume', @@ -94,7 +110,15 @@ export const LeaderboardTable = ({ onClick={() => handleColumnSort('createdAt')} > {_(msg`Created`)} - + {sortBy === 'createdAt' ? ( + sortOrder === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )}
); }, @@ -102,7 +126,7 @@ export const LeaderboardTable = ({ cell: ({ row }) => i18n.date(row.original.createdAt), }, ] satisfies DataTableColumnDef[]; - }, [sortOrder]); + }, [sortOrder, sortBy]); useEffect(() => { startTransition(() => { @@ -133,6 +157,9 @@ export const LeaderboardTable = ({ const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => { startTransition(() => { updateSearchParams({ + search: debouncedSearchString, + page, + perPage, sortBy: column, sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc', }); diff --git a/packages/lib/server-only/admin/get-signing-volume.ts b/packages/lib/server-only/admin/get-signing-volume.ts index 497000501..964d68ea5 100644 --- a/packages/lib/server-only/admin/get-signing-volume.ts +++ b/packages/lib/server-only/admin/get-signing-volume.ts @@ -1,5 +1,5 @@ -import { prisma } from '@documenso/prisma'; -import { DocumentStatus, Prisma } from '@documenso/prisma/client'; +import { kyselyPrisma, sql } from '@documenso/prisma'; +import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client'; export type SigningVolume = { id: number; @@ -24,92 +24,78 @@ export async function getSigningVolume({ sortBy = 'signingVolume', sortOrder = 'desc', }: GetSigningVolumeOptions) { - const whereClause = Prisma.validator()({ - status: 'ACTIVE', - OR: [ - { - user: { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { email: { contains: search, mode: 'insensitive' } }, - ], - }, - }, - { - team: { - name: { contains: search, mode: 'insensitive' }, - }, - }, - ], - }); + const offset = Math.max(page - 1, 0) * perPage; - const [subscriptions, totalCount] = await Promise.all([ - prisma.subscription.findMany({ - where: whereClause, - include: { - user: { - select: { - name: true, - email: true, - documents: { - where: { - status: DocumentStatus.COMPLETED, - deletedAt: null, - teamId: null, - }, - }, - }, - }, - team: { - select: { - name: true, - documents: { - where: { - status: DocumentStatus.COMPLETED, - deletedAt: null, - }, - }, - }, - }, - }, - orderBy: - sortBy === 'name' - ? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }] - : sortBy === 'createdAt' - ? [{ createdAt: sortOrder }] - : undefined, - skip: Math.max(page - 1, 0) * perPage, - take: perPage, - }), - prisma.subscription.count({ - where: whereClause, - }), - ]); + let findQuery = kyselyPrisma.$kysely + .selectFrom('Subscription as s') + .leftJoin('User as u', 's.userId', 'u.id') + .leftJoin('Team as t', 's.teamId', 't.id') + .leftJoin('Document as ud', (join) => + join + .onRef('u.id', '=', 'ud.userId') + .on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED)) + .on('ud.deletedAt', 'is', null) + .on('ud.teamId', 'is', null), + ) + .leftJoin('Document as td', (join) => + join + .onRef('t.id', '=', 'td.teamId') + .on('td.status', '=', sql.lit(DocumentStatus.COMPLETED)) + .on('td.deletedAt', 'is', null), + ) + // @ts-expect-error - Raw SQL enum casting not properly typed by Kysely + .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) + .where((eb) => + eb.or([ + eb('u.name', 'ilike', `%${search}%`), + eb('u.email', 'ilike', `%${search}%`), + eb('t.name', 'ilike', `%${search}%`), + ]), + ) + .select([ + 's.id as id', + 's.createdAt as createdAt', + 's.planId as planId', + sql`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'), + sql`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'), + ]) + .groupBy(['s.id', 'u.name', 't.name', 'u.email']); - const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => { - const name = - subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown'; - const userSignedDocs = subscription.user?.documents?.length || 0; - const teamSignedDocs = subscription.team?.documents?.length || 0; - return { - id: subscription.id, - name, - signingVolume: userSignedDocs + teamSignedDocs, - createdAt: subscription.createdAt, - planId: subscription.planId, - }; - }); - - if (sortBy === 'signingVolume') { - leaderboardWithVolume.sort((a, b) => { - return sortOrder === 'desc' - ? b.signingVolume - a.signingVolume - : a.signingVolume - b.signingVolume; - }); + switch (sortBy) { + case 'name': + findQuery = findQuery.orderBy('name', sortOrder); + break; + case 'createdAt': + findQuery = findQuery.orderBy('createdAt', sortOrder); + break; + case 'signingVolume': + findQuery = findQuery.orderBy('signingVolume', sortOrder); + break; + default: + findQuery = findQuery.orderBy('signingVolume', 'desc'); } + findQuery = findQuery.limit(perPage).offset(offset); + + const countQuery = kyselyPrisma.$kysely + .selectFrom('Subscription as s') + .leftJoin('User as u', 's.userId', 'u.id') + .leftJoin('Team as t', 's.teamId', 't.id') + // @ts-expect-error - Raw SQL enum casting not properly typed by Kysely + .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) + .where((eb) => + eb.or([ + eb('u.name', 'ilike', `%${search}%`), + eb('u.email', 'ilike', `%${search}%`), + eb('t.name', 'ilike', `%${search}%`), + ]), + ) + .select(({ fn }) => [fn.countAll().as('count')]); + + const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); + return { - leaderboard: leaderboardWithVolume, - totalPages: Math.ceil(totalCount / perPage), + leaderboard: results, + totalPages: Math.ceil(Number(count) / perPage), }; } From 70a3ac0525c4a6189eec7771297dadf1847c898a Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 Jan 2025 15:18:12 +1100 Subject: [PATCH 3/6] fix: tidy document invite email render logic (#1597) Updates one of our confusing ternaries to use `ts-pattern` for rendering the conditional blocks making it easy to follow the logic occurring. ## Related Issue N/A ## Changes Made - Swapped ternary for `ts-pattern` ## Testing Performed - Manually created a bunch of documents in configurations matching those required to exhaust the `match` conditions. --- .../template-document-invite.tsx | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index 8c550fbfa..c8b4a402d 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RecipientRole } from '@documenso/prisma/client'; @@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({ const rejectDocumentLink = useMemo(() => { const url = new URL(signDocumentLink); - url.searchParams.set('reject', 'true'); - return url.toString(); - }, []); + }, [signDocumentLink]); return ( <> @@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
- {selfSigner ? ( - - Please {_(actionVerb).toLowerCase()} your document -
"{documentName}" -
- ) : isTeamInvite ? ( - <> - {includeSenderDetails ? ( - - {inviterName} on behalf of "{teamName}" has invited you to{' '} - {_(actionVerb).toLowerCase()} - - ) : ( - - {teamName} has invited you to {_(actionVerb).toLowerCase()} - - )} -
"{documentName}" - - ) : ( - - {inviterName} has invited you to {_(actionVerb).toLowerCase()} -
"{documentName}" -
- )} + {match({ selfSigner, isTeamInvite, includeSenderDetails, teamName }) + .with({ selfSigner: true }, () => ( + + Please {_(actionVerb).toLowerCase()} your document +
"{documentName}" +
+ )) + .with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => ( + + {inviterName} on behalf of "{teamName}" has invited you to{' '} + {_(actionVerb).toLowerCase()} +
"{documentName}" +
+ )) + .with({ isTeamInvite: true, teamName: P.string }, () => ( + + {teamName} has invited you to {_(actionVerb).toLowerCase()} +
"{documentName}" +
+ )) + .otherwise(() => ( + + {inviterName} has invited you to {_(actionVerb).toLowerCase()} +
"{documentName}" +
+ ))}
From bcef84787dc90da9e29f3e26caddc06a5a82965b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 Jan 2025 15:33:32 +1100 Subject: [PATCH 4/6] feat: bulk send templates via csv (#1578) Implements a bulk send feature allowing users to upload a CSV file to create multiple documents from a template. Includes CSV template generation, background processing, and email notifications. image image image ## Changes Made - Added `TemplateBulkSendDialog` with CSV upload/download functionality - Implemented bulk send job handler using background task system - Created email template for completion notifications - Added bulk send option to template view and actions dropdown - Added CSV parsing with email/name validation ## Testing Performed - CSV upload with valid/invalid data - Bulk send with/without immediate sending - Email notifications and error handling - Team context integration - File size and row count limits Resolves #1550 --- .../templates/[id]/template-page-view.tsx | 3 + .../templates/data-table-action-dropdown.tsx | 15 +- .../templates/template-bulk-send-dialog.tsx | 275 ++++++++++++++++++ package-lock.json | 7 + .../email/templates/bulk-send-complete.tsx | 91 ++++++ packages/lib/jobs/client.ts | 2 + .../emails/send-bulk-complete-email.ts | 39 +++ .../internal/bulk-send-template.handler.ts | 208 +++++++++++++ .../internal/bulk-send-template.ts | 37 +++ packages/lib/package.json | 1 + .../trpc/server/template-router/router.ts | 48 +++ .../trpc/server/template-router/schema.ts | 8 + 12 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/templates/template-bulk-send-dialog.tsx create mode 100644 packages/email/templates/bulk-send-complete.tsx create mode 100644 packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts create mode 100644 packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/bulk-send-template.ts diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx index 895eed438..081f22348 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { TemplateType } from '~/components/formatter/template-type'; +import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog'; import { DataTableActionDropdown } from '../data-table-action-dropdown'; import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; @@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
+ +
+ } + /> + setDeleteDialogOpen(true)} diff --git a/apps/web/src/components/templates/template-bulk-send-dialog.tsx b/apps/web/src/components/templates/template-bulk-send-dialog.tsx new file mode 100644 index 000000000..a21b20c7c --- /dev/null +++ b/apps/web/src/components/templates/template-bulk-send-dialog.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +const ZBulkSendFormSchema = z.object({ + file: z.instanceof(File), + sendImmediately: z.boolean().default(false), +}); + +type TBulkSendFormSchema = z.infer; + +export type TemplateBulkSendDialogProps = { + templateId: number; + recipients: Array<{ email: string; name?: string | null }>; + trigger?: React.ReactNode; + onSuccess?: () => void; +}; + +export const TemplateBulkSendDialog = ({ + templateId, + recipients, + trigger, + onSuccess, +}: TemplateBulkSendDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZBulkSendFormSchema), + defaultValues: { + sendImmediately: false, + }, + }); + + const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + + const onDownloadTemplate = () => { + const headers = recipients.flatMap((_, index) => [ + `recipient_${index + 1}_email`, + `recipient_${index + 1}_name`, + ]); + + const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']); + + const csv = [headers.join(','), exampleRow.join(',')].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + + const a = Object.assign(document.createElement('a'), { + href: url, + download: 'template.csv', + }); + + a.click(); + + window.URL.revokeObjectURL(url); + }; + + const onSubmit = async (values: TBulkSendFormSchema) => { + try { + const csv = await values.file.text(); + + await uploadBulkSend({ + templateId, + teamId: team?.id, + csv: csv, + sendImmediately: values.sendImmediately, + }); + + toast({ + title: _(msg`Success`), + description: _( + msg`Your bulk send has been initiated. You will receive an email notification upon completion.`, + ), + }); + + form.reset(); + onSuccess?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'Failed to upload CSV. Please check the file format and try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + {trigger ?? ( + + )} + + + + + + Bulk Send Template via CSV + + + + + Upload a CSV file to create multiple documents from this template. Each row represents + one document with its recipient details. + + + + +
+ +
+

+ CSV Structure +

+ +

+ + For each recipient, provide their email (required) and name (optional) in separate + columns. Download the template CSV below for the correct format. + +

+ +

+ Current recipients: +

+ +
    + {recipients.map((recipient, index) => ( +
  • + {recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email} +
  • + ))} +
+
+ +
+ + +

+ Pre-formatted CSV template with example data. +

+
+ + ( + + + {!value ? ( + + ) : ( +
+
+ + {value.name} +
+ + +
+ )} +
+ + {error &&

{error.message}

} + +

+ + Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use + template defaults. + +

+
+ )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/package-lock.json b/package-lock.json index 9ae01d2d5..03988dc46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13836,6 +13836,12 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, "node_modules/cytoscape": { "version": "3.28.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz", @@ -35031,6 +35037,7 @@ "@trigger.dev/sdk": "^2.3.18", "@upstash/redis": "^1.20.6", "@vvo/tzdb": "^6.117.0", + "csv-parse": "^5.6.0", "inngest": "^3.19.13", "kysely": "^0.26.3", "luxon": "^3.4.0", diff --git a/packages/email/templates/bulk-send-complete.tsx b/packages/email/templates/bulk-send-complete.tsx new file mode 100644 index 000000000..52c8416fd --- /dev/null +++ b/packages/email/templates/bulk-send-complete.tsx @@ -0,0 +1,91 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Html, Preview, Section, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; + +export interface BulkSendCompleteEmailProps { + userName: string; + templateName: string; + totalProcessed: number; + successCount: number; + failedCount: number; + errors: string[]; + assetBaseUrl?: string; +} + +export const BulkSendCompleteEmail = ({ + userName, + templateName, + totalProcessed, + successCount, + failedCount, + errors, +}: BulkSendCompleteEmailProps) => { + const { _ } = useLingui(); + + return ( + + + {_(msg`Bulk send operation complete for template "${templateName}"`)} + +
+ +
+ + Hi {userName}, + + + + Your bulk send operation for template "{templateName}" has completed. + + + + Summary: + + +
    +
  • + Total rows processed: {totalProcessed} +
  • +
  • + Successfully created: {successCount} +
  • +
  • + Failed: {failedCount} +
  • +
+ + {failedCount > 0 && ( +
+ + The following errors occurred: + + +
    + {errors.map((error, index) => ( +
  • + {error} +
  • + ))} +
+
+ )} + + + + You can view the created documents in your dashboard under the "Documents created + from template" section. + + +
+
+ + + + +
+ + + ); +}; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 988208a0d..6b0cbe693 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -6,6 +6,7 @@ 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 { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email'; import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email'; +import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template'; import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document'; /** @@ -21,6 +22,7 @@ export const jobsClient = new JobClient([ SEAL_DOCUMENT_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, + BULK_SEND_TEMPLATE_JOB_DEFINITION, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts b/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts new file mode 100644 index 000000000..a16def8cf --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; +import { type JobDefinition } from '../../client/_internal/job'; + +const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email'; + +const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + userId: z.number(), + templateId: z.number(), + templateName: z.string(), + totalProcessed: z.number(), + successCount: z.number(), + failedCount: z.number(), + errors: z.array(z.string()), + requestMetadata: ZRequestMetadataSchema.optional(), +}); + +export type TSendBulkCompleteEmailJobDefinition = z.infer< + typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA +>; + +export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = { + id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID, + name: 'Send Bulk Complete Email', + version: '1.0.0', + trigger: { + name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID, + schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-bulk-complete-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID, + TSendBulkCompleteEmailJobDefinition +>; diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts new file mode 100644 index 000000000..bce18752f --- /dev/null +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts @@ -0,0 +1,208 @@ +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 { prisma } from '@documenso/prisma'; +import type { TeamGlobalSettings } from '@documenso/prisma/client'; + +import { getI18nInstance } from '../../../client-only/providers/i18n.server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import { AppError } from '../../../errors/app-error'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding'; +import type { JobRunIO } from '../../client/_internal/job'; +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' }), + z.string().max(0, { message: 'Value must be a valid email or empty string' }), + ]), +}); + +export const run = async ({ + payload, + io, +}: { + payload: TBulkSendTemplateJobDefinition; + io: JobRunIO; +}) => { + const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload; + + const template = await getTemplateById({ + id: templateId, + userId, + teamId, + }); + + if (!template) { + throw new Error('Template not found'); + } + + const rows = parse(csvContent, { columns: true, skip_empty_lines: true }); + + if (rows.length > 100) { + throw new Error('Maximum 100 rows allowed per upload'); + } + + const { recipients } = template; + + // Validate CSV structure + const csvHeaders = Object.keys(rows[0]); + const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`); + + for (const header of requiredHeaders) { + if (!csvHeaders.includes(header)) { + throw new Error(`Missing required column: ${header}`); + } + } + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + email: true, + name: true, + }, + }); + + const results = { + success: 0, + failed: 0, + errors: Array(), + }; + + // Process each row + for (const [rowIndex, row] of rows.entries()) { + try { + for (const [recipientIndex] of recipients.entries()) { + const nameKey = `recipient_${recipientIndex + 1}_name`; + const emailKey = `recipient_${recipientIndex + 1}_email`; + + const parsed = ZRecipientRowSchema.safeParse({ + name: row[nameKey], + email: row[emailKey], + }); + + if (!parsed.success) { + throw new Error( + `Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`, + ); + } + } + + const document = await io.runTask(`create-document-${rowIndex}`, async () => { + return await createDocumentFromTemplate({ + templateId: template.id, + userId, + teamId, + recipients: recipients.map((recipient, index) => { + return { + id: recipient.id, + email: row[`recipient_${index + 1}_email`] || recipient.email, + name: row[`recipient_${index + 1}_name`] || recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + }; + }), + requestMetadata: { + source: 'app', + auth: 'session', + requestMetadata: requestMetadata || {}, + }, + }); + }); + + if (sendImmediately) { + await io.runTask(`send-document-${rowIndex}`, async () => { + await sendDocument({ + documentId: document.id, + userId, + teamId, + requestMetadata: { + source: 'app', + auth: 'session', + requestMetadata: requestMetadata || {}, + }, + }).catch((err) => { + console.error(err); + + throw new AppError('DOCUMENT_SEND_FAILED'); + }); + }); + } + + results.success += 1; + } catch (error) { + results.failed += 1; + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`); + } + } + + await io.runTask('send-completion-email', async () => { + const completionTemplate = createElement(BulkSendCompleteEmail, { + userName: user.name || user.email, + templateName: template.title, + totalProcessed: rows.length, + successCount: results.success, + failedCount: results.failed, + errors: results.errors, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), + }); + + let teamGlobalSettings: TeamGlobalSettings | undefined | null; + + if (template.teamId) { + teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({ + where: { + teamId: template.teamId, + }, + }); + } + + const branding = teamGlobalSettings + ? teamGlobalSettingsToBranding(teamGlobalSettings) + : undefined; + + const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(completionTemplate, { + lang: teamGlobalSettings?.documentLanguage, + branding, + }), + renderEmailWithI18N(completionTemplate, { + lang: teamGlobalSettings?.documentLanguage, + branding, + plainText: true, + }), + ]); + + await mailer.sendMail({ + to: { + name: user.name || '', + address: user.email, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: i18n._(msg`Bulk Send Complete: ${template.title}`), + html, + text, + }); + }); +}; diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.ts new file mode 100644 index 000000000..c101e3c40 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; +import { type JobDefinition } from '../../client/_internal/job'; + +const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template'; + +const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({ + userId: z.number(), + teamId: z.number().optional(), + templateId: z.number(), + csvContent: z.string(), + sendImmediately: z.boolean(), + requestMetadata: ZRequestMetadataSchema.optional(), +}); + +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, + name: 'Bulk Send Template', + version: '1.0.0', + trigger: { + name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID, + schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./bulk-send-template.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID, + TBulkSendTemplateJobDefinition +>; diff --git a/packages/lib/package.json b/packages/lib/package.json index 3ab271e5b..cc74d8621 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -40,6 +40,7 @@ "@trigger.dev/sdk": "^2.3.18", "@upstash/redis": "^1.20.6", "@vvo/tzdb": "^6.117.0", + "csv-parse": "^5.6.0", "inngest": "^3.19.13", "kysely": "^0.26.3", "luxon": "^3.4.0", diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 0be0e89a1..56f319634 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,5 +1,8 @@ +import { TRPCError } from '@trpc/server'; + import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { jobs } from '@documenso/lib/jobs/client'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { @@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; import { + ZBulkSendTemplateMutationSchema, ZCreateDocumentFromDirectTemplateRequestSchema, ZCreateDocumentFromTemplateRequestSchema, ZCreateDocumentFromTemplateResponseSchema, @@ -414,4 +418,48 @@ export const templateRouter = router({ userId, }); }), + + /** + * @private + */ + uploadBulkSend: authenticatedProcedure + .input(ZBulkSendTemplateMutationSchema) + .mutation(async ({ ctx, input }) => { + const { templateId, teamId, csv, sendImmediately } = input; + const { user } = ctx; + + if (csv.length > 4 * 1024 * 1024) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'File size exceeds 4MB limit', + }); + } + + const template = await getTemplateById({ + id: templateId, + teamId, + userId: user.id, + }); + + if (!template) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Template not found', + }); + } + + await jobs.triggerJob({ + name: 'internal.bulk-send-template', + payload: { + userId: user.id, + teamId, + templateId, + csvContent: csv, + sendImmediately, + requestMetadata: ctx.metadata.requestMetadata, + }, + }); + + return { success: true }; + }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index ee07946ee..78147fc6d 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -188,6 +188,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({ export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema; +export const ZBulkSendTemplateMutationSchema = z.object({ + templateId: z.number(), + teamId: z.number().optional(), + csv: z.string().min(1), + sendImmediately: z.boolean(), +}); + export type TCreateTemplateMutationSchema = z.infer; export type TDuplicateTemplateMutationSchema = z.infer; export type TDeleteTemplateMutationSchema = z.infer; +export type TBulkSendTemplateMutationSchema = z.infer; From 7e4faef95fd315832e5631a5222012de44d205e3 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 28 Jan 2025 06:34:22 +0200 Subject: [PATCH 5/6] chore: add cancelled webhook event (#1608) https://github.com/user-attachments/assets/9f2ff975-6688-4150-b4e3-0eb21e2b5503 --- .../pages/developers/webhooks.mdx | 93 ++++++++++++++++++- .../server-only/document/delete-document.ts | 14 ++- .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/prisma/migrations/20250124135853_add_cancelled_event_webhooks/migration.sql diff --git a/apps/documentation/pages/developers/webhooks.mdx b/apps/documentation/pages/developers/webhooks.mdx index 3ce2e7ee9..1155e32c8 100644 --- a/apps/documentation/pages/developers/webhooks.mdx +++ b/apps/documentation/pages/developers/webhooks.mdx @@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events: - `document.signed` - `document.completed` - `document.rejected` +- `document.cancelled` ## Create a webhook subscription @@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo To create a new webhook subscription, you need to provide the following information: - Enter the webhook URL that will receive the event payload. -- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`. +- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`. - Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request. ![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp) @@ -528,6 +529,96 @@ Example payload for the `document.rejected` event: } ``` +Example payload for the `document.rejected` event: + +```json +{ + "event": "DOCUMENT_CANCELLED", + "payload": { + "id": 7, + "externalId": null, + "userId": 3, + "authOptions": null, + "formValues": null, + "visibility": "EVERYONE", + "title": "documenso.pdf", + "status": "PENDING", + "documentDataId": "cm6exvn93006hi02ru90a265a", + "createdAt": "2025-01-27T11:02:14.393Z", + "updatedAt": "2025-01-27T11:03:16.387Z", + "completedAt": null, + "deletedAt": null, + "teamId": null, + "templateId": null, + "source": "DOCUMENT", + "documentMeta": { + "id": "cm6exvn96006ji02rqvzjvwoy", + "subject": "", + "message": "", + "timezone": "Etc/UTC", + "password": null, + "dateFormat": "yyyy-MM-dd hh:mm a", + "redirectUrl": "", + "signingOrder": "PARALLEL", + "typedSignatureEnabled": true, + "language": "en", + "distributionMethod": "EMAIL", + "emailSettings": { + "documentDeleted": true, + "documentPending": true, + "recipientSigned": true, + "recipientRemoved": true, + "documentCompleted": true, + "ownerDocumentCompleted": true, + "recipientSigningRequest": true + } + }, + "recipients": [ + { + "id": 7, + "documentId": 7, + "templateId": null, + "email": "mybirihix@mailinator.com", + "name": "Zorita Baird", + "token": "XkKx1HCs6Znm2UBJA2j6o", + "documentDeletedAt": null, + "expired": null, + "signedAt": null, + "authOptions": { "accessAuth": null, "actionAuth": null }, + "signingOrder": 1, + "rejectionReason": null, + "role": "SIGNER", + "readStatus": "NOT_OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ], + "Recipient": [ + { + "id": 7, + "documentId": 7, + "templateId": null, + "email": "signer@documenso.com", + "name": "Signer", + "token": "XkKx1HCs6Znm2UBJA2j6o", + "documentDeletedAt": null, + "expired": null, + "signedAt": null, + "authOptions": { "accessAuth": null, "actionAuth": null }, + "signingOrder": 1, + "rejectionReason": null, + "role": "SIGNER", + "readStatus": "NOT_OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ] + }, + "createdAt": "2025-01-27T11:03:27.730Z", + "webhookEndpoint": "https://mywebhooksite.com/mywebhook" +} +``` + ## Availability Webhooks are available to individual users and teams. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 2f19e1e70..43c815558 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -15,7 +15,7 @@ import type { TeamGlobalSettings, User, } from '@documenso/prisma/client'; -import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; +import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@documenso/prisma/client'; import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -23,10 +23,15 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; +import { + ZWebhookDocumentSchema, + mapDocumentToWebhookDocumentPayload, +} from '../../types/webhook-payload'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type DeleteDocumentOptions = { id: number; @@ -112,6 +117,13 @@ export const deleteDocument = async ({ }); } + await triggerWebhook({ + event: WebhookTriggerEvents.DOCUMENT_CANCELLED, + data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)), + userId, + teamId, + }); + // Return partial document for API v1 response. return { id: document.id, diff --git a/packages/prisma/migrations/20250124135853_add_cancelled_event_webhooks/migration.sql b/packages/prisma/migrations/20250124135853_add_cancelled_event_webhooks/migration.sql new file mode 100644 index 000000000..51b9aaa08 --- /dev/null +++ b/packages/prisma/migrations/20250124135853_add_cancelled_event_webhooks/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 013ab034e..44e0bfeee 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -175,6 +175,7 @@ enum WebhookTriggerEvents { DOCUMENT_SIGNED DOCUMENT_COMPLETED DOCUMENT_REJECTED + DOCUMENT_CANCELLED } model Webhook { From 2f866c41b4a317b4a9b2c9ccdfb756316d76180a Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 28 Jan 2025 07:16:18 +0200 Subject: [PATCH 6/6] fix: create global settings on team creation (#1601) The global team settings weren't created when creating a new team. ## Changes Made The global team settings are now created when a new team is created. --- packages/lib/server-only/team/create-team.ts | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts index ac279b519..27ff11a00 100644 --- a/packages/lib/server-only/team/create-team.ts +++ b/packages/lib/server-only/team/create-team.ts @@ -95,7 +95,7 @@ export const createTeam = async ({ }); } - await tx.team.create({ + const team = await tx.team.create({ data: { name: teamName, url: teamUrl, @@ -104,13 +104,23 @@ export const createTeam = async ({ members: { create: [ { - userId, + userId: user.id, role: TeamMemberRole.ADMIN, }, ], }, }, }); + + await tx.teamGlobalSettings.upsert({ + where: { + teamId: team.id, + }, + update: {}, + create: { + teamId: team.id, + }, + }); }); return { @@ -225,6 +235,16 @@ export const createTeamFromPendingTeam = async ({ }, }); + await tx.teamGlobalSettings.upsert({ + where: { + teamId: team.id, + }, + update: {}, + create: { + teamId: team.id, + }, + }); + await tx.subscription.upsert( mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id), );