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;