diff --git a/server/api/service/roadmap-suggestions.post.ts b/server/api/service/roadmap-suggestions.post.ts index 2ca0108..3c3b969 100644 --- a/server/api/service/roadmap-suggestions.post.ts +++ b/server/api/service/roadmap-suggestions.post.ts @@ -44,14 +44,18 @@ export default defineEventHandler(async (event) => { consentPrivacy: true, }) - const lines = [ - `Titel: ${title}`, - `Beschreibung: ${details}`, - `Kontakt: ${email || '—'}`, - allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht', + const dataEntries = [ + ['Titel', title], + ['Beschreibung', details], + ['E-Mail', email || null], + ['Kontaktaufnahme erlaubt', allowContact], ] - await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n')) + await sendAdminNotification({ + event: 'Neuer Roadmap-Vorschlag', + summary: `Neuer Roadmap-Vorschlag: ${title}`, + data: dataEntries, + }) return { success: true, diff --git a/server/api/service/updates.post.ts b/server/api/service/updates.post.ts index a3fcf97..c23201b 100644 --- a/server/api/service/updates.post.ts +++ b/server/api/service/updates.post.ts @@ -37,12 +37,16 @@ export default defineEventHandler(async (event) => { }) if (result.created) { - const lines = [ - `E-Mail: ${email}`, - name ? `Name: ${name}` : null, - `Quelle: ${source}`, - ].filter(Boolean) - await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n')) + const dataEntries = [ + ['E-Mail', email], + ['Name', name || null], + ['Quelle', source], + ] + await sendAdminNotification({ + event: 'Neue Updates-Liste', + summary: `Neue Updates-Anmeldung: ${email}`, + data: dataEntries, + }) } return { diff --git a/server/api/service/waitlist.post.ts b/server/api/service/waitlist.post.ts index f46b06f..95bab2a 100644 --- a/server/api/service/waitlist.post.ts +++ b/server/api/service/waitlist.post.ts @@ -56,16 +56,23 @@ export default defineEventHandler(async (event) => { }) if (!previouslyWantedUpdates && updateResult.created) { - const lines = [ - `E-Mail: ${email}`, - name ? `Name: ${name}` : null, - notes ? `Notizen: ${notes}` : null, - `Quelle: ${source}`, - ].filter(Boolean) - await sendAdminNotification( - '[OpenSquawk] Neue Updates-Liste (via Warteliste)', - `${lines.join('\n')}\nOpt-In: Produkt-Updates`, - ) + const dataEntries = [ + ['E-Mail', email], + ] + if (name) { + dataEntries.push(['Name', name]) + } + if (notes) { + dataEntries.push(['Notizen', notes]) + } + dataEntries.push(['Quelle', source]) + dataEntries.push(['Opt-In', 'Produkt-Updates']) + + await sendAdminNotification({ + event: 'Neue Updates-Liste (via Warteliste)', + summary: `Produkt-Updates Opt-in (Warteliste): ${email}`, + data: dataEntries, + }) } } @@ -98,14 +105,23 @@ export default defineEventHandler(async (event) => { }) } - const lines = [ - `E-Mail: ${email}`, - name ? `Name: ${name}` : null, - notes ? `Notizen: ${notes}` : null, - `Quelle: ${source}`, - wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste', - ].filter(Boolean) - await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n')) + const dataEntries = [ + ['E-Mail', email], + ] + if (name) { + dataEntries.push(['Name', name]) + } + if (notes) { + dataEntries.push(['Notizen', notes]) + } + dataEntries.push(['Quelle', source]) + dataEntries.push(['Opt-In', wantsProductUpdates ? 'Produkt-Updates' : 'Nur Warteliste']) + + await sendAdminNotification({ + event: 'Neue Wartelisten-Anmeldung', + summary: `Neue Wartelisten-Anmeldung: ${email}`, + data: dataEntries, + }) return { success: true, diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts index 6115421..2067c5f 100644 --- a/server/utils/notifications.ts +++ b/server/utils/notifications.ts @@ -1,6 +1,23 @@ const ADMIN_EMAIL_FALLBACK = 'info@opensquawk.de' const RESEND_ENDPOINT = 'https://api.resend.com/emails' +const ADMIN_NOTIFICATION_SUBJECT_PREFIX = process.env.NOTIFY_SUBJECT_PREFIX || '[OpenSquawk Web]' + +type NotificationPrimitive = string | number | boolean | Date | null | undefined +type NotificationValue = + | NotificationPrimitive + | NotificationPrimitive[] + | Record + +type NotificationData = Record | Array<[string, NotificationValue]> + +export interface AdminNotificationOptions { + event: string + summary?: string + data?: NotificationData + to?: string + from?: string +} interface MailOptions { to: string @@ -122,11 +139,116 @@ export async function sendMail(options: MailOptions) { return success } -export async function sendAdminNotification(subject: string, text: string) { - const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK - const success = await sendMailInternal({ to, subject, text }) +function resolveNotificationSubject(event: string) { + const trimmedEvent = event.trim() + if (!trimmedEvent) { + return ADMIN_NOTIFICATION_SUBJECT_PREFIX + } + + if (trimmedEvent.toLowerCase().startsWith(ADMIN_NOTIFICATION_SUBJECT_PREFIX.toLowerCase())) { + return trimmedEvent + } + + return `${ADMIN_NOTIFICATION_SUBJECT_PREFIX} ${trimmedEvent}` +} + +function toNotificationEntries(data?: NotificationData): Array<[string, NotificationValue]> { + if (!data) { + return [] + } + + if (Array.isArray(data)) { + return data + } + + return Object.entries(data) +} + +function formatNotificationValue(value: NotificationValue): string { + if (value === null || value === undefined) { + return '—' + } + + if (Array.isArray(value)) { + const formatted = value + .map((entry) => formatNotificationValue(entry)) + .filter((entry) => entry && entry !== '—') + return formatted.length ? formatted.join(', ') : '—' + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (typeof value === 'object') { + return JSON.stringify(value, null, 2) + } + + if (typeof value === 'boolean') { + return value ? 'Ja' : 'Nein' + } + + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed ? trimmed : '—' + } + + return String(value) +} + +function formatDataEntries(entries: Array<[string, NotificationValue]>) { + return entries.map(([label, rawValue]) => { + const formattedValue = formatNotificationValue(rawValue) + if (formattedValue.includes('\n')) { + const indented = formattedValue + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + return `• ${label}:\n${indented}` + } + + return `• ${label}: ${formattedValue}` + }) +} + +function buildAdminNotificationText(options: AdminNotificationOptions) { + const lines: string[] = [] + const summary = options.summary?.trim() + if (summary) { + lines.push(summary) + } else { + lines.push(`Neue Aktivität auf der Website: ${options.event}`) + } + + const dataEntries = formatDataEntries([ + ['Ereignis', options.event], + ...toNotificationEntries(options.data), + ]) + + if (dataEntries.length) { + lines.push('') + lines.push('Details:') + lines.push(...dataEntries) + } + + return lines.join('\n') +} + +export async function sendAdminNotification(options: AdminNotificationOptions) { + const to = options.to || process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK + const subject = resolveNotificationSubject(options.event) + const text = buildAdminNotificationText(options) + + const success = await sendMailInternal({ + to, + subject, + text, + from: options.from, + }) + if (!success) { console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`) } + return success }