Adding Birthday tracking feature - to compete with FamilyWall
This commit is contained in:
@@ -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 +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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user