Adding Birthday tracking feature - to compete with FamilyWall

This commit is contained in:
Rafael Foster
2026-04-26 07:36:53 -03:00
parent a1b1a71227
commit 394b4ea84e
30 changed files with 1518 additions and 14 deletions
+159
View File
@@ -0,0 +1,159 @@
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;
+8 -6
View File
@@ -8,6 +8,7 @@ import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import * as v from '../middleware/validate.js';
import { syncAllBirthdayReminders } from '../services/birthdays.js';
const log = createLogger('Reminders');
const router = express.Router();
@@ -22,8 +23,9 @@ const VALID_ENTITY_TYPES = ['task', 'event'];
// --------------------------------------------------------
router.get('/pending', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const now = new Date().toISOString();
syncAllBirthdayReminders(db.get(), userId, new Date());
const rows = db.get().prepare(`
SELECT
@@ -53,7 +55,7 @@ router.get('/pending', (req, res) => {
// --------------------------------------------------------
router.get('/', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const entityType = req.query.entity_type;
const entityId = parseInt(req.query.entity_id, 10);
@@ -82,7 +84,7 @@ router.get('/', (req, res) => {
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const { entity_type, entity_id, remind_at } = req.body;
const errors = v.collectErrors([
@@ -127,7 +129,7 @@ router.post('/', (req, res) => {
// --------------------------------------------------------
router.patch('/:id/dismiss', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const reminderId = parseInt(req.params.id, 10);
if (!reminderId) {
@@ -157,7 +159,7 @@ router.patch('/:id/dismiss', (req, res) => {
// --------------------------------------------------------
router.delete('/:id', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const reminderId = parseInt(req.params.id, 10);
if (!reminderId) {
@@ -187,7 +189,7 @@ router.delete('/:id', (req, res) => {
// --------------------------------------------------------
router.delete('/', (req, res) => {
try {
const userId = req.session.userId;
const userId = req.authUserId || req.session.userId;
const entityType = req.query.entity_type;
const entityId = parseInt(req.query.entity_id, 10);