diff --git a/package.json b/package.json index d37e0fb..fab6e44 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.5.0", + "version": "2.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server/email.js b/server/email.js index e48f3c0..546de6c 100644 --- a/server/email.js +++ b/server/email.js @@ -89,17 +89,22 @@ export async function syncContactToResend(name, email) { const { firstName, lastName } = splitName(name) const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) try { - const { error: contactError } = await contactResend.contacts.create({ + // Never touch `unsubscribed` for a contact that already exists — otherwise a form submission + // would silently resubscribe someone who opted out in Resend. + const { data: existing } = await contactResend.contacts.get({ email }) + if (existing) { + const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName }) + if (updateError) console.error('[resend] contact sync error:', updateError) + return + } + const { error: createError } = await contactResend.contacts.create({ email, firstName, lastName, unsubscribed: false, ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), }) - if (contactError) { - const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false }) - if (updateError) console.error('[resend] contact sync error:', updateError) - } + if (createError) console.error('[resend] contact sync error:', createError) } catch (err) { console.error('[resend] contact sync exception:', err) } diff --git a/server/routes/contact.js b/server/routes/contact.js index dc09ffd..bf0bfed 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -429,6 +429,151 @@ export function register(app) { res.send(csv) }) + // ── Subscribers ⇄ Resend comparison ───────────────────────────────────────── + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + + function getAppSubscribers() { + const byEmail = new Map() + for (const entry of state.contactSubmissions) { + if (entry.subscribe !== true || !entry.email) continue + const email = String(entry.email).trim().toLowerCase() + if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email }) + } + return byEmail + } + + async function listAllResendContacts(resend, segmentId) { + const out = [] + let after + for (let page = 0; page < 50; page += 1) { + const { data, error } = await resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }) + if (error) throw new Error(error.message || 'Resend contacts.list failed') + const rows = data?.data ?? [] + out.push(...rows) + if (!data?.has_more || rows.length === 0) break + after = rows[rows.length - 1].id + await sleep(550) // Resend allows ~2 requests/second + } + return out + } + + async function buildResendComparison() { + const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) + const segmentId = process.env.RESEND_SEGMENT_ID || null + const appSubs = getAppSubscribers() + const resendContacts = await listAllResendContacts(resend) + let segmentEmails = null + if (segmentId) { + await sleep(550) + segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase())) + } + + const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c])) + const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ') + + const onlyInApp = [] + const unsubscribedInResend = [] + const notInSegment = [] + let matched = 0 + for (const sub of appSubs.values()) { + const contact = resendByEmail.get(sub.email) + if (!contact) { onlyInApp.push(sub); continue } + if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue } + if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue } + matched += 1 + } + const onlyInResend = resendContacts + .filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed) + .map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() })) + + return { + appCount: appSubs.size, + resendCount: resendContacts.length, + resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length, + segmentConfigured: Boolean(segmentId), + segmentCount: segmentEmails ? segmentEmails.size : null, + matched, + onlyInApp, + onlyInResend, + unsubscribedInResend, + notInSegment, + } + } + + app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => { + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + try { + res.json(await buildResendComparison()) + } catch (err) { + console.error('[subscribers] resend compare failed:', err) + res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' }) + } + }) + + app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => { + const { action } = req.body ?? {} + if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) { + res.status(400).json({ message: 'Unknown action.' }); return + } + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + try { + const diff = await buildResendComparison() + + if (action === 'apply-unsubscribes') { + const emails = new Set(diff.unsubscribedInResend.map(s => s.email)) + let changed = 0 + state.contactSubmissions = state.contactSubmissions.map(entry => { + if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) { + changed += 1 + return { ...entry, subscribe: false } + } + return entry + }) + if (changed > 0) queueContactSubmissionsWrite() + res.json({ ok: true, done: emails.size, failed: 0 }); return + } + + const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) + const segmentId = process.env.RESEND_SEGMENT_ID || null + const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100) + if (action === 'add-to-segment' && !segmentId) { + res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return + } + + let done = 0 + let failed = 0 + for (const target of targets) { + try { + if (action === 'add-to-resend') { + const { firstName, lastName } = splitName(target.name) + const { error } = await resend.contacts.create({ + email: target.email, firstName, lastName, unsubscribed: false, + ...(segmentId ? { segments: [{ id: segmentId }] } : {}), + }) + if (error) throw new Error(error.message) + } else { + const { error } = await resend.contacts.segments.add({ email: target.email, segmentId }) + if (error) throw new Error(error.message) + } + done += 1 + } catch (err) { + failed += 1 + console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err) + } + await sleep(550) + } + const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length + res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) }) + } catch (err) { + console.error('[subscribers] resend sync failed:', err) + res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' }) + } + }) + app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => { if (!process.env.RESEND_API_KEY) { res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 1b264fd..5cd0c0d 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -662,6 +662,19 @@ interface Subscriber { } +interface ResendComparison { + appCount: number + resendCount: number + resendUnsubscribedCount: number + segmentConfigured: boolean + segmentCount: number | null + matched: number + onlyInApp: Array<{ name: string; email: string }> + onlyInResend: Array<{ name: string; email: string }> + unsubscribedInResend: Array<{ name: string; email: string }> + notInSegment: Array<{ name: string; email: string }> +} + type StringField = Exclude type AdminView = @@ -1583,6 +1596,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [mobileNavOpen, setMobileNavOpen] = useState(false) const [navSearch, setNavSearch] = useState('') const [subscribers, setSubscribers] = useState([]) + const [resendCmp, setResendCmp] = useState(null) + const [resendCmpLoading, setResendCmpLoading] = useState(false) + const [resendCmpBusy, setResendCmpBusy] = useState(false) + const [resendCmpMsg, setResendCmpMsg] = useState('') const [subscriberSearch, setSubscriberSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) // Study comments moderation state @@ -3495,12 +3512,92 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) || s.email.toLowerCase().includes(subscriberSearch.toLowerCase()) ) + async function runResendCompare() { + setResendCmpLoading(true); setResendCmpMsg('') + try { + const r = await fetch('/api/admin-subscribers/resend-compare') + const d = await r.json() + if (!r.ok) setResendCmpMsg(d.message || 'Comparison failed.') + else setResendCmp(d as ResendComparison) + } catch { setResendCmpMsg('Network error. Try again.') } + finally { setResendCmpLoading(false) } + } + async function runResendSync(action: 'add-to-resend' | 'add-to-segment' | 'apply-unsubscribes', prompt: string) { + if (!confirm(prompt)) return + setResendCmpBusy(true); setResendCmpMsg('') + try { + const r = await fetch('/api/admin-subscribers/resend-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }) + const d = await r.json() + if (!r.ok) { setResendCmpMsg(d.message || 'Sync failed.'); return } + setResendCmpMsg(`Done: ${d.done} updated${d.failed ? `, ${d.failed} failed` : ''}${d.remaining ? `, ${d.remaining} more — run the action again` : ''}.`) + const subsRes = await fetch('/api/admin-subscribers') + if (subsRes.ok) setSubscribers(((await subsRes.json()) as { subscribers: Subscriber[] }).subscribers ?? []) + await runResendCompare() + } catch { setResendCmpMsg('Network error. Try again.') } + finally { setResendCmpBusy(false) } + } + const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode) => ( +
0 && rows.length <= 10}> + + {rows.length} + {title} + +

{note}

+ {rows.length > 0 && ( +
    + {rows.slice(0, 200).map(r =>
  • {r.name || '—'}{r.email}
  • )} + {rows.length > 200 &&
  • …and {rows.length - 200} more
  • } +
+ )} + {rows.length > 0 && action} +
+ ) return (

Subscribers

{subscribers.length} people have opted in to email updates.

+ +
+
+
+ Compare with Resend +

Checks that everyone who opted in here is also in your Resend audience, and that nobody who unsubscribed there is still active here.

+
+ +
+ {resendCmpMsg &&

{resendCmpMsg}

} + {resendCmp && (() => { + const c = resendCmp + const inSync = c.onlyInApp.length === 0 && c.unsubscribedInResend.length === 0 && c.notInSegment.length === 0 + return ( + <> +
+
{c.appCount}Subscribers here
+
{c.resendCount}Contacts in Resend{c.resendUnsubscribedCount ? ` (${c.resendUnsubscribedCount} unsubscribed)` : ''}
+ {c.segmentCount !== null &&
{c.segmentCount}In your Resend segment
} +
{c.matched}Fully matched
+
+ {inSync &&

✓ Every subscriber here is in Resend and active. {c.onlyInResend.length > 0 ? `Resend also has ${c.onlyInResend.length} other active contact${c.onlyInResend.length === 1 ? '' : 's'} (see below).` : ''}

} + {!c.segmentConfigured &&

Note: RESEND_SEGMENT_ID isn’t set on the server, so segment membership wasn’t checked and new contacts aren’t added to a segment.

} + {renderCmpList('Subscribed here, missing from Resend', 'These people opted in on the site but never made it into Resend, so Resend broadcasts won’t reach them.', c.onlyInApp, + )} + {renderCmpList('Unsubscribed in Resend, still active here', 'They opted out through Resend. Marking them unsubscribed here stops the in-app Broadcast Email from sending to them.', c.unsubscribedInResend, + )} + {c.segmentConfigured && renderCmpList('In Resend, but not in your segment', 'These subscribers exist in Resend but aren’t in the segment your broadcasts use.', c.notInSegment, + )} + {renderCmpList('In Resend, not subscribed here', 'Informational only. These are usually study-account signups, or people who opted in before the site tracked it. Nothing is changed for them.', c.onlyInResend)} + + ) + })()} +
div { flex: 1 1 320px; min-width: 0; } +.cmp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin: 0.9rem 0; } +.cmp-stats > div { background: #151209; border: 1px solid rgba(201, 168, 76, 0.15); border-radius: 8px; padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.15rem; } +.cmp-stats b { font-size: 1.4rem; color: var(--brand-gold); } +.cmp-stats span { font-size: 0.75rem; opacity: 0.75; } +.cmp-ok { color: #7fd48a; font-size: 0.9rem; } +.cmp-note { font-size: 0.8rem; opacity: 0.7; margin: 0.4rem 0; } +.cmp-group { border-top: 1px solid rgba(201, 168, 76, 0.14); padding: 0.6rem 0; } +.cmp-group summary { cursor: pointer; display: flex; align-items: center; gap: 0.6rem; } +.cmp-count { background: rgba(201, 168, 76, 0.2); color: var(--brand-gold); border-radius: 999px; min-width: 1.8rem; text-align: center; padding: 0.1rem 0.5rem; font-weight: 600; font-size: 0.85rem; } +.cmp-count.is-zero { background: rgba(127, 212, 138, 0.15); color: #7fd48a; } +.cmp-title { font-size: 0.92rem; } +.cmp-rows { list-style: none; margin: 0.4rem 0 0.7rem; padding: 0; max-height: 260px; overflow-y: auto; font-size: 0.82rem; } +.cmp-rows li { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); overflow-wrap: anywhere; } +.cmp-rows li span { min-width: 140px; opacity: 0.85; } +.cmp-more { opacity: 0.6; }