Adding Birthday tracking feature - to compete with FamilyWall
This commit is contained in:
@@ -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);
|
||||
`,
|
||||
|
||||
@@ -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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user