// Account settings: profile, reminder settings, test notification, and the full data export. import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js'; import { requireAuth } from '../auth.js'; import { votdAvailable } from '../votd.js'; import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js'; import { accountExportName, buildAccountExport } from '../accountExport.js'; export function registerAccountRoutes(app) { // --------------------------------------------------------------------------- // PATCH /api/auth/profile — update account-level settings (currently just podcastName) // --------------------------------------------------------------------------- app.patch('/api/auth/profile', requireAuth, (req, res) => { try { const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200); setPodcastName(req.session.userId, podcastName || null); res.json({ podcastName: podcastName || null }); } catch (err) { console.error('PATCH /api/auth/profile error:', err); res.status(500).json({ error: 'Failed to save profile.' }); } }); // --------------------------------------------------------------------------- // PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh // --------------------------------------------------------------------------- app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { try { const reminderEnabled = !!req.body?.reminderEnabled; const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim(); const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim(); const reminderTime = String(req.body?.reminderTime ?? '').trim(); const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null; if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); } if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); } if (ntfyUrl) { try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); } } if (reminderEnabled) { if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' }); if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) { return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' }); } } // the verse of the day is optional, and only offered when the server can look it up const votdEnabled = !!req.body?.votdEnabled && votdAvailable(); const votdTime = String(req.body?.votdTime ?? '').trim(); if (votdEnabled) { if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' }); if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' }); } setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null }); const prayerEnabled = !!req.body?.prayerEnabled; const prayerTime = String(req.body?.prayerTime ?? '').trim(); if (prayerEnabled) { if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' }); if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' }); } setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null }); setReminderSettings(req.session.userId, { ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, reminderEnabled, reminderTz, }); res.json({ reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null, }); } catch (err) { console.error('PATCH /api/auth/reminder-settings error:', err); res.status(500).json({ error: 'Failed to save reminder settings.' }); } }); // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // GET /api/account/export — everything this account holds, as one restorable file // --------------------------------------------------------------------------- app.get('/api/account/export', requireAuth, (req, res) => { try { const userId = req.session.userId; const user = getUserById(userId); const backup = buildAccountExport(user, { projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean), prefs: () => getUserPrefs(userId), marks: () => getReaderMarks(userId), ink: () => getAllReaderInk(userId), plans: () => getReadingProgress(userId), history: () => getReaderHistory(userId), prayers: () => getPrayers(userId).filter((p) => !p.deleted), }); res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); res.json(backup); } catch (err) { console.error('GET /api/account/export error:', err); res.status(500).json({ error: 'Failed to export your data.' }); } }); // POST /api/auth/reminder-test — sends a test notification to the topic in the request // body (so it can be tried before saving), or to the saved settings. const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed app.post('/api/auth/reminder-test', requireAuth, async (req, res) => { const userId = req.session.userId; if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) { return res.status(429).json({ error: 'Wait a few seconds before sending another test.' }); } _lastReminderTest.set(userId, Date.now()); const saved = getUserById(userId); const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim(); const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim(); if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' }); if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); } if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); } try { await sendTestReminder({ ntfyTopic, ntfyUrl }); res.json({ ok: true }); } catch (err) { res.status(502).json({ error: err.message }); } }); }