From 315ac4a14eb03ae054f81c96a3a6fa494dc04e68 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 22 Feb 2026 10:46:10 -0700 Subject: [PATCH 1/2] Show email delivery status from Resend list API in VolunteerTable Fetches all sent emails from the new Resend list API endpoint on volunteer load, matching by recipient email. Shows delivery status even when no resend_id is stored in the volunteer document. Merges "Via Resend" emails into the Msgs tooltip with status chips. Co-Authored-By: Claude Opus 4.6 --- src/components/admin/VolunteerTable.js | 127 +++++++++++++++++++++---- 1 file changed, 109 insertions(+), 18 deletions(-) diff --git a/src/components/admin/VolunteerTable.js b/src/components/admin/VolunteerTable.js index 293bd42..380a46d 100644 --- a/src/components/admin/VolunteerTable.js +++ b/src/components/admin/VolunteerTable.js @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback } from "react"; +import React, { useMemo, useState, useCallback, useEffect } from "react"; import { Table, TableBody, @@ -205,6 +205,8 @@ const VolunteerTable = ({ const [copyFeedback, setCopyFeedback] = useState({ open: false, message: '' }); const [resendStatuses, setResendStatuses] = useState({}); // { resend_id: { last_event, ... } } const [loadingResendStatus, setLoadingResendStatus] = useState({}); + const [resendEmailsByRecipient, setResendEmailsByRecipient] = useState({}); // { email: [{id, subject, created_at, last_event}] } + const [resendListLoaded, setResendListLoaded] = useState(false); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -285,6 +287,48 @@ const VolunteerTable = ({ } }, [accessToken, orgId, resendStatuses, loadingResendStatus]); + // Fetch all sent emails from Resend list API, indexed by recipient + const fetchResendEmailList = useCallback(async () => { + if (!accessToken || !orgId || !volunteers?.length) return; + + const uniqueEmails = [...new Set( + volunteers + .map(v => v.email) + .filter(e => e && e.includes('@')) + )]; + + if (uniqueEmails.length === 0) return; + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/admin/emails/resend-list?emails=${encodeURIComponent(uniqueEmails.join(','))}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-Org-Id': orgId, + }, + } + ); + + if (response.ok) { + const data = await response.json(); + if (data.emails_by_recipient) { + setResendEmailsByRecipient(data.emails_by_recipient); + } + } + } catch (err) { + console.warn('Failed to fetch Resend email list:', err); + } + }, [accessToken, orgId, volunteers]); + + // Fetch Resend email list once when volunteers load + useEffect(() => { + if (!resendListLoaded && volunteers?.length > 0 && accessToken && orgId) { + setResendListLoaded(true); + fetchResendEmailList(); + } + }, [volunteers, accessToken, orgId, resendListLoaded, fetchResendEmailList]); + // Helper to get sent emails from either new sent_emails or legacy messages_sent const getSentEmails = useCallback((volunteer) => { if (Array.isArray(volunteer.sent_emails) && volunteer.sent_emails.length > 0) { @@ -571,7 +615,25 @@ const VolunteerTable = ({ const sentEmails = getSentEmails(volunteer); const messageCount = sentEmails.length; - if (messageCount === 0) { + // Look up emails from Resend list API for this volunteer + const volunteerEmail = volunteer.email?.toLowerCase(); + const resendListEmails = volunteerEmail ? (resendEmailsByRecipient[volunteerEmail] || []) : []; + + // Delivery status chip rendering helper (shared by stored and list-based emails) + const eventMap = { + delivered: { label: 'Delivered', color: 'success' }, + sent: { label: 'Sent', color: 'info' }, + bounced: { label: 'Bounced', color: 'error' }, + complained: { label: 'Complained', color: 'error' }, + delivery_delayed: { label: 'Delayed', color: 'warning' }, + }; + + const renderResendEventChip = (lastEvent) => { + const display = eventMap[lastEvent] || { label: lastEvent || 'Unknown', color: 'default' }; + return ; + }; + + if (messageCount === 0 && resendListEmails.length === 0) { return ( { - // Check for Resend live status first + // Check for Resend live status first (from per-ID fetch) if (email.resend_id && resendStatuses[email.resend_id]) { const status = resendStatuses[email.resend_id]; - const eventMap = { - delivered: { label: 'Delivered', color: 'success' }, - sent: { label: 'Sent', color: 'info' }, - bounced: { label: 'Bounced', color: 'error' }, - complained: { label: 'Complained', color: 'error' }, - delivery_delayed: { label: 'Delayed', color: 'warning' }, - }; - const display = eventMap[status.last_event] || { label: status.last_event || 'Unknown', color: 'default' }; - return ; + return renderResendEventChip(status.last_event); } // Loading state if (email.resend_id && loadingResendStatus[email.resend_id]) { return ; } + // Try matching from Resend list data by subject when no resend_id + if (!email.resend_id && email.subject && resendListEmails.length > 0) { + const match = resendListEmails.find(re => re.subject === email.subject); + if (match) { + return renderResendEventChip(match.last_event); + } + } // Legacy delivery_status fallback if (email._legacy_delivery_status) { const ds = email._legacy_delivery_status; @@ -620,13 +681,18 @@ const VolunteerTable = ({ return null; }; + // Determine display count: stored messages + any additional from Resend list + const storedResendIds = new Set(sentEmails.filter(e => e.resend_id).map(e => e.resend_id)); + const additionalResendEmails = resendListEmails.filter(re => !storedResendIds.has(re.id)); + const totalDisplayCount = messageCount + additionalResendEmails.length; + const messagesToolTipContent = ( - Recent Messages ({messageCount}) + Recent Messages ({totalDisplayCount}) {sentEmails.slice(0, 5).map((email, idx) => ( - + {new Date(email.timestamp).toLocaleString()} @@ -643,15 +709,40 @@ const VolunteerTable = ({ ))} + {additionalResendEmails.length > 0 && ( + <> + + Via Resend + + {additionalResendEmails.slice(0, 5).map((re, idx) => ( + + + {new Date(re.created_at).toLocaleString()} + + + Subject: {re.subject} + + + {renderResendEventChip(re.last_event)} + + + ))} + {additionalResendEmails.length > 5 && ( + + ...and {additionalResendEmails.length - 5} more from Resend + + )} + + )} {messageCount > 5 && ( - ...and {messageCount - 5} more messages + ...and {messageCount - 5} more stored messages )} ); - // Fetch Resend statuses when the tooltip opens + // Fetch Resend statuses when the tooltip opens (supplementary per-ID fetch) const handleTooltipOpen = () => { const resendIds = sentEmails .filter(e => e.resend_id) @@ -679,7 +770,7 @@ const VolunteerTable = ({ }} > Date: Tue, 24 Feb 2026 13:26:04 -0700 Subject: [PATCH 2/2] Fix nested response path for Resend list API data The backend wraps the response under a `data` key via _success_response(), so the correct path is data.data.emails_by_recipient, not data.emails_by_recipient. Co-Authored-By: Claude Opus 4.6 --- src/components/admin/VolunteerTable.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/admin/VolunteerTable.js b/src/components/admin/VolunteerTable.js index 380a46d..616c2d0 100644 --- a/src/components/admin/VolunteerTable.js +++ b/src/components/admin/VolunteerTable.js @@ -312,8 +312,9 @@ const VolunteerTable = ({ if (response.ok) { const data = await response.json(); - if (data.emails_by_recipient) { - setResendEmailsByRecipient(data.emails_by_recipient); + const emailsByRecipient = data?.data?.emails_by_recipient || data?.emails_by_recipient; + if (emailsByRecipient) { + setResendEmailsByRecipient(emailsByRecipient); } } } catch (err) {