import express from 'express'; import { createLogger } from '../logger.js'; import * as db from '../db.js'; import { collectErrors, date as validateDate, str, MAX_SHORT, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; import { deleteBirthdayArtifacts, hydrateBirthday, syncBirthdayArtifacts, syncAllBirthdayReminders } from '../services/birthdays.js'; const log = createLogger('Birthdays'); const router = express.Router(); const MAX_PHOTO_LENGTH = 900_000; const PHOTO_RE = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/; function validatePhotoData(val) { if (val === undefined) return { value: undefined, error: null }; if (val === null || val === '') return { value: null, error: null }; const s = String(val).trim(); if (s.length > MAX_PHOTO_LENGTH) return { value: null, error: 'Profile picture is too large.' }; if (!PHOTO_RE.test(s)) return { value: null, error: 'Profile picture must be a valid image data URL.' }; return { value: s, error: null }; } function loadBirthday(id) { return db.get().prepare('SELECT * FROM birthdays WHERE id = ?').get(id); } function loadBirthdayForUser(id, userId) { return db.get().prepare('SELECT * FROM birthdays WHERE id = ? AND created_by = ?').get(id, userId); } function sortHydrated(rows) { return rows .map((row) => hydrateBirthday(row)) .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name)); } router.get('/', (req, res) => { try { const userId = req.authUserId || req.session.userId; syncAllBirthdayReminders(db.get(), userId); let sql = 'SELECT * FROM birthdays WHERE created_by = ?'; const params = [userId]; if (req.query.q) { sql += ' AND name LIKE ?'; params.push(`%${String(req.query.q).trim()}%`); } sql += ' ORDER BY name COLLATE NOCASE ASC'; const rows = db.get().prepare(sql).all(...params); res.json({ data: sortHydrated(rows) }); } catch (err) { log.error('GET / error:', err); res.status(500).json({ error: 'Internal error.', code: 500 }); } }); router.get('/upcoming', (req, res) => { try { const userId = req.authUserId || req.session.userId; syncAllBirthdayReminders(db.get(), userId); const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 5, 1), 50); const rows = db.get().prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId); res.json({ data: sortHydrated(rows).slice(0, limit) }); } catch (err) { log.error('GET /upcoming error:', err); res.status(500).json({ error: 'Internal error.', code: 500 }); } }); router.post('/', (req, res) => { try { const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); const vBirthDate = validateDate(req.body.birth_date, 'Birth date', true); const vNotes = str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false }); const vPhoto = validatePhotoData(req.body.photo_data); const errors = collectErrors([vName, vBirthDate, vNotes, vPhoto]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const result = db.get().prepare(` INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by) VALUES (?, ?, ?, ?, ?) `).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId); const birthday = loadBirthday(result.lastInsertRowid); const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday)); res.status(201).json({ data: hydrateBirthday(loadBirthday(synced.id)) }); } catch (err) { log.error('POST / error:', err); res.status(500).json({ error: 'Internal error.', code: 500 }); } }); router.put('/:id', (req, res) => { try { const userId = req.authUserId || req.session.userId; const id = parseInt(req.params.id, 10); const existing = loadBirthdayForUser(id, userId); if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 }); const checks = []; if (req.body.name !== undefined) checks.push(str(req.body.name, 'Name', { max: MAX_TITLE, required: false })); if (req.body.birth_date !== undefined) checks.push(validateDate(req.body.birth_date, 'Birth date')); if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false })); if (req.body.photo_data !== undefined) checks.push(validatePhotoData(req.body.photo_data)); const errors = collectErrors(checks); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const vPhoto = req.body.photo_data !== undefined ? validatePhotoData(req.body.photo_data) : { value: undefined }; db.get().prepare(` UPDATE birthdays SET name = COALESCE(?, name), birth_date = COALESCE(?, birth_date), notes = ?, photo_data = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? `).run( req.body.name?.trim() ?? null, req.body.birth_date ?? null, req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes, req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data, id, ); const updated = loadBirthday(id); db.transaction(() => syncBirthdayArtifacts(db.get(), updated)); res.json({ data: hydrateBirthday(loadBirthday(id)) }); } catch (err) { log.error('PUT /:id error:', err); res.status(500).json({ error: 'Internal error.', code: 500 }); } }); router.delete('/:id', (req, res) => { try { const userId = req.authUserId || req.session.userId; const id = parseInt(req.params.id, 10); const existing = loadBirthdayForUser(id, userId); if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 }); db.transaction(() => { deleteBirthdayArtifacts(db.get(), existing); db.get().prepare('DELETE FROM birthdays WHERE id = ?').run(id); }); res.status(204).end(); } catch (err) { log.error('DELETE /:id error:', err); res.status(500).json({ error: 'Internal error.', code: 500 }); } }); router.get('/meta/options', (_req, res) => { res.json({ data: { photoMaxBytes: MAX_PHOTO_LENGTH, acceptedImageTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] } }); }); export default router;