mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
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. <img width="563" alt="image" src="https://github.com/user-attachments/assets/658cee71-6508-4a00-87da-b17c6762b7d8" /> <img width="578" alt="image" src="https://github.com/user-attachments/assets/bbfac70b-c6a0-466a-be98-99ca4c4eb1b9" /> <img width="635" alt="image" src="https://github.com/user-attachments/assets/65b2f55d-d491-45ac-84d6-1a31afe953dd" /> ## 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
This commit is contained in:
@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|||||||
|
|
||||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
|
||||||
|
|
||||||
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||||
@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||||
<TemplateDirectLinkDialogWrapper template={template} />
|
<TemplateDirectLinkDialogWrapper template={template} />
|
||||||
|
|
||||||
|
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
|
||||||
|
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
<Link href={`${templateRootPath}/${template.id}/edit`}>
|
<Link href={`${templateRootPath}/${template.id}/edit`}>
|
||||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||||
@ -17,6 +17,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
|
||||||
|
|
||||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||||
import { MoveTemplateDialog } from './move-template-dialog';
|
import { MoveTemplateDialog } from './move-template-dialog';
|
||||||
@ -86,6 +88,17 @@ export const DataTableActionDropdown = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TemplateBulkSendDialog
|
||||||
|
templateId={row.id}
|
||||||
|
recipients={row.recipients}
|
||||||
|
trigger={
|
||||||
|
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Bulk Send via CSV</Trans>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isOwner && !isTeamTemplate}
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
|||||||
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal file
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal file
@ -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<typeof ZBulkSendFormSchema>;
|
||||||
|
|
||||||
|
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<TBulkSendFormSchema>({
|
||||||
|
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 (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Bulk Send via CSV</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Bulk Send Template via CSV</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
Upload a CSV file to create multiple documents from this template. Each row represents
|
||||||
|
one document with its recipient details.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||||
|
<div className="bg-muted/70 rounded-lg border p-4">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
<Trans>CSV Structure</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>
|
||||||
|
For each recipient, provide their email (required) and name (optional) in separate
|
||||||
|
columns. Download the template CSV below for the correct format.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
<Trans>Current recipients:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
||||||
|
{recipients.map((recipient, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<Button onClick={onDownloadTemplate} variant="outline" type="button">
|
||||||
|
<Trans>Download Template CSV</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Pre-formatted CSV template with example data.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
{!value ? (
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onChange(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Upload CSV</Trans>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 items-center rounded-md border px-3">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<FileIcon className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="flex-1 truncate text-sm">{value.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="text-destructive hover:text-destructive p-0 text-xs"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
<Trans>
|
||||||
|
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
||||||
|
template defaults.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sendImmediately"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="send-immediately"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="send-immediately"
|
||||||
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Send documents to recipients immediately</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Upload and Process</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -13836,6 +13836,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
"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": {
|
"node_modules/cytoscape": {
|
||||||
"version": "3.28.1",
|
"version": "3.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
|
||||||
@ -35031,6 +35037,7 @@
|
|||||||
"@trigger.dev/sdk": "^2.3.18",
|
"@trigger.dev/sdk": "^2.3.18",
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"@vvo/tzdb": "^6.117.0",
|
"@vvo/tzdb": "^6.117.0",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
"inngest": "^3.19.13",
|
"inngest": "^3.19.13",
|
||||||
"kysely": "^0.26.3",
|
"kysely": "^0.26.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
|
|||||||
91
packages/email/templates/bulk-send-complete.tsx
Normal file
91
packages/email/templates/bulk-send-complete.tsx
Normal file
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Text className="text-sm">
|
||||||
|
<Trans>Hi {userName},</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-sm">
|
||||||
|
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-lg font-semibold">
|
||||||
|
<Trans>Summary:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ul className="my-2 ml-4 list-inside list-disc">
|
||||||
|
<li>
|
||||||
|
<Trans>Total rows processed: {totalProcessed}</Trans>
|
||||||
|
</li>
|
||||||
|
<li className="mt-1">
|
||||||
|
<Trans>Successfully created: {successCount}</Trans>
|
||||||
|
</li>
|
||||||
|
<li className="mt-1">
|
||||||
|
<Trans>Failed: {failedCount}</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<Section className="mt-4">
|
||||||
|
<Text className="text-lg font-semibold">
|
||||||
|
<Trans>The following errors occurred:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ul className="my-2 ml-4 list-inside list-disc">
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
|
||||||
|
{error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className="text-sm">
|
||||||
|
<Trans>
|
||||||
|
You can view the created documents in your dashboard under the "Documents created
|
||||||
|
from template" section.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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_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_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 { 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';
|
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||||
|
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
@ -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
|
||||||
|
>;
|
||||||
@ -40,6 +40,7 @@
|
|||||||
"@trigger.dev/sdk": "^2.3.18",
|
"@trigger.dev/sdk": "^2.3.18",
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"@vvo/tzdb": "^6.117.0",
|
"@vvo/tzdb": "^6.117.0",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
"inngest": "^3.19.13",
|
"inngest": "^3.19.13",
|
||||||
"kysely": "^0.26.3",
|
"kysely": "^0.26.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
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 { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import {
|
import {
|
||||||
@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
|
|||||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZBulkSendTemplateMutationSchema,
|
||||||
ZCreateDocumentFromDirectTemplateRequestSchema,
|
ZCreateDocumentFromDirectTemplateRequestSchema,
|
||||||
ZCreateDocumentFromTemplateRequestSchema,
|
ZCreateDocumentFromTemplateRequestSchema,
|
||||||
ZCreateDocumentFromTemplateResponseSchema,
|
ZCreateDocumentFromTemplateResponseSchema,
|
||||||
@ -414,4 +418,48 @@ export const templateRouter = router({
|
|||||||
userId,
|
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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -188,6 +188,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
|
|||||||
|
|
||||||
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
|
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<typeof ZCreateTemplateMutationSchema>;
|
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||||
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||||
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||||
|
export type TBulkSendTemplateMutationSchema = z.infer<typeof ZBulkSendTemplateMutationSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user