Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions dashboard/app/keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Key Name</label>
<Input
placeholder="e.g. Billing Service, Mobile App Backend"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
required
autoFocus
/>
</div>

<div className="space-y-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Key Type & Permissions</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
Expand Down
138 changes: 125 additions & 13 deletions dashboard/components/send/batch-notification-form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,20 +9,23 @@ 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";
import { getMissingRequiredCsvFields, importCsvRecipients as createCsvRecipients, mapCsvHeaders, parseCsv } from "@/lib/csv-import";

// 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);
Expand Down Expand Up @@ -68,7 +71,10 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
// Dynamic Data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [contentData, setContentData] = useState<Record<string, Record<string, any>>>({});
const [recipients, setRecipients] = useState<BatchRecipient[]>([createNewRecipient()]);
const [recipients, setRecipients] = useState<BatchRecipient[]>([]);
const [csvImport, setCsvImport] = useState<CsvImportData | null>(null);
const [csvMappings, setCsvMappings] = useState<Record<string, string>>({});
const csvInputRef = useRef<HTMLInputElement>(null);
const [templatesByChannel, setTemplatesByChannel] = useState<Record<string, NotificationTemplateListItem[]>>({});
const [templatesLoadingByChannel, setTemplatesLoadingByChannel] = useState<Record<string, boolean>>({});
const [selectedTemplatesByChannel, setSelectedTemplatesByChannel] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -461,7 +470,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
onSuccess?.();

// Reset
setRecipients([createNewRecipient()]);
setRecipients([]);
setContentData({});
setScheduledDate(undefined);
setSelectedTemplatesByChannel({});
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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 (
<form onSubmit={handleSubmit} className="space-y-6 w-full">
{/* Channels */}
Expand Down Expand Up @@ -566,17 +630,23 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
<CardTitle className="text-sm font-medium">Recipients</CardTitle>
<CardDescription>Add multiple recipients with template variables</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRecipient}>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => csvInputRef.current?.click()}>
<ImportIcon className="h-4 w-4 mr-1" /> Import from CSV
</Button>
<input ref={csvInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleCsvSelected} />
<Button type="button" variant="outline" size="sm" onClick={addRecipient}>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{recipients.map((recipient, index) => (
<div key={recipient.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Recipient {index + 1}</span>
{recipients.length > 1 && (
{recipients.length > 0 && (
<Button type="button" variant="ghost" size="sm" onClick={() => removeRecipient(recipient.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
Expand Down Expand Up @@ -653,6 +723,48 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
</CardContent>
</Card>

<Dialog open={Boolean(csvImport)} onOpenChange={(open) => !open && setCsvImport(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import Recipients from CSV</DialogTitle>
<DialogDescription>
Map CSV columns to recipient fields. A column can be used more than once.
{csvImport && ` ${csvImport.fileName} (${csvImport.rows.length} rows)`}
</DialogDescription>
</DialogHeader>
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-2">
{csvRecipientFields.map((field) => (
<div key={field.key} className="grid grid-cols-1 items-center gap-2 sm:grid-cols-[1fr_1fr]">
<Label>{field.label}{field.required ? " *" : ""}</Label>
<Select
value={csvMappings[field.key] || "__ignore__"}
onValueChange={(value) =>
setCsvMappings((current) => ({
...current,
[field.key]: value === "__ignore__" ? "" : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Ignore field" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ignore__">Ignore field</SelectItem>
{csvImport?.headers.map((header) => (
<SelectItem key={`${field.key}-${header}`} value={header}>{header}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCsvImport(null)}>Cancel</Button>
<Button type="button" onClick={importCsvRecipients}>Import Recipients</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{selectedChannels.map((channel) => {
const provider = getActiveProvider(channel);
if (!provider) return null;
Expand Down
113 changes: 113 additions & 0 deletions dashboard/lib/csv-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { CsvRecipientField } from "./types";

export interface ParsedCsv {
headers: string[];
rows: Record<string, string>[];
}

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<string, string> {
const mappings: Record<string, string> = {};
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<string, string>
): CsvRecipientField[] {
return fields.filter((field) => field.required && !mappings[field.key]);
}

export function importCsvRecipients(
rows: Record<string, string>[],
fields: CsvRecipientField[],
mappings: Record<string, string>,
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<string, string> = {};
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 };
}
16 changes: 14 additions & 2 deletions dashboard/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>[];
}

export interface ProviderMetadata {
id: string;
name: string;
Expand All @@ -132,7 +145,7 @@ export interface ProviderMetadata {
export interface ChannelMetadata {
providers: ProviderMetadata[];
default?: string;
fallback?: string;
fallback?: string | string[];
}

export interface PluginMetadata {
Expand Down Expand Up @@ -446,4 +459,3 @@ export interface ApiKeyUsageDetailResponse {
};
}


Loading
Loading