Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
Publish Container / build (push) Successful in 16m27s
Publish Container / build (push) Successful in 16m27s
- Subscribers: compare in-app list with Resend audience/segment, with one-click fixes - syncContactToResend no longer resets unsubscribed on existing contacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e74bd1df5d
commit
af967d0049
5 files changed
+273
-6
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+10
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||
|
||||
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<Subscriber[]>([])
|
||||
const [resendCmp, setResendCmp] = useState<ResendComparison | null>(null)
|
||||
const [resendCmpLoading, setResendCmpLoading] = useState(false)
|
||||
const [resendCmpBusy, setResendCmpBusy] = useState(false)
|
||||
const [resendCmpMsg, setResendCmpMsg] = useState('')
|
||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||
// 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) => (
|
||||
<details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
|
||||
<summary>
|
||||
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span>
|
||||
<span className="cmp-title">{title}</span>
|
||||
</summary>
|
||||
<p className="cmp-note">{note}</p>
|
||||
{rows.length > 0 && (
|
||||
<ul className="cmp-rows">
|
||||
{rows.slice(0, 200).map(r => <li key={r.email}><span>{r.name || '—'}</span><a href={`mailto:${r.email}`}>{r.email}</a></li>)}
|
||||
{rows.length > 200 && <li className="cmp-more">…and {rows.length - 200} more</li>}
|
||||
</ul>
|
||||
)}
|
||||
{rows.length > 0 && action}
|
||||
</details>
|
||||
)
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Subscribers</h2>
|
||||
<p>{subscribers.length} people have opted in to email updates.</p>
|
||||
</div>
|
||||
|
||||
<div className="cmp-card">
|
||||
<div className="cmp-head">
|
||||
<div>
|
||||
<strong>Compare with Resend</strong>
|
||||
<p className="cmp-note" style={{ margin: '0.2rem 0 0' }}>Checks that everyone who opted in here is also in your Resend audience, and that nobody who unsubscribed there is still active here.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-reset" disabled={resendCmpLoading || resendCmpBusy} onClick={() => void runResendCompare()}>
|
||||
{resendCmpLoading ? 'Checking Resend…' : resendCmp ? 'Re-check' : 'Run comparison'}
|
||||
</button>
|
||||
</div>
|
||||
{resendCmpMsg && <p className="admin-stats-note" role="status">{resendCmpMsg}</p>}
|
||||
{resendCmp && (() => {
|
||||
const c = resendCmp
|
||||
const inSync = c.onlyInApp.length === 0 && c.unsubscribedInResend.length === 0 && c.notInSegment.length === 0
|
||||
return (
|
||||
<>
|
||||
<div className="cmp-stats">
|
||||
<div><b>{c.appCount}</b><span>Subscribers here</span></div>
|
||||
<div><b>{c.resendCount}</b><span>Contacts in Resend{c.resendUnsubscribedCount ? ` (${c.resendUnsubscribedCount} unsubscribed)` : ''}</span></div>
|
||||
{c.segmentCount !== null && <div><b>{c.segmentCount}</b><span>In your Resend segment</span></div>}
|
||||
<div><b>{c.matched}</b><span>Fully matched</span></div>
|
||||
</div>
|
||||
{inSync && <p className="cmp-ok">✓ 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).` : ''}</p>}
|
||||
{!c.segmentConfigured && <p className="cmp-note">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.</p>}
|
||||
{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,
|
||||
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-resend', `Add ${c.onlyInApp.length} subscriber${c.onlyInApp.length === 1 ? '' : 's'} to Resend? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.onlyInApp.length, 100)} to Resend`}</button>)}
|
||||
{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,
|
||||
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('apply-unsubscribes', `Mark ${c.unsubscribedInResend.length} person${c.unsubscribedInResend.length === 1 ? '' : 's'} as unsubscribed here? They will be removed from the subscriber list and future broadcasts.`)}>{resendCmpBusy ? 'Working…' : 'Mark unsubscribed here'}</button>)}
|
||||
{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,
|
||||
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-segment', `Add ${c.notInSegment.length} contact${c.notInSegment.length === 1 ? '' : 's'} to the Resend segment? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.notInSegment.length, 100)} to segment`}</button>)}
|
||||
{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>
|
||||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="search"
|
||||
|
||||
+20
@@ -12969,3 +12969,23 @@
|
||||
.ib-layout.has-open { grid-template-columns: minmax(0, 1fr); }
|
||||
.ib-detail { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
/* ── Subscribers ⇄ Resend comparison ── */
|
||||
.cmp-card { background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 12px; padding: 1rem; margin-bottom: 1.2rem; }
|
||||
.cmp-head { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.cmp-head > 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; }
|
||||
Reference in new issue
Block a user