Add contacts features: tags, history, CSV import/export, merge, last-contacted; fix CalendarPage unused var; v1.1.23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -1113,7 +1113,11 @@ function sanitizeLoadedContactSubmissions(value) {
|
||||
snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null,
|
||||
threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(),
|
||||
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
|
||||
source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form',
|
||||
source: ['inbound-email', 'download', 'manual'].includes(entry.source) ? entry.source : 'contact-form',
|
||||
notes: typeof entry.notes === 'string' ? entry.notes.trim().slice(0, 2000) : '',
|
||||
tags: Array.isArray(entry.tags)
|
||||
? [...new Set(entry.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
|
||||
: [],
|
||||
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
|
||||
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
|
||||
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
|
||||
|
||||
@@ -442,6 +442,9 @@ export function register(app) {
|
||||
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
|
||||
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
||||
if (Array.isArray(req.body?.tags)) {
|
||||
patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
|
||||
}
|
||||
if ('snoozedUntil' in (req.body ?? {})) {
|
||||
const v = req.body.snoozedUntil
|
||||
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
|
||||
@@ -507,6 +510,65 @@ export function register(app) {
|
||||
res.json({ ok: true, affected })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => {
|
||||
const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : ''
|
||||
const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : ''
|
||||
if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) {
|
||||
res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return
|
||||
}
|
||||
let affected = 0
|
||||
state.contactSubmissions = state.contactSubmissions.map(s => {
|
||||
if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s
|
||||
affected++
|
||||
return { ...s, email: keepEmail }
|
||||
})
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, affected })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => {
|
||||
const rows = req.body?.rows
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ message: 'rows must be a non-empty array.' }); return
|
||||
}
|
||||
const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
const toAdd = []
|
||||
for (const row of rows.slice(0, 1000)) {
|
||||
const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : ''
|
||||
const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : ''
|
||||
const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : ''
|
||||
const tags = Array.isArray(row.tags)
|
||||
? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20)
|
||||
: (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : [])
|
||||
if (!name && !email) { skipped++; continue }
|
||||
if (email && !EMAIL_RE.test(email)) { skipped++; continue }
|
||||
toAdd.push({ name, email, notes, tags })
|
||||
}
|
||||
for (const row of toAdd) {
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: false,
|
||||
archived: false,
|
||||
source: 'manual',
|
||||
notes: row.notes,
|
||||
tags: row.tags,
|
||||
emailStatus: normalizeContactEmailStatus(null, false),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
created++
|
||||
}
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
if (created > 0) queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, created, skipped })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
|
||||
const { id, attachmentId } = req.params
|
||||
const submission = state.contactSubmissions.find(s => s.id === id)
|
||||
|
||||
Reference in New Issue
Block a user