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
+18
View File
@@ -116,6 +116,17 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS birthdays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
birth_date TEXT NOT NULL,
notes TEXT,
photo_data TEXT,
calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
@@ -182,6 +193,9 @@ const MIGRATIONS_SQL = {
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
AFTER UPDATE ON contacts FOR EACH ROW
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at
AFTER UPDATE ON birthdays FOR EACH ROW
BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
AFTER UPDATE ON budget_entries FOR EACH ROW
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
@@ -196,6 +210,10 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name);
CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date);
CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by);
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
+26
View File
@@ -691,6 +691,32 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
},
{
version: 18,
description: 'Birthdays with calendar integration',
up: `
CREATE TABLE IF NOT EXISTS birthdays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
birth_date TEXT NOT NULL,
notes TEXT,
photo_data TEXT,
calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at
AFTER UPDATE ON birthdays FOR EACH ROW
BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name);
CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date);
CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by);
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
`,
},
];
/**
+2
View File
@@ -25,6 +25,7 @@ import recipesRouter from './routes/recipes.js';
import calendarRouter from './routes/calendar.js';
import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
@@ -191,6 +192,7 @@ app.use('/api/v1/recipes', recipesRouter);
app.use('/api/v1/calendar', calendarRouter);
app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
+15
View File
@@ -370,6 +370,20 @@ function buildPaths() {
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
'/api/v1/birthdays': {
get: op({ summary: 'List birthdays', tag: 'Birthdays' }),
post: op({ summary: 'Create birthday', tag: 'Birthdays', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/birthdays/upcoming': {
get: op({ summary: 'List upcoming birthdays', tag: 'Birthdays' }),
},
'/api/v1/birthdays/meta/options': {
get: op({ summary: 'Get birthday upload options', tag: 'Birthdays' }),
},
'/api/v1/birthdays/{id}': {
put: op({ summary: 'Update birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true }),
},
'/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) },
'/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) },
'/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) },
@@ -437,6 +451,7 @@ function buildOpenApiSpec(req, appVersion) {
{ name: 'Calendar' },
{ name: 'Notes' },
{ name: 'Contacts' },
{ name: 'Birthdays' },
{ name: 'Budget' },
{ name: 'Weather' },
{ name: 'Preferences' },
+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);
+201
View File
@@ -0,0 +1,201 @@
const BIRTHDAY_COLOR = '#E11D48';
const BIRTHDAY_RRULE = 'FREQ=YEARLY;INTERVAL=1';
function pad2(n) {
return String(n).padStart(2, '0');
}
function leapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function normalizedMonthDay(birthDate, year) {
const [, monthStr, dayStr] = String(birthDate).split('-');
const month = parseInt(monthStr, 10);
let day = parseInt(dayStr, 10);
if (month === 2 && day === 29 && !leapYear(year)) day = 28;
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function nextBirthdayDate(birthDate, from = new Date()) {
const now = from instanceof Date ? from : new Date(from);
const thisYear = normalizedMonthDay(birthDate, now.getFullYear());
const today = now.toISOString().slice(0, 10);
return thisYear >= today
? thisYear
: normalizedMonthDay(birthDate, now.getFullYear() + 1);
}
function nextBirthdayAge(birthDate, from = new Date()) {
const next = nextBirthdayDate(birthDate, from);
return parseInt(next.slice(0, 4), 10) - parseInt(String(birthDate).slice(0, 4), 10);
}
function daysUntilBirthday(birthDate, from = new Date()) {
const now = from instanceof Date ? from : new Date(from);
const next = nextBirthdayDate(birthDate, now);
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const nextUtc = Date.UTC(
parseInt(next.slice(0, 4), 10),
parseInt(next.slice(5, 7), 10) - 1,
parseInt(next.slice(8, 10), 10),
);
return Math.round((nextUtc - todayUtc) / 86400000);
}
function birthdayReminderAt(birthDate, from = new Date()) {
const next = nextBirthdayDate(birthDate, from);
return `${next}T12:00:00Z`;
}
function eventTitle(name) {
return `Birthday: ${name}`;
}
function eventDescription(name, birthDate) {
return `Birthday reminder for ${name} (${birthDate}).`;
}
function syncBirthdayCalendarEvent(database, birthday) {
const payload = {
title: eventTitle(birthday.name),
description: eventDescription(birthday.name, birthday.birth_date),
start_datetime: birthday.birth_date,
end_datetime: null,
all_day: 1,
location: null,
color: BIRTHDAY_COLOR,
assigned_to: null,
recurrence_rule: BIRTHDAY_RRULE,
created_by: birthday.created_by,
};
if (birthday.calendar_event_id) {
const existing = database.prepare('SELECT id FROM calendar_events WHERE id = ?').get(birthday.calendar_event_id);
if (existing) {
database.prepare(`
UPDATE calendar_events
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?,
location = ?, color = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?,
external_source = 'local'
WHERE id = ?
`).run(
payload.title,
payload.description,
payload.start_datetime,
payload.end_datetime,
payload.all_day,
payload.location,
payload.color,
payload.assigned_to,
payload.recurrence_rule,
payload.created_by,
birthday.calendar_event_id,
);
return birthday.calendar_event_id;
}
}
const result = database.prepare(`
INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day, location, color,
assigned_to, created_by, recurrence_rule, external_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
`).run(
payload.title,
payload.description,
payload.start_datetime,
payload.end_datetime,
payload.all_day,
payload.location,
payload.color,
payload.assigned_to,
payload.created_by,
payload.recurrence_rule,
);
database.prepare('UPDATE birthdays SET calendar_event_id = ? WHERE id = ?')
.run(result.lastInsertRowid, birthday.id);
return result.lastInsertRowid;
}
function syncBirthdayReminder(database, birthday, from = new Date()) {
if (!birthday.calendar_event_id) return null;
const desired = birthdayReminderAt(birthday.birth_date, from);
const existing = database.prepare(`
SELECT * FROM reminders
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
ORDER BY created_at DESC
`).all(birthday.calendar_event_id, birthday.created_by);
const active = existing.find((row) => row.dismissed === 0);
if (active && active.remind_at === desired) return active.id;
database.prepare(`
DELETE FROM reminders
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
`).run(birthday.calendar_event_id, birthday.created_by);
const result = database.prepare(`
INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
VALUES ('event', ?, ?, ?)
`).run(birthday.calendar_event_id, desired, birthday.created_by);
return result.lastInsertRowid;
}
function syncBirthdayArtifacts(database, birthday, from = new Date()) {
const calendarEventId = syncBirthdayCalendarEvent(database, birthday);
const refreshed = { ...birthday, calendar_event_id: calendarEventId };
syncBirthdayReminder(database, refreshed, from);
return refreshed;
}
function deleteBirthdayArtifacts(database, birthday) {
if (birthday.calendar_event_id) {
database.prepare(`
DELETE FROM reminders
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
`).run(birthday.calendar_event_id, birthday.created_by);
database.prepare('DELETE FROM calendar_events WHERE id = ?').run(birthday.calendar_event_id);
}
}
function hydrateBirthday(row, from = new Date()) {
const next_birthday = nextBirthdayDate(row.birth_date, from);
return {
...row,
next_birthday,
next_age: nextBirthdayAge(row.birth_date, from),
days_until: daysUntilBirthday(row.birth_date, from),
};
}
function syncAllBirthdayReminders(database, userId, from = new Date()) {
const birthdays = database.prepare(`
SELECT * FROM birthdays WHERE created_by = ? ORDER BY birth_date ASC
`).all(userId);
birthdays.forEach((birthday) => {
const refreshed = birthday.calendar_event_id ? birthday : {
...birthday,
calendar_event_id: syncBirthdayCalendarEvent(database, birthday),
};
syncBirthdayReminder(database, refreshed, from);
});
}
export {
BIRTHDAY_COLOR,
BIRTHDAY_RRULE,
birthdayReminderAt,
daysUntilBirthday,
deleteBirthdayArtifacts,
eventDescription,
eventTitle,
hydrateBirthday,
nextBirthdayAge,
nextBirthdayDate,
syncAllBirthdayReminders,
syncBirthdayArtifacts,
};