Compare commits

...
20 changed files with 521 additions and 262 deletions
@@ -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>
+4 -3
View File
@@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import type { EntryContext, RouterContextProvider } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -19,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
loadContext: RouterContextProvider,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -33,7 +34,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.nonce || undefined;
const nonce = loadContext.get(nonceContext) || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
+13
View File
@@ -0,0 +1,13 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
+8
View File
@@ -0,0 +1,8 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+5 -2
View File
@@ -23,13 +23,16 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce } from './utils/nonce';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -74,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.nonce,
nonce: context.get(nonceContext),
session: session.isAuthenticated
? {
user: session.user,
@@ -1,4 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,16 +20,18 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { user } = await getOptionalSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
+7 -1
View File
@@ -1,4 +1,10 @@
import { useRouteLoaderData } from 'react-router';
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
/**
* Returns the supplied CSP nonce only when rendering on the server.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.17.0"
"version": "2.18.0"
}
+3
View File
@@ -8,4 +8,7 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
+2 -3
View File
@@ -24,12 +24,11 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
return await next();
}
// Add context to any pages you want here.
return next();
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
+6 -23
View File
@@ -1,33 +1,16 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import { RouterContextProvider } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
+2 -1
View File
@@ -5,6 +5,7 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -53,7 +54,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
return getLoadContext() as unknown as AppLoadContext;
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.17.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.17.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -416,7 +416,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.17.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.17.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -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 };
};
@@ -35,33 +35,6 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
// Todo: (RR7) Migrate to use this after RR7.
// Note: If we actually ever proceed with this, there are multiple
// locations where we will need to update this.
// const user = await prisma.$transaction(async (tx) => {
// const user = await tx.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword, // Todo: (RR7) Drop password.
// signature,
// },
// });
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
// return user;
// });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
+11 -1
View File
@@ -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 };
}),
});
-143
View File
@@ -1,143 +0,0 @@
import { useLayoutEffect, useRef } from 'react';
import { cn } from '../lib/utils';
export type Dimensions = {
height: number;
width: number;
};
export type AutoSizedTextProps = {
children: React.ReactNode;
className?: string;
maxHeight?: number;
useRem?: boolean;
};
const ITERATION_LIMIT = 20;
const MAXIMUM_DIFFERENCE = 1; // px
function getElementDimensions(element: HTMLElement): Dimensions {
const bbox = element.getBoundingClientRect();
return {
width: bbox.width,
height: bbox.height,
};
}
function getBaseFontSize(): number {
try {
const fontSize = getComputedStyle(document.documentElement).fontSize;
const parsed = parseFloat(fontSize);
// Check if we got a valid number
if (!Number.isFinite(parsed)) {
return 16;
}
return parsed;
} catch (error) {
// Fallback to browser default if anything goes wrong
return 16;
}
}
function pxToRem(px: number): number {
return px / getBaseFontSize();
}
export function AutoSizedText({ children, className, maxHeight, useRem = false }: AutoSizedTextProps) {
const childRef = useRef<HTMLDivElement>(null);
const fontSize = useRef<number>(0);
const fontSizeLowerBound = useRef<number>(0);
const fontSizeUpperBound = useRef<number>(0);
const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => {
const childElement = childRef.current;
if (!childElement) {
return;
}
let newFontSize: number;
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const isElementTooBig = childDimensions.width > parentDimensions.width || childDimensions.height > targetHeight;
if (isElementTooBig) {
// Scale down if element is bigger than target
newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2;
fontSizeUpperBound.current = fontSize.current;
} else if (childDimensions.width < parentDimensions.width || childDimensions.height < parentDimensions.height) {
// Scale up if element is smaller than target
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
fontSizeLowerBound.current = fontSize.current;
}
fontSize.current = newFontSize;
// Convert to rem if useRem is true
const displayFontSize = useRem ? `${pxToRem(newFontSize)}rem` : `${newFontSize}px`;
childElement.style.fontSize = displayFontSize;
};
useLayoutEffect(() => {
const childElement = childRef.current;
const parentElement = childRef.current?.parentElement;
if (!childElement || !parentElement) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const parentDimensions = entry.contentRect;
// Reset iteration parameters
fontSizeLowerBound.current = 0;
fontSizeUpperBound.current = parentDimensions.height;
let iterationCount = 0;
while (iterationCount <= ITERATION_LIMIT) {
const childDimensions = getElementDimensions(childElement);
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const widthDifference = parentDimensions.width - childDimensions.width;
const heightDifference = targetHeight - childDimensions.height;
const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
const isWithinTolerance =
Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE || Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
if (childFitsIntoParent && isWithinTolerance) {
break;
}
adjustFontSize(childDimensions, parentDimensions);
iterationCount += 1;
}
});
observer.observe(parentElement);
return () => {
observer.disconnect();
};
}, [maxHeight, useRem]);
return (
<div ref={childRef} className={cn('inline-block leading-none', className)}>
{children}
</div>
);
}