diff --git a/dashboard/app/keys/page.tsx b/dashboard/app/keys/page.tsx index 5d0ea54..6d336c4 100644 --- a/dashboard/app/keys/page.tsx +++ b/dashboard/app/keys/page.tsx @@ -350,19 +350,17 @@ export default function ApiKeysPage() { Enter a descriptive name for this API key. The key secret will be displayed once after creation. -
-
+
+
setNewKeyName(e.target.value)} - required - autoFocus />
-
+
>>({}); - const [recipients, setRecipients] = useState([createNewRecipient()]); + const [recipients, setRecipients] = useState([]); + const [csvImport, setCsvImport] = useState(null); + const [csvMappings, setCsvMappings] = useState>({}); + const csvInputRef = useRef(null); const [templatesByChannel, setTemplatesByChannel] = useState>({}); const [templatesLoadingByChannel, setTemplatesLoadingByChannel] = useState>({}); const [selectedTemplatesByChannel, setSelectedTemplatesByChannel] = useState>({}); @@ -204,12 +210,10 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) }, [selectedChannels, plugins, selectedProviders]); // Recipient Management - const addRecipient = () => setRecipients([...recipients, createNewRecipient()]); + const addRecipient = () => setRecipients((current) => [...current, createNewRecipient()]); const removeRecipient = (id: string) => { - if (recipients.length > 1) { - setRecipients(recipients.filter(r => r.id !== id)); - } + setRecipients((current) => current.filter((recipient) => recipient.id !== id)); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -360,6 +364,11 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) return; } + if (recipients.length === 0) { + toast.error("Add at least one recipient"); + return; + } + setIsLoading(true); try { @@ -461,7 +470,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) onSuccess?.(); // Reset - setRecipients([createNewRecipient()]); + setRecipients([]); setContentData({}); setScheduledDate(undefined); setSelectedTemplatesByChannel({}); @@ -507,6 +516,61 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) ) ).sort((left, right) => left.localeCompare(right)); + const csvRecipientFields: CsvRecipientField[] = [ + { key: "user_id", label: "User ID", required: true }, + ...uniqueRecipientFields.map((field) => ({ + key: field.name, + label: field.name, + required: field.required, + })), + ...activeTemplateVariableNames.map((name) => ({ + key: `variables.${name}`, + label: `{{${name}}}`, + required: false, + variableName: name, + })), + ]; + + const handleCsvSelected = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + try { + const parsed = parseCsv(await file.text()); + if (parsed.headers.length === 0 || parsed.rows.length === 0) { + toast.error("CSV must contain headers and at least one data row"); + return; + } + const mappings = mapCsvHeaders(parsed.headers, csvRecipientFields); + setCsvMappings(mappings); + setCsvImport({ fileName: file.name, ...parsed }); + } catch { + toast.error("Failed to read CSV file"); + } + }; + + const importCsvRecipients = () => { + if (!csvImport) return; + const missing = getMissingRequiredCsvFields(csvRecipientFields, csvMappings); + if (missing.length > 0) { + toast.error(`Map required fields: ${missing.map((field) => field.label).join(", ")}`); + return; + } + + const { recipients: imported, skipped } = createCsvRecipients( + csvImport.rows, + csvRecipientFields, + csvMappings, + generateUUID + ); + + setRecipients((current) => [...current, ...imported]); + setCsvImport(null); + setCsvMappings({}); + toast.success(`Imported ${imported.length} recipient${imported.length === 1 ? "" : "s"}${skipped ? `; skipped ${skipped}` : ""}`); + }; + return (
{/* Channels */} @@ -566,9 +630,15 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) Recipients Add multiple recipients with template variables
- +
+ + + +
@@ -576,7 +646,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
Recipient {index + 1} - {recipients.length > 1 && ( + {recipients.length > 0 && ( @@ -653,6 +723,48 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) + !open && setCsvImport(null)}> + + + Import Recipients from CSV + + Map CSV columns to recipient fields. A column can be used more than once. + {csvImport && ` ${csvImport.fileName} (${csvImport.rows.length} rows)`} + + +
+ {csvRecipientFields.map((field) => ( +
+ + +
+ ))} +
+ + + + +
+
+ {selectedChannels.map((channel) => { const provider = getActiveProvider(channel); if (!provider) return null; diff --git a/dashboard/lib/csv-import.ts b/dashboard/lib/csv-import.ts new file mode 100644 index 0000000..ab9d470 --- /dev/null +++ b/dashboard/lib/csv-import.ts @@ -0,0 +1,113 @@ +import { CsvRecipientField } from "./types"; + +export interface ParsedCsv { + headers: string[]; + rows: Record[]; +} + +export interface CsvImportedRecipient { + id: string; + request_id: string; + user_id: string; + variables: string; + [key: string]: string; +} + +export function parseCsv(text: string): ParsedCsv { + const values: string[][] = []; + let row: string[] = []; + let value = ""; + let quoted = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const next = text[index + 1]; + if (character === '"') { + if (quoted && next === '"') { + value += '"'; + index += 1; + } else { + quoted = !quoted; + } + } else if (character === "," && !quoted) { + row.push(value); + value = ""; + } else if ((character === "\n" || character === "\r") && !quoted) { + if (character === "\r" && next === "\n") index += 1; + row.push(value); + if (row.some((cell) => cell.trim() !== "")) values.push(row); + row = []; + value = ""; + } else { + value += character; + } + } + + if (value || row.length > 0) { + row.push(value); + if (row.some((cell) => cell.trim() !== "")) values.push(row); + } + + const headers = (values.shift() || []).map((header, index) => header.replace(/^\uFEFF/, "").trim() || `Column ${index + 1}`); + return { + headers, + rows: values.map((cells) => + Object.fromEntries(headers.map((header, index) => [header, (cells[index] || "").trim()])) + ), + }; +} + +const normalizeCsvHeader = (value: string) => value.toLowerCase().replace(/[\s_-]+/g, ""); + +export function mapCsvHeaders(headers: string[], fields: CsvRecipientField[]): Record { + const mappings: Record = {}; + fields.forEach((field) => { + const matchingHeader = headers.find((header) => normalizeCsvHeader(header) === normalizeCsvHeader(field.key)); + if (matchingHeader) mappings[field.key] = matchingHeader; + }); + return mappings; +} + +export function getMissingRequiredCsvFields( + fields: CsvRecipientField[], + mappings: Record +): CsvRecipientField[] { + return fields.filter((field) => field.required && !mappings[field.key]); +} + +export function importCsvRecipients( + rows: Record[], + fields: CsvRecipientField[], + mappings: Record, + createId: () => string +): { recipients: CsvImportedRecipient[]; skipped: number } { + let skipped = 0; + const recipients = rows.flatMap((row) => { + const userId = mappings.user_id ? row[mappings.user_id] : ""; + const requiredMissing = fields.some( + (field) => field.required && !row[mappings[field.key] || ""] + ); + if (!userId || requiredMissing) { + skipped += 1; + return []; + } + + const variables: Record = {}; + const recipient: CsvImportedRecipient = { + id: createId(), + request_id: createId(), + user_id: userId, + variables: "{}", + }; + fields.forEach((field) => { + const fieldValue = field.key === "user_id" ? userId : row[mappings[field.key] || ""]; + if (!fieldValue) return; + if (field.variableName) variables[field.variableName] = fieldValue; + else if (field.key !== "user_id") recipient[field.key] = fieldValue; + }); + recipient.variables = JSON.stringify(variables); + return [recipient]; + }); + + return { recipients, skipped }; +} diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 3cc5ed5..06491f4 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -119,6 +119,19 @@ export interface FieldDefinition { description?: string; } +export interface CsvRecipientField { + key: string; + label: string; + required: boolean; + variableName?: string; +} + +export interface CsvImportData { + fileName: string; + headers: string[]; + rows: Record[]; +} + export interface ProviderMetadata { id: string; name: string; @@ -132,7 +145,7 @@ export interface ProviderMetadata { export interface ChannelMetadata { providers: ProviderMetadata[]; default?: string; - fallback?: string; + fallback?: string | string[]; } export interface PluginMetadata { @@ -446,4 +459,3 @@ export interface ApiKeyUsageDetailResponse { }; } - diff --git a/tests/unit/dashboard/csv-import.test.ts b/tests/unit/dashboard/csv-import.test.ts new file mode 100644 index 0000000..72e8c30 --- /dev/null +++ b/tests/unit/dashboard/csv-import.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + getMissingRequiredCsvFields, + importCsvRecipients, + mapCsvHeaders, + parseCsv, +} from "../../../dashboard/lib/csv-import"; +import { CsvRecipientField } from "../../../dashboard/lib/types"; + +const fields: CsvRecipientField[] = [ + { key: "user_id", label: "User ID", required: true }, + { key: "email", label: "email", required: true }, + { key: "name", label: "name", required: false }, + { key: "variables.greeting", label: "{{greeting}}", required: false, variableName: "greeting" }, +]; + +describe("CSV import helpers", () => { + it("parses quoted values, escaped quotes, BOM headers, and mixed newlines", () => { + expect(parseCsv('\uFEFFuser_id, message\r\nu-1,"Hello, ""Sam"""\n\nu-2,"Line 1\nLine 2"')).toEqual({ + headers: ["user_id", "message"], + rows: [ + { user_id: "u-1", message: 'Hello, "Sam"' }, + { user_id: "u-2", message: "Line 1\nLine 2" }, + ], + }); + }); + + it("trims cells, ignores blank rows, and names empty headers", () => { + expect(parseCsv(" id , ,email\n 1 , value , one \n,,\n")).toEqual({ + headers: ["id", "Column 2", "email"], + rows: [{ id: "1", "Column 2": "value", email: "one" }], + }); + }); + + it("maps headers case-insensitively while treating spaces, hyphens, and underscores equally", () => { + expect(mapCsvHeaders(["user-id", "EMAIL", "variables.greeting"], fields)).toEqual({ + user_id: "user-id", + email: "EMAIL", + "variables.greeting": "variables.greeting", + }); + }); + + it("reports required fields that are not mapped", () => { + const missing = getMissingRequiredCsvFields(fields, { user_id: "id" }); + expect(missing.map((field) => field.key)).toEqual(["email"]); + }); + + it("imports mapped fields and variables while skipping rows missing required values", () => { + const result = importCsvRecipients( + [ + { id: "u-1", mail: "one@example.com", display: "One", greeting: "Hi" }, + { id: "", mail: "missing@example.com", display: "Missing", greeting: "Hello" }, + { id: "u-3", mail: "", display: "Missing email", greeting: "Hello" }, + ], + fields, + { user_id: "id", email: "mail", name: "display", "variables.greeting": "greeting" }, + (() => { + let next = 0; + return () => `generated-${++next}`; + })() + ); + + expect(result.skipped).toBe(2); + expect(result.recipients).toEqual([{ + id: "generated-1", + request_id: "generated-2", + user_id: "u-1", + email: "one@example.com", + name: "One", + variables: '{"greeting":"Hi"}', + }]); + }); +});