Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s
Publish Container / build (push) Successful in 16m39s
- Asset Manager: search/filter/sort, list+grid views, rename, move between R2 folders, tag chips, bulk actions, used-in counts; fix R2 delete lookup - Inbox: readable wrapped rows with subject/preview, real read state, type filters, bulk actions, mojibake repair - Broadcast: pick a Resend template to fill subject and body Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4931bfab4d
commit
e74bd1df5d
10 files changed
+1247
-417
No files matched your search
@@ -353,24 +353,52 @@ export function register(app) {
|
||||
|
||||
|
||||
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||
res.json({ submissions: state.contactSubmissions })
|
||||
})
|
||||
|
||||
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
const { archived } = req.body ?? {}
|
||||
if (typeof archived !== 'boolean') {
|
||||
res.status(400).json({ message: 'archived must be a boolean.' }); return
|
||||
const { archived, read } = req.body ?? {}
|
||||
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
|
||||
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
|
||||
}
|
||||
const idx = state.contactSubmissions.findIndex(s => s.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], archived }
|
||||
const patch = {}
|
||||
if (typeof archived === 'boolean') patch.archived = archived
|
||||
if (typeof read === 'boolean') patch.read = read
|
||||
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, submission: state.contactSubmissions[idx] })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
|
||||
const { ids, action } = req.body ?? {}
|
||||
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
|
||||
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
|
||||
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
|
||||
}
|
||||
const idSet = new Set(ids)
|
||||
let count = 0
|
||||
if (action === 'delete') {
|
||||
const before = state.contactSubmissions.length
|
||||
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
|
||||
count = before - state.contactSubmissions.length
|
||||
} else {
|
||||
state.contactSubmissions = state.contactSubmissions.map(s => {
|
||||
if (!idSet.has(s.id)) return s
|
||||
count += 1
|
||||
if (action === 'archive') return { ...s, archived: true }
|
||||
if (action === 'unarchive') return { ...s, archived: false }
|
||||
return { ...s, read: action === 'read' }
|
||||
})
|
||||
}
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, count })
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const subscribers = state.contactSubmissions
|
||||
@@ -401,6 +429,64 @@ export function register(app) {
|
||||
res.send(csv)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const templates = []
|
||||
let after
|
||||
for (let page = 0; page < 5; page += 1) {
|
||||
const { data, error } = await resend.templates.list({ limit: 100, ...(after ? { after } : {}) })
|
||||
if (error) throw new Error(error.message)
|
||||
templates.push(...(data?.data ?? []))
|
||||
if (!data?.has_more || !data.data?.length) break
|
||||
after = data.data[data.data.length - 1].id
|
||||
}
|
||||
res.json({
|
||||
templates: templates
|
||||
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
|
||||
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] template list failed:', err)
|
||||
res.status(502).json({ message: 'Could not load templates from Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data, error } = await resend.templates.get(req.params.id)
|
||||
if (error || !data) {
|
||||
res.status(404).json({ message: error?.message || 'Template not found.' }); return
|
||||
}
|
||||
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
|
||||
let html = data.html ?? ''
|
||||
for (const v of data.variables ?? []) {
|
||||
if (/^(first_?name|name)$/i.test(v.key)) continue
|
||||
if (v.fallback_value === null || v.fallback_value === undefined) continue
|
||||
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
|
||||
}
|
||||
res.json({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subject: data.subject ?? '',
|
||||
html,
|
||||
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
|
||||
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
|
||||
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] template get failed:', err)
|
||||
res.status(502).json({ message: 'Could not load that template from Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html } = req.body ?? {}
|
||||
if (!subject || typeof subject !== 'string' || !subject.trim()) {
|
||||
@@ -436,7 +522,7 @@ export function register(app) {
|
||||
to: r.email,
|
||||
reply_to: replyTo,
|
||||
subject: subject.trim(),
|
||||
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')),
|
||||
html: html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, escapeHtml(r.name || 'Friend')),
|
||||
}))
|
||||
try {
|
||||
const { error } = await resend.batch.send(emails)
|
||||
|
||||
Reference in new issue
Block a user