From 7a1e8149861ec48cd2d542254e25e46dd48ac47f Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 24 Oct 2026 18:12:09 +0530 Subject: [PATCH 1/4] fix: improve layout and styling of API key creation modal --- dashboard/app/keys/page.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 />
-
+
Date: Sat, 24 Oct 2026 18:12:16 +0530 Subject: [PATCH 2/4] fix(types): update fallback property in ChannelMetadata to support multiple string values --- dashboard/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 3cc5ed5..c577a72 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -132,7 +132,7 @@ export interface ProviderMetadata { export interface ChannelMetadata { providers: ProviderMetadata[]; default?: string; - fallback?: string; + fallback?: string | string[]; } export interface PluginMetadata { From 699efc3cfe53b4f2418c59f96f3556b4cb815661 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 24 Oct 2026 18:39:59 +0530 Subject: [PATCH 3/4] feat: add CSV import functionality for batch recipients in notification form --- .../send/batch-notification-form.tsx | 207 ++++++++++++++++-- dashboard/lib/types.ts | 14 +- 2 files changed, 207 insertions(+), 14 deletions(-) diff --git a/dashboard/components/send/batch-notification-form.tsx b/dashboard/components/send/batch-notification-form.tsx index 20cdd58..91147d7 100644 --- a/dashboard/components/send/batch-notification-form.tsx +++ b/dashboard/components/send/batch-notification-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -9,26 +9,72 @@ import { Label } from "@/components/ui/label"; import { DateTimePicker } from "@/components/ui/datetime-picker"; import { HtmlPreview } from "./html-preview"; import { toast } from "sonner"; -import { Loader2, Send, Plus, Trash2, AlertCircle } from "lucide-react"; +import { Loader2, Send, Plus, Trash2, AlertCircle, ImportIcon } from "lucide-react"; import { DynamicField } from "./dynamic-field"; -import { PluginMetadata, FieldDefinition, NotificationTemplateDetail, NotificationTemplateListItem } from "@/lib/types"; +import { PluginMetadata, FieldDefinition, NotificationTemplateDetail, NotificationTemplateListItem, CsvImportData, CsvRecipientField } from "@/lib/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { pluginService, templateService, notificationService } from "@/lib/api-client"; import { withBasePath } from "@/lib/utils"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; // Generate a UUIDv4 function generateUUID(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } +function parseCsv(text: string): { headers: string[]; rows: Record[] } { + 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()])) + ), + }; +} + interface BatchRecipient { id: string; // Internal UI ID request_id: string; @@ -68,7 +114,10 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) // Dynamic Data // eslint-disable-next-line @typescript-eslint/no-explicit-any const [contentData, setContentData] = useState>>({}); - 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 +253,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 +407,11 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) return; } + if (recipients.length === 0) { + toast.error("Add at least one recipient"); + return; + } + setIsLoading(true); try { @@ -461,7 +513,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) onSuccess?.(); // Reset - setRecipients([createNewRecipient()]); + setRecipients([]); setContentData({}); setScheduledDate(undefined); setSelectedTemplatesByChannel({}); @@ -507,6 +559,87 @@ 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 normalized = (value: string) => value.toLowerCase().replace(/[\s_-]+/g, ""); + const mappings: Record = {}; + csvRecipientFields.forEach((field) => { + const matchingHeader = parsed.headers.find((header) => normalized(header) === normalized(field.key)); + if (matchingHeader) mappings[field.key] = matchingHeader; + }); + setCsvMappings(mappings); + setCsvImport({ fileName: file.name, ...parsed }); + } catch { + toast.error("Failed to read CSV file"); + } + }; + + const importCsvRecipients = () => { + if (!csvImport) return; + const missing = csvRecipientFields.filter((field) => field.required && !csvMappings[field.key]); + if (missing.length > 0) { + toast.error(`Map required fields: ${missing.map((field) => field.label).join(", ")}`); + return; + } + + let skipped = 0; + const imported = csvImport.rows.flatMap((row) => { + const userId = csvMappings.user_id ? row[csvMappings.user_id] : ""; + const requiredMissing = csvRecipientFields.some( + (field) => field.required && !row[csvMappings[field.key] || ""] + ); + if (!userId || requiredMissing) { + skipped += 1; + return []; + } + + const variables: Record = {}; + const recipient: BatchRecipient = { + id: generateUUID(), + request_id: generateUUID(), + user_id: userId, + variables: "{}", + }; + csvRecipientFields.forEach((field) => { + const value = field.key === "user_id" ? userId : row[csvMappings[field.key] || ""]; + if (!value) return; + if (field.variableName) variables[field.variableName] = value; + else if (field.key !== "user_id") recipient[field.key] = value; + }); + recipient.variables = JSON.stringify(variables); + return [recipient]; + }); + + setRecipients((current) => [...current, ...imported]); + setCsvImport(null); + setCsvMappings({}); + toast.success(`Imported ${imported.length} recipient${imported.length === 1 ? "" : "s"}${skipped ? `; skipped ${skipped}` : ""}`); + }; + return (
{/* Channels */} @@ -566,9 +699,15 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) Recipients Add multiple recipients with template variables
- +
+ + + +
@@ -576,7 +715,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
Recipient {index + 1} - {recipients.length > 1 && ( + {recipients.length > 0 && ( @@ -653,6 +792,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/types.ts b/dashboard/lib/types.ts index c577a72..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; @@ -446,4 +459,3 @@ export interface ApiKeyUsageDetailResponse { }; } - From fcc50e411213ee6d43412c65a4978310fd45fd84 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sun, 25 Oct 2026 12:07:05 +0530 Subject: [PATCH 4/4] test(csv-import): add unit test for the csv import in dashboard send section --- .../send/batch-notification-form.tsx | 87 ++------------ dashboard/lib/csv-import.ts | 113 ++++++++++++++++++ tests/unit/dashboard/csv-import.test.ts | 73 +++++++++++ 3 files changed, 195 insertions(+), 78 deletions(-) create mode 100644 dashboard/lib/csv-import.ts create mode 100644 tests/unit/dashboard/csv-import.test.ts diff --git a/dashboard/components/send/batch-notification-form.tsx b/dashboard/components/send/batch-notification-form.tsx index 91147d7..6006929 100644 --- a/dashboard/components/send/batch-notification-form.tsx +++ b/dashboard/components/send/batch-notification-form.tsx @@ -18,6 +18,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { pluginService, templateService, notificationService } from "@/lib/api-client"; import { withBasePath } from "@/lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { getMissingRequiredCsvFields, importCsvRecipients as createCsvRecipients, mapCsvHeaders, parseCsv } from "@/lib/csv-import"; // Generate a UUIDv4 function generateUUID(): string { @@ -31,50 +32,6 @@ function generateUUID(): string { }); } -function parseCsv(text: string): { headers: string[]; rows: Record[] } { - 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()])) - ), - }; -} - interface BatchRecipient { id: string; // Internal UI ID request_id: string; @@ -585,12 +542,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) toast.error("CSV must contain headers and at least one data row"); return; } - const normalized = (value: string) => value.toLowerCase().replace(/[\s_-]+/g, ""); - const mappings: Record = {}; - csvRecipientFields.forEach((field) => { - const matchingHeader = parsed.headers.find((header) => normalized(header) === normalized(field.key)); - if (matchingHeader) mappings[field.key] = matchingHeader; - }); + const mappings = mapCsvHeaders(parsed.headers, csvRecipientFields); setCsvMappings(mappings); setCsvImport({ fileName: file.name, ...parsed }); } catch { @@ -600,39 +552,18 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) const importCsvRecipients = () => { if (!csvImport) return; - const missing = csvRecipientFields.filter((field) => field.required && !csvMappings[field.key]); + const missing = getMissingRequiredCsvFields(csvRecipientFields, csvMappings); if (missing.length > 0) { toast.error(`Map required fields: ${missing.map((field) => field.label).join(", ")}`); return; } - let skipped = 0; - const imported = csvImport.rows.flatMap((row) => { - const userId = csvMappings.user_id ? row[csvMappings.user_id] : ""; - const requiredMissing = csvRecipientFields.some( - (field) => field.required && !row[csvMappings[field.key] || ""] - ); - if (!userId || requiredMissing) { - skipped += 1; - return []; - } - - const variables: Record = {}; - const recipient: BatchRecipient = { - id: generateUUID(), - request_id: generateUUID(), - user_id: userId, - variables: "{}", - }; - csvRecipientFields.forEach((field) => { - const value = field.key === "user_id" ? userId : row[csvMappings[field.key] || ""]; - if (!value) return; - if (field.variableName) variables[field.variableName] = value; - else if (field.key !== "user_id") recipient[field.key] = value; - }); - recipient.variables = JSON.stringify(variables); - return [recipient]; - }); + const { recipients: imported, skipped } = createCsvRecipients( + csvImport.rows, + csvRecipientFields, + csvMappings, + generateUUID + ); setRecipients((current) => [...current, ...imported]); setCsvImport(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/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"}', + }]); + }); +});