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/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/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/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/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/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}" +
+ ))}
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/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), }; } 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/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/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), ); 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/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 { 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 670a39bcb..eacaf6d7d 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -183,6 +183,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; 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 = ({ />
+
+ + + +
+