mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 18:54:00 +10:00
fix: improve invalid bulk template upload error handling (#3326)
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
@@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
|
||||
|
||||
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
await uploadBulkSend({
|
||||
const result = await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setValidationError(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setValidationError(null);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||
onClick={() => onChange(null)}
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
|
||||
form.resetField('file');
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -218,6 +257,67 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationError !== null && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="max-h-32 overflow-y-auto">
|
||||
{match(validationError)
|
||||
.with({ type: 'PARSE_ERROR' }, () => (
|
||||
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
|
||||
))
|
||||
.with({ type: 'EMPTY' }, () => (
|
||||
<Trans>
|
||||
The CSV does not contain any rows. Please add at least one row of recipient details.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
|
||||
<Trans>
|
||||
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
|
||||
<>
|
||||
<Trans>
|
||||
The CSV is missing the following required columns. Please download the template CSV for the
|
||||
correct format.
|
||||
</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{missingColumns.map((column) => (
|
||||
<li key={column} className="font-mono">
|
||||
{column}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
|
||||
<>
|
||||
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{rowErrors.map((rowError, index) => (
|
||||
<li key={index}>
|
||||
<Trans>
|
||||
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
|
||||
email or empty
|
||||
</Trans>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
|
||||
code === AppErrorCode.LIMIT_EXCEEDED ? (
|
||||
<Trans>The CSV exceeds the maximum file size.</Trans>
|
||||
) : (
|
||||
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
@@ -240,7 +340,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
type="button"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { createElement } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
@@ -17,14 +15,6 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
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([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
@@ -41,25 +31,21 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(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`);
|
||||
// The CSV is validated upfront when the bulk send is uploaded, this acts as
|
||||
// a final safeguard prior to processing.
|
||||
const csvValidationResult = validateBulkSendCsv({
|
||||
csvContent,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
if (!csvValidationResult.success) {
|
||||
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
|
||||
}
|
||||
|
||||
const rows = csvValidationResult.data;
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -79,22 +65,6 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
// 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 envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
id: {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
|
||||
import { validateBulkSendCsv } from './validate-bulk-send-csv';
|
||||
|
||||
const buildCsv = (headers: string[], rows: string[][]) =>
|
||||
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
|
||||
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
|
||||
if (result.success) {
|
||||
throw new Error('Expected validation to fail, but it passed');
|
||||
}
|
||||
|
||||
return result.error;
|
||||
};
|
||||
|
||||
describe('validateBulkSendCsv', () => {
|
||||
describe('valid CSVs', () => {
|
||||
it('returns the parsed rows for a valid CSV', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[
|
||||
['alice@example.com', 'Alice'],
|
||||
['bob@example.com', 'Bob'],
|
||||
],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: [
|
||||
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
|
||||
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an empty string email so template defaults can be used', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the optional name column to be omitted entirely', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unknown extra columns', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates columns for every configured recipient', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[['alice@example.com', 'bob@example.com']],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows exactly the maximum number of rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PARSE_ERROR', () => {
|
||||
it('rejects a CSV that cannot be parsed', () => {
|
||||
const csvContent = 'recipient_1_email\n"unclosed quote';
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
|
||||
it('rejects a CSV with inconsistent column counts', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMPTY', () => {
|
||||
it('rejects an empty file', () => {
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
|
||||
it('rejects a CSV containing only a header row', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW_LIMIT_EXCEEDED', () => {
|
||||
it('rejects a CSV exceeding the default limit of 100 rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
|
||||
});
|
||||
|
||||
it('respects a custom maxRows option', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MISSING_COLUMNS', () => {
|
||||
it('rejects a CSV missing a required email column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
|
||||
});
|
||||
|
||||
it('reports every missing column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
|
||||
|
||||
expect(error).toEqual({
|
||||
type: 'MISSING_COLUMNS',
|
||||
missingColumns: ['recipient_2_email', 'recipient_3_email'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('INVALID_RECIPIENTS', () => {
|
||||
it('rejects a CSV containing an invalid email', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('references the offending row and column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates errors across multiple rows and recipients', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[
|
||||
['not-an-email', 'bob@example.com'],
|
||||
['alice@example.com', 'also-not-an-email'],
|
||||
],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [
|
||||
{ row: 1, column: 'recipient_1_email' },
|
||||
{ row: 2, column: 'recipient_2_email' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '../../utils/zod';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type TBulkSendCsvRow = Record<string, string | undefined>;
|
||||
|
||||
export type TBulkSendCsvRowError = {
|
||||
/**
|
||||
* The 1-indexed row number the error occurred on, excluding the header row.
|
||||
*/
|
||||
row: number;
|
||||
|
||||
/**
|
||||
* The column the error occurred in, such as `recipient_1_email`.
|
||||
*/
|
||||
column: string;
|
||||
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TBulkSendCsvError =
|
||||
| { type: 'PARSE_ERROR' }
|
||||
| { type: 'EMPTY' }
|
||||
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
|
||||
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
|
||||
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
|
||||
|
||||
export type TValidateBulkSendCsvResult =
|
||||
| { success: true; data: TBulkSendCsvRow[] }
|
||||
| { success: false; error: TBulkSendCsvError };
|
||||
|
||||
export type ValidateBulkSendCsvOptions = {
|
||||
csvContent: string;
|
||||
|
||||
/**
|
||||
* The number of recipients configured on the template, used to derive the
|
||||
* required `recipient_N_email` columns.
|
||||
*/
|
||||
recipientCount: number;
|
||||
|
||||
maxRows?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the CSV provided for a template bulk send.
|
||||
*
|
||||
* Returns a discriminated union so callers can surface structured error
|
||||
* details, such as which rows contain invalid recipients.
|
||||
*/
|
||||
export const validateBulkSendCsv = ({
|
||||
csvContent,
|
||||
recipientCount,
|
||||
maxRows = 100,
|
||||
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
|
||||
let rows: TBulkSendCsvRow[];
|
||||
|
||||
try {
|
||||
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
} catch {
|
||||
return { success: false, error: { type: 'PARSE_ERROR' } };
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: false, error: { type: 'EMPTY' } };
|
||||
}
|
||||
|
||||
if (rows.length > maxRows) {
|
||||
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
|
||||
}
|
||||
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
|
||||
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
|
||||
}
|
||||
|
||||
const rowErrors: TBulkSendCsvRowError[] = [];
|
||||
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
|
||||
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) {
|
||||
rowErrors.push({
|
||||
row: rowIndex + 1,
|
||||
column: emailKey,
|
||||
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
|
||||
}
|
||||
|
||||
return { success: true, data: rows };
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template
|
||||
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
@@ -879,6 +880,15 @@ export const templateRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const csvValidationResult = validateBulkSendCsv({
|
||||
csvContent: csv,
|
||||
recipientCount: template.recipients.length,
|
||||
});
|
||||
|
||||
if (!csvValidationResult.success) {
|
||||
return { success: false as const, error: csvValidationResult.error };
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.bulk-send-template',
|
||||
payload: {
|
||||
@@ -891,6 +901,6 @@ export const templateRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user