BillTracker/services/calendarFeedService.js

359 lines
11 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { getDb } = require('../db/database');
const { normalizeCycleType, resolveDueDate } = require('./statusService');
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
const FEED_PAST_MONTHS = 12;
const FEED_FUTURE_MONTHS = 24;
const WEEKDAY_CODES = {
sunday: 'SU',
monday: 'MO',
tuesday: 'TU',
wednesday: 'WE',
thursday: 'TH',
friday: 'FR',
saturday: 'SA',
};
function ensureCalendarTokenSchema(db = getDb()) {
db.exec(`
CREATE TABLE IF NOT EXISTS calendar_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT,
active INTEGER NOT NULL DEFAULT 1,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1;
CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active);
`);
}
function generateToken() {
return crypto.randomBytes(32).toString('base64url');
}
function sanitizeLabel(label) {
const value = String(label || 'Bill Tracker Calendar').trim();
return value.slice(0, 80) || 'Bill Tracker Calendar';
}
function getActiveToken(userId, db = getDb()) {
ensureCalendarTokenSchema(db);
return db.prepare(`
SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at
FROM calendar_tokens
WHERE user_id = ? AND active = 1
ORDER BY created_at DESC, id DESC
LIMIT 1
`).get(userId) || null;
}
function createToken(userId, label = 'Bill Tracker Calendar', db = getDb()) {
ensureCalendarTokenSchema(db);
const token = generateToken();
const result = db.prepare(`
INSERT INTO calendar_tokens (user_id, token, label, active, created_at)
VALUES (?, ?, ?, 1, datetime('now'))
`).run(userId, token, sanitizeLabel(label));
return db.prepare(`
SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at
FROM calendar_tokens
WHERE id = ?
`).get(result.lastInsertRowid);
}
function getOrCreateToken(userId, db = getDb()) {
return getActiveToken(userId, db) || createToken(userId, 'Bill Tracker Calendar', db);
}
function regenerateToken(userId, db = getDb()) {
ensureCalendarTokenSchema(db);
let next;
db.transaction(() => {
db.prepare(`
UPDATE calendar_tokens
SET active = 0, revoked_at = datetime('now')
WHERE user_id = ? AND active = 1
`).run(userId);
next = createToken(userId, 'Bill Tracker Calendar', db);
})();
return next;
}
function revokeToken(userId, db = getDb()) {
ensureCalendarTokenSchema(db);
db.prepare(`
UPDATE calendar_tokens
SET active = 0, revoked_at = datetime('now')
WHERE user_id = ? AND active = 1
`).run(userId);
}
function getTokenRecord(token, db = getDb()) {
ensureCalendarTokenSchema(db);
if (!token || typeof token !== 'string') return null;
return db.prepare(`
SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at
FROM calendar_tokens
WHERE token = ? AND active = 1
`).get(token) || null;
}
function markTokenUsed(tokenId, db = getDb()) {
ensureCalendarTokenSchema(db);
db.prepare('UPDATE calendar_tokens SET last_used_at = datetime(\'now\') WHERE id = ?').run(tokenId);
}
function originFromRequest(req) {
const host = req.get?.('x-forwarded-host') || req.get?.('host') || 'localhost';
const proto = req.get?.('x-forwarded-proto') || req.protocol || 'http';
return `${String(proto).split(',')[0]}://${String(host).split(',')[0]}`;
}
function feedUrlForToken(req, token) {
return `${originFromRequest(req)}/api/calendar/feed.ics?token=${encodeURIComponent(token)}`;
}
function ymd(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0'),
].join('-');
}
function icsDate(dateString) {
return String(dateString).replaceAll('-', '');
}
function utcStamp(date = new Date()) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
function addDays(dateString, days) {
const [year, month, day] = String(dateString).split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() + days);
return ymd(date);
}
function addMonths(date, months) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
}
function monthIterator(startDate, endDate) {
const months = [];
let cursor = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), 1));
const end = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), 1));
while (cursor <= end) {
months.push({ year: cursor.getUTCFullYear(), month: cursor.getUTCMonth() + 1 });
cursor = addMonths(cursor, 1);
}
return months;
}
// RFC 5545 §3.3.11 TEXT: escape backslash, semicolon, comma, and line breaks.
function escapeText(value) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/\r\n|\r|\n/g, '\\n')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,');
}
// RFC 5545 §3.1 content lines: fold at 75 octets, not JavaScript characters.
function foldLine(line) {
const value = String(line);
if (Buffer.byteLength(value, 'utf8') <= 75) return value;
const lines = [];
let current = '';
let limit = 75;
for (const char of value) {
const next = current + char;
if (Buffer.byteLength(next, 'utf8') > limit) {
lines.push(current);
current = char;
limit = 74; // continuation lines include one leading WSP octet
} else {
current = next;
}
}
if (current) lines.push(current);
return lines.map((part, index) => (index === 0 ? part : ` ${part}`)).join('\r\n');
}
function weekdayForCycleDay(value) {
const normalized = String(value || 'monday').trim().toLowerCase();
return WEEKDAY_CODES[normalized] || 'MO';
}
function monthForCycleDay(value) {
const parsed = parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 12 ? parsed : 1;
}
function dayForBill(bill) {
const parsed = parseInt(bill?.due_day, 10);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 31 ? parsed : 1;
}
// RRULE values follow RFC 5545 §3.3.10 and mirror the app's five cycle types.
function rruleForCycle(bill = {}) {
const cycleType = normalizeCycleType(bill);
if (cycleType === 'weekly') {
return `FREQ=WEEKLY;BYDAY=${weekdayForCycleDay(bill.cycle_day)}`;
}
if (cycleType === 'biweekly') {
return `FREQ=WEEKLY;INTERVAL=2;BYDAY=${weekdayForCycleDay(bill.cycle_day)}`;
}
if (cycleType === 'quarterly') {
return `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=${dayForBill(bill)}`;
}
if (cycleType === 'annual') {
return `FREQ=YEARLY;BYMONTH=${monthForCycleDay(bill.cycle_day)};BYMONTHDAY=${dayForBill(bill)}`;
}
return `FREQ=MONTHLY;BYMONTHDAY=${dayForBill(bill)}`;
}
function eventUid(bill, dueDate) {
return `bill-tracker-bill-${bill.id}-${dueDate}@bill-tracker`;
}
function eventSummary(bill, detailLevel = 'standard') {
if (detailLevel === 'private') return 'Bill due';
if (detailLevel === 'full') return `${bill.name} due - $${Number(bill.expected_amount || 0).toFixed(2)}`;
return `${bill.name} due`;
}
function eventDescription(bill, dueDate, detailLevel = 'standard') {
const lines = ['Bill Tracker reminder'];
if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`);
if (detailLevel === 'full') lines.push(`Expected amount: $${Number(bill.expected_amount || 0).toFixed(2)}`);
lines.push(`Due date: ${dueDate}`);
if (bill.category_name) lines.push(`Category: ${bill.category_name}`);
if (bill.autopay_enabled) lines.push('Autopay: enabled');
return lines.join('\n');
}
function buildEvent({ bill, dueDate, detailLevel = 'standard', dtstamp = utcStamp() }) {
const dtEnd = addDays(dueDate, 1);
const lines = [
'BEGIN:VEVENT',
`UID:${eventUid(bill, dueDate)}`,
`DTSTAMP:${dtstamp}`,
`DTSTART;VALUE=DATE:${icsDate(dueDate)}`,
`DTEND;VALUE=DATE:${icsDate(dtEnd)}`,
`SUMMARY:${escapeText(eventSummary(bill, detailLevel))}`,
`DESCRIPTION:${escapeText(eventDescription(bill, dueDate, detailLevel))}`,
`CATEGORIES:${escapeText(bill.category_name || 'Bills')}`,
'TRANSP:TRANSPARENT',
'STATUS:CONFIRMED',
'END:VEVENT',
];
return lines;
}
function loadActiveBillsForFeed(userId, db = getDb()) {
return db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.active = 1
AND b.deleted_at IS NULL
ORDER BY b.name ASC
`).all(userId);
}
function buildFeedEvents(userId, options = {}, db = getDb()) {
const now = options.now || new Date();
const start = options.startDate || addMonths(now, -(options.pastMonths ?? FEED_PAST_MONTHS));
const end = options.endDate || addMonths(now, options.futureMonths ?? FEED_FUTURE_MONTHS);
const detailLevel = options.detailLevel || 'standard';
const dtstamp = options.dtstamp || utcStamp(now);
const events = [];
const seen = new Set();
for (const bill of loadActiveBillsForFeed(userId, db)) {
for (const { year, month } of monthIterator(start, end)) {
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) continue;
const uid = eventUid(bill, dueDate);
if (seen.has(uid)) continue;
seen.add(uid);
events.push({ bill, dueDate, uid, lines: buildEvent({ bill, dueDate, detailLevel, dtstamp }) });
}
}
events.sort((a, b) => a.dueDate.localeCompare(b.dueDate) || a.bill.name.localeCompare(b.bill.name));
return events;
}
function buildIcsCalendar({ name = 'Bill Tracker', events = [] } = {}) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:${PRODID}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeText(name)}`,
'X-WR-TIMEZONE:UTC',
];
for (const event of events) {
lines.push(...event.lines);
}
lines.push('END:VCALENDAR');
return `${lines.map(foldLine).join('\r\n')}\r\n`;
}
function buildCalendarFeed(userId, options = {}, db = getDb()) {
const events = buildFeedEvents(userId, options, db);
return {
ics: buildIcsCalendar({ name: options.name || 'Bill Tracker', events }),
events,
};
}
function previewFeed(userId, options = {}, db = getDb()) {
const events = buildFeedEvents(userId, { ...options, pastMonths: 0, futureMonths: 6 }, db);
return events.slice(0, options.limit || 10).map(event => ({
uid: event.uid,
bill_id: event.bill.id,
name: event.bill.name,
due_date: event.dueDate,
amount: Number(event.bill.expected_amount || 0),
cycle_type: normalizeCycleType(event.bill),
category_name: event.bill.category_name || null,
}));
}
module.exports = {
buildCalendarFeed,
buildFeedEvents,
createToken,
ensureCalendarTokenSchema,
escapeText,
feedUrlForToken,
foldLine,
getActiveToken,
getOrCreateToken,
getTokenRecord,
markTokenUsed,
previewFeed,
regenerateToken,
revokeToken,
rruleForCycle,
};