From 9a9bcb7cf7471a73ab18b293f762152113244ab0 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Mon, 17 Aug 2026 16:39:59 -0400 Subject: [PATCH 1/2] create email async plans Plan and implement asynchronous email backend and UI. Swap to database-backed templates. Fix backend and frontent tests Remove MatchingSendPage and dedupe sending --- js/src/app/router/router.tsx | 32 ++- js/src/features/emails/EmailAdminPage.tsx | 62 +++--- .../emails/EmailHistoryDetailPage.tsx | 14 ++ js/src/features/emails/EmailHistoryPage.tsx | 8 + .../emails/EmailProgressFallbackPage.tsx | 9 + js/src/features/emails/EmailProgressPage.tsx | 24 ++ js/src/features/emails/EmailSendPage.tsx | 59 +++++ .../emails/_components/CsvUploader.tsx | 70 +----- .../emails/_components/EmailHistory.tsx | 120 ++++++++++ .../emails/_components/EmailPreviewer.tsx | 6 +- .../emails/_components/EmailProgress.tsx | 207 ++++++++++++++++++ .../emails/_components/EmailSender.tsx | 68 ++++-- .../emails/_components/StatusBadge.tsx | 31 +++ .../emails/_components/TemplateSelector.tsx | 96 ++++++++ 14 files changed, 696 insertions(+), 110 deletions(-) create mode 100644 js/src/features/emails/EmailHistoryDetailPage.tsx create mode 100644 js/src/features/emails/EmailHistoryPage.tsx create mode 100644 js/src/features/emails/EmailProgressFallbackPage.tsx create mode 100644 js/src/features/emails/EmailProgressPage.tsx create mode 100644 js/src/features/emails/EmailSendPage.tsx create mode 100644 js/src/features/emails/_components/EmailHistory.tsx create mode 100644 js/src/features/emails/_components/EmailProgress.tsx create mode 100644 js/src/features/emails/_components/StatusBadge.tsx create mode 100644 js/src/features/emails/_components/TemplateSelector.tsx diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index bb70f14..711b7e0 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -6,11 +6,27 @@ import { RequireAuth } from "@/app/router/guards/RequireAuth"; import AdminPage from "@/features/admin/Admin.page"; import AdminLoginPage from "@/features/admin/AdminLogin.page"; import EmailAdminPage from "@/features/emails/EmailAdminPage"; +import { EmailHistoryDetailPage } from "@/features/emails/EmailHistoryDetailPage"; +import { EmailHistoryPage } from "@/features/emails/EmailHistoryPage"; +import { EmailProgressFallbackPage } from "@/features/emails/EmailProgressFallbackPage"; +import { EmailProgressPage } from "@/features/emails/EmailProgressPage"; +import { EmailSendPage } from "@/features/emails/EmailSendPage"; import HomePage from "@/features/home/Home.page"; import SamplePage from "@/features/sample/Sample.page"; import SampleAdminPage from "@/features/sample/SampleAdmin.page"; import { SignUpPage } from "@/features/sign-up/SignUp.page"; -import { createBrowserRouter } from "react-router-dom"; +import { Navigate, createBrowserRouter } from "react-router-dom"; + +function createEmailAdminTabRoutes() { + return [ + { index: true, element: }, + { path: "send", element: }, + { path: "progress", element: }, + { path: "progress/:requestId", element: }, + { path: "history", element: }, + { path: "history/:requestId", element: }, + ]; +} export const router = createBrowserRouter([ // Public admin login: its page owns the full viewport. @@ -24,8 +40,12 @@ export const router = createBrowserRouter([ { path: "sign-up", element: }, ], }, - // Temporary public email admin page for TESTING - { path: "email", element: }, + // Temporary public email routes for TESTING (before auth is wired) + { + path: "email", + element: , + children: createEmailAdminTabRoutes(), + }, // Authenticated: guard -> layout -> page. Admin nests a second guard + layout. { element: , @@ -41,7 +61,11 @@ export const router = createBrowserRouter([ element: , children: [ { path: "admin", element: }, - { path: "admin/email", element: }, + { + path: "admin/email", + element: , + children: createEmailAdminTabRoutes(), + }, { path: "sample/admin", element: }, ], }, diff --git a/js/src/features/emails/EmailAdminPage.tsx b/js/src/features/emails/EmailAdminPage.tsx index 09bdf28..a2d576c 100644 --- a/js/src/features/emails/EmailAdminPage.tsx +++ b/js/src/features/emails/EmailAdminPage.tsx @@ -1,30 +1,42 @@ -import { CsvUploader } from "@/features/emails/_components/CsvUploader"; -import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer"; -import { EmailSender } from "@/features/emails/_components/EmailSender"; -import { - SendAsyncRequest, - type MessagePreview, -} from "@/features/emails/dto/emailDto"; -import { Box, Flex, Stack } from "@mantine/core"; -import { useState } from "react"; - +import { Box, Tabs } from "@mantine/core"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +type EmailAdminTabValue = "send" | "progress" | "history"; +function getSelectedTab(pathname: string): EmailAdminTabValue { + if (pathname.includes("/history")) { + return "history"; + } + if (pathname.includes("/progress")) { + return "progress"; + } + return "send"; +} export default function EmailAdminPage() { - const [request, setRequest] = useState(null); - const [previews, setPreviews] = useState(null); - + const location = useLocation(); + const navigate = useNavigate(); + const selectedTab = getSelectedTab(location.pathname); + const handleTabChange = (value: string | null) => { + if (value === "send") { + navigate("send"); + return; + } + if (value === "progress") { + navigate("progress"); + return; + } + if (value === "history") { + navigate("history"); + } + }; return ( - - - - - - - + + + Send Emails + Live Progress + History + + + - + ); } diff --git a/js/src/features/emails/EmailHistoryDetailPage.tsx b/js/src/features/emails/EmailHistoryDetailPage.tsx new file mode 100644 index 0000000..4e1d012 --- /dev/null +++ b/js/src/features/emails/EmailHistoryDetailPage.tsx @@ -0,0 +1,14 @@ +import { EmailHistory } from "@/features/emails/_components/EmailHistory"; +import { useNavigate, useParams } from "react-router-dom"; + +export function EmailHistoryDetailPage() { + const { requestId } = useParams<{ requestId: string }>(); + const navigate = useNavigate(); + + return ( + navigate("../history")} + /> + ); +} diff --git a/js/src/features/emails/EmailHistoryPage.tsx b/js/src/features/emails/EmailHistoryPage.tsx new file mode 100644 index 0000000..dae229e --- /dev/null +++ b/js/src/features/emails/EmailHistoryPage.tsx @@ -0,0 +1,8 @@ +import { EmailHistory } from "@/features/emails/_components/EmailHistory"; +import { useNavigate } from "react-router-dom"; + +export function EmailHistoryPage() { + const navigate = useNavigate(); + + return navigate(requestId)} />; +} diff --git a/js/src/features/emails/EmailProgressFallbackPage.tsx b/js/src/features/emails/EmailProgressFallbackPage.tsx new file mode 100644 index 0000000..a519e9e --- /dev/null +++ b/js/src/features/emails/EmailProgressFallbackPage.tsx @@ -0,0 +1,9 @@ +import { Text } from "@mantine/core"; + +export function EmailProgressFallbackPage() { + return ( + + No Email Sending Session in progress. Please select a batch from history + + ); +} diff --git a/js/src/features/emails/EmailProgressPage.tsx b/js/src/features/emails/EmailProgressPage.tsx new file mode 100644 index 0000000..425016f --- /dev/null +++ b/js/src/features/emails/EmailProgressPage.tsx @@ -0,0 +1,24 @@ +import { EmailProgress } from "@/features/emails/_components/EmailProgress"; +import { Stack, Group, Text, Button } from "@mantine/core"; +import { useNavigate, useParams } from "react-router-dom"; + +export function EmailProgressPage() { + const { requestId } = useParams<{ requestId: string }>(); + const navigate = useNavigate(); + + if (!requestId) { + return Missing request ID.; + } + + return ( + + + Batch {requestId} + + + + + ); +} diff --git a/js/src/features/emails/EmailSendPage.tsx b/js/src/features/emails/EmailSendPage.tsx new file mode 100644 index 0000000..928b3d3 --- /dev/null +++ b/js/src/features/emails/EmailSendPage.tsx @@ -0,0 +1,59 @@ +import type { + MessagePreview, + SendAsyncRequest, +} from "@/features/emails/dto/emailDto"; + +import { CsvUploader } from "@/features/emails/_components/CsvUploader"; +import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer"; +import { EmailSender } from "@/features/emails/_components/EmailSender"; +import { TemplateSelector } from "@/features/emails/_components/TemplateSelector"; +import { Box, Flex, Stack } from "@mantine/core"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export function EmailSendPage() { + const [request, setRequest] = useState(null); + const [previews, setPreviews] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState( + null, + ); + const [isSending, setIsSending] = useState(false); + const navigate = useNavigate(); + + const handleTemplateChange = (templateId: string | null) => { + setSelectedTemplateId(templateId); + setRequest(null); + setPreviews(null); + }; + + return ( + + + {/* Template selector - new for async */} + + {/* CSV uploader */} + + {/* Send button */} + + + + {/* Preview */} + + + + ); +} diff --git a/js/src/features/emails/_components/CsvUploader.tsx b/js/src/features/emails/_components/CsvUploader.tsx index 579027c..d10076a 100644 --- a/js/src/features/emails/_components/CsvUploader.tsx +++ b/js/src/features/emails/_components/CsvUploader.tsx @@ -5,10 +5,6 @@ import type { } from "@/features/emails/dto/emailDto"; import { showEmailError } from "@/features/emails/api/emailError"; -import { - EmailTemplate, - emailTemplateMap, -} from "@/features/emails/api/emailTemplate"; import { dataToSendRequest, parseUserFile, @@ -17,13 +13,11 @@ import { import { FileInput, Button, - NativeSelect, Flex, Box, ScrollArea, Text, Table, - Spoiler, } from "@mantine/core"; import { useEffect, useState } from "react"; @@ -37,31 +31,32 @@ const rowStyle = { /** * Main component for user and pairing CSV file uploaders, an email template dropdown, and email generation button. - * @param setRequest - A function to set the SendRequest object in the parent component. - * @returns A component that allows users to upload CSV files, select an email template, and generate a SendRequest object. + * @param setRequest - A function to set the SendAsyncRequest object in the parent component. + * @returns A component that allows users to upload CSV files, select an email template, and generate a SendAsyncRequest object. */ export function CsvUploader({ + templateId, setRequest, }: { + templateId: string | null; setRequest: React.Dispatch>; }) { const [userMap, setUserMap] = useState>(new Map()); const [pairList, setPairList] = useState([]); const [userFile, setUserFile] = useState(null); const [pairingFile, setPairingFile] = useState(null); - const [template, setTemplate] = useState(""); const handleCSV = async () => { if (!userMap || userMap.size === 0) { showEmailError("Missing file", "Please upload a User CSV."); return; } - if (template === "") { + if (!templateId) { showEmailError("Missing template", "Please select a template."); return; } try { - const req = await dataToSendRequest(userMap, pairList, template); + const req = await dataToSendRequest(userMap, pairList, templateId); setRequest(req); } catch (err) { showEmailError("Error when processing CSV", `${err}`); @@ -80,10 +75,7 @@ export function CsvUploader({ setPairFile={setPairingFile} setPairs={setPairList} /> - - + ); } @@ -260,51 +252,3 @@ export function PairingCsvUpload({ ); } - -/** - * Component for email template selection. - * @param selection - The currently selected email template. (undefined if no template is selected) - * @param setSelection - A function to handle changes to the selected email template. - * @returns Email template selection dropdown and template text preview. - */ -export function TemplateSelect({ - selection, - setSelection, -}: { - selection: string | undefined; - setSelection: React.Dispatch>; -}) { - const [template, setTemplate] = useState(); - - useEffect(() => { - if (selection) { - // Display the appropriate template - const template = emailTemplateMap[selection]; - setTemplate(template); - } else { - setTemplate(undefined); - } - }, [selection]); - - return ( - - setSelection(event.currentTarget.value)} - label="Email Templates" - description="Select an email template" - data={[{ label: "Select a template", value: "" }, "Pair", "Reminder"]} - /> - - - Subject: {template ? template.subject : ""} -
- - Body:{" "} - {template ? template.body : "Select a template to see its content."} - -
-
-
- ); -} diff --git a/js/src/features/emails/_components/EmailHistory.tsx b/js/src/features/emails/_components/EmailHistory.tsx new file mode 100644 index 0000000..7190512 --- /dev/null +++ b/js/src/features/emails/_components/EmailHistory.tsx @@ -0,0 +1,120 @@ +import type { EmailRequestSummary } from "@/features/emails/dto/emailDto"; + +import { EmailProgress } from "@/features/emails/_components/EmailProgress"; +import { listRequests } from "@/features/emails/api/emailAPI"; +import { Table, Text, Title, Badge, Stack, Group } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +interface EmailHistoryProps { + detailRequestId?: string | null; + onSelectRequest?: (requestId: string) => void; + onBack?: () => void; +} + +export function EmailHistory({ + detailRequestId, + onSelectRequest, + onBack, +}: EmailHistoryProps = {}) { + const [localSelectedRequestId, setLocalSelectedRequestId] = useState< + string | null + >(null); + const selectedRequestId = detailRequestId ?? localSelectedRequestId; + + const handleSelectRequest = + onSelectRequest ?? + ((requestId: string) => setLocalSelectedRequestId(requestId)); + const handleBack = onBack ?? (() => setLocalSelectedRequestId(null)); + + const { + data: requests, + isLoading, + error, + } = useQuery({ + queryKey: ["emailRequests"], + queryFn: () => listRequests(), + }); + + if (isLoading) return Loading history...; + + if (selectedRequestId) { + return ( + + + Batch Details + + ← Back to history + + + + + ); + } + + const rows = (requests || []).map((req) => ( + handleSelectRequest(req.id)} + style={{ cursor: "pointer" }} + className="hover:bg-gray-50" + > + + {new Date(req.createdAt).toLocaleString()} + + + + {req.source} + + + + {req.total} + + + + + Sent: {req.sent} + + + Error: {req.error} + + + + + + {req.terminal ? "Complete" : "In Progress"} + + + + )); + + return ( + + Email History + {error || (requests && requests.length === 0) ? + No email history yet. + : <> + + Click a row to view batch details + + + + + Created + Source + Total + Results + Status + + + {rows} +
+ + } +
+ ); +} diff --git a/js/src/features/emails/_components/EmailPreviewer.tsx b/js/src/features/emails/_components/EmailPreviewer.tsx index 463ebac..69136d3 100644 --- a/js/src/features/emails/_components/EmailPreviewer.tsx +++ b/js/src/features/emails/_components/EmailPreviewer.tsx @@ -14,10 +14,8 @@ import { useEffect } from "react"; /** * Component that displays the rendered emails returned by the /api/email/preview endpoint in a carousel. * - * Note: this takes the *rendered* previews (MessagePreview[]), not the raw object from dataToSendRequest. - * dataToSendRequest returns a SendEmailRequest whose subject/body still contain unresolved ${...} placeholders; - * passing that here would show literal template syntax. Render it first via previewEmails(request), then - * pass the result in so the user sees exactly what will be sent. + * Note: this takes the *rendered* previews (MessagePreview[]), not the SendAsyncRequest. + * The request is passed for query key dependency only. */ export function EmailPreviewer({ previews, diff --git a/js/src/features/emails/_components/EmailProgress.tsx b/js/src/features/emails/_components/EmailProgress.tsx new file mode 100644 index 0000000..899c91d --- /dev/null +++ b/js/src/features/emails/_components/EmailProgress.tsx @@ -0,0 +1,207 @@ +import type { EmailProgress } from "@/features/emails/dto/emailDto"; + +import { StatusBadge } from "@/features/emails/_components/StatusBadge"; +import { + getProgress, + resendEmail, + triggerProcess, +} from "@/features/emails/api/emailAPI"; +import { + showEmailSuccess, + showEmailError, +} from "@/features/emails/api/emailError"; +import { + SimpleGrid, + Stack, + Table, + Text, + Title, + Badge, + ActionIcon, + Tooltip, +} from "@mantine/core"; +import { IconRefresh } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +interface EmailProgressProps { + requestId: string; +} + +export function EmailProgress({ requestId }: EmailProgressProps) { + const [isTerminal, setIsTerminal] = useState(false); + const [isResending, setIsResending] = useState(null); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["emailProgress", requestId], + queryFn: () => getProgress(requestId), + refetchInterval: (data) => { + // Stop polling when batch is terminal (no pending or processing emails) + if (data) { + //&& data.pending + data.processing === 0 + setIsTerminal(true); + return false; // stops polling + } + return 2000; // poll every 2 seconds + }, + enabled: !!requestId, + }); + + const handleResend = async (emailId: string) => { + setIsResending(emailId); + try { + await resendEmail(emailId); + showEmailSuccess("Resend", `Email ${emailId} re-queued successfully`); + // Trigger the runner to send it + await triggerProcess(); + // Refetch to show updated status + await refetch(); + } catch (err) { + showEmailError( + "Resend Failed", + err instanceof Error ? err.message : "Unknown error", + ); + } finally { + setIsResending(null); + } + }; + + if (isLoading) return Loading progress...; + if (error) + return Error loading progress: {error.message}; + if (!data) return No progress data; + + const rows = data.emails.map((email) => ( + + + {email.recipients.join(", ")} + + + + + + + {email.error || "—"} + + + + + {email.sentAt ? new Date(email.sentAt).toLocaleString() : "—"} + + + + {email.status === "ERROR" && ( + + handleResend(email.id)} + loading={isResending === email.id} + color="red" + > + + + + )} + + + )); + + return ( + + + Live Progress {isTerminal && <Badge color="green">Complete</Badge>} + + {/* Summary tiles */} + +
+ + Total + + + {data.total} + +
+
+ + Pending + + + {data.pending} + +
+
+ + Processing + + + {data.processing} + +
+
+ + Sent + + + {data.sent} + +
+
+ + Error + + + {data.error} + +
+
+ {/* Per-email table */} +
+ + Emails ({data.emails.length}) + + + + + Recipients + Status + Error + Sent At + Action + + + {rows} +
+
+
+ ); +} diff --git a/js/src/features/emails/_components/EmailSender.tsx b/js/src/features/emails/_components/EmailSender.tsx index 2ce231b..dff0a5e 100644 --- a/js/src/features/emails/_components/EmailSender.tsx +++ b/js/src/features/emails/_components/EmailSender.tsx @@ -1,12 +1,14 @@ import type { SendAsyncRequest } from "@/features/emails/dto/emailDto"; import { sendToEmailApi } from "@/features/emails/api/emailAPI"; +import { enqueueEmails, triggerProcess } from "@/features/emails/api/emailAPI"; import { showEmailError, showEmailPending, showEmailSuccess, } from "@/features/emails/api/emailError"; -import { Button, Flex, Text } from "@mantine/core"; +import { type EnqueueEmailRequest } from "@/features/emails/dto/emailDto"; +import { Button, Text } from "@mantine/core"; import { modals } from "@mantine/modals"; import { useMutation } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -16,7 +18,20 @@ import { useEffect } from "react"; * @param request - The SendRequest object containing the email data to be sent. * @returns A button that, when clicked, opens a confirmation modal and sends the emails if confirmed. */ -export function EmailSender({ request }: { request: SendAsyncRequest | null }) { +export function EmailSender({ + + request, + selectedTemplateId, + isSending, + setIsSending, + navigate, +}: { + request: SendAsyncRequest | null; + selectedTemplateId: string | null; + isSending: boolean; + setIsSending: (isSending: boolean) => void; + navigate: (path: string) => void; +}) { const mutation = useMutation({ mutationFn: async (req: SendAsyncRequest) => sendToEmailApi(req), }); @@ -41,28 +56,53 @@ export function EmailSender({ request }: { request: SendAsyncRequest | null }) { "Cancel", `${request?.messages.length} Emails cancelled.`, ), - onConfirm: () => void handleSend(), + onConfirm: () => void handleAsyncSend(), }); - const handleSend = async () => { + const handleAsyncSend = async () => { + if (!selectedTemplateId) { + showEmailError("Missing Template", "Please select a template"); + return; + } if (!request) { - showEmailError("Preview first", "Preview before sending."); + showEmailError("Missing Request", "Please process CSV files first"); return; } + setIsSending(true); try { - await mutation.mutateAsync(request); - showEmailSuccess("Success", "Emails sent."); - } catch { - showEmailError("Error", "Unable to send emails."); + // Transform the SendRequest to EnqueueEmailRequest + const enqueueRequest: EnqueueEmailRequest = { + templateId: selectedTemplateId, + replyTo: request.replyTo || undefined, + messages: request.messages, + }; + + const response = await enqueueEmails(enqueueRequest); + showEmailSuccess("Emails Queued", `Accepted ${response.accepted} emails`); + + // Kick the runner to start draining + await triggerProcess(); + + navigate(`../progress/${response.requestId}`); + } catch (err) { + showEmailError( + "Send Failed", + err instanceof Error ? err.message : "Unknown error", + ); + } finally { + setIsSending(false); } }; return ( - - - + ); } diff --git a/js/src/features/emails/_components/StatusBadge.tsx b/js/src/features/emails/_components/StatusBadge.tsx new file mode 100644 index 0000000..704071f --- /dev/null +++ b/js/src/features/emails/_components/StatusBadge.tsx @@ -0,0 +1,31 @@ +import { Badge } from "@mantine/core"; + +export type StatusType = "PENDING" | "PROCESSING" | "SENT" | "ERROR"; + +function getStatusColor(status: StatusType): string { + switch (status) { + case "PENDING": + return "gray"; + case "PROCESSING": + return "blue"; + case "SENT": + return "green"; + case "ERROR": + return "red"; + default: + return "gray"; + } +} + +interface StatusBadgeProps { + status: StatusType; + size?: "xs" | "sm" | "md" | "lg" | "xl"; +} + +export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) { + return ( + + {status} + + ); +} diff --git a/js/src/features/emails/_components/TemplateSelector.tsx b/js/src/features/emails/_components/TemplateSelector.tsx new file mode 100644 index 0000000..9b82134 --- /dev/null +++ b/js/src/features/emails/_components/TemplateSelector.tsx @@ -0,0 +1,96 @@ +import type { EmailTemplate } from "@/features/emails/dto/emailDto"; + +import { listTemplates } from "@/features/emails/api/emailAPI"; +import { Select, Stack, Text, Badge, Box, Spoiler } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +interface TemplateSelectorProps { + value: string | null; + onChange: (templateId: string | null) => void; + label?: string; + error?: string; +} + +export function TemplateSelector({ + value, + onChange, + label = "Select Template", + error, +}: TemplateSelectorProps) { + const { + data: templates, + isLoading, + error: queryError, + } = useQuery({ + queryKey: ["emailTemplates"], + queryFn: () => listTemplates(), + }); + + const selectedTemplate = templates?.find((t) => t.id === value); + + return ( + +