'use strict'; import type { Db } from '../types/db'; import type { Req } from '../types/http'; const crypto = require('crypto'); const { getDb } = require('../db/database.cts'); const { normalizeCycleType, resolveDueDate } = require('./statusService.cts'); const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); type Row = Record; const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const FEED_PAST_MONTHS = 12; const FEED_FUTURE_MONTHS = 24; const WEEKDAY_CODES: Record = { sunday: 'SU', monday: 'MO', tuesday: 'TU', wednesday: 'WE', thursday: 'TH', friday: 'FR', saturday: 'SA', }; function ensureCalendarTokenSchema(db: Db = getDb()): void { 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(): string { return crypto.randomBytes(32).toString('base64url'); } function sanitizeLabel(label: unknown): string { const value = String(label || 'Bill Tracker Calendar').trim(); return value.slice(0, 80) || 'Bill Tracker Calendar'; } function getActiveToken(userId: number, db: Db = getDb()): Row | null { 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: number, label = 'Bill Tracker Calendar', db: Db = getDb()): Row { 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: number, db: Db = getDb()): Row { return getActiveToken(userId, db) || createToken(userId, 'Bill Tracker Calendar', db); } function regenerateToken(userId: number, db: Db = getDb()): Row { ensureCalendarTokenSchema(db); let next: Row; 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: number, db: Db = getDb()): void { 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: unknown, db: Db = getDb()): Row | null { 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: number, db: Db = getDb()): void { ensureCalendarTokenSchema(db); db.prepare("UPDATE calendar_tokens SET last_used_at = datetime('now') WHERE id = ?").run(tokenId); } function originFromRequest(req: Req): string { 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: Req, token: string): string { return `${originFromRequest(req)}/api/calendar/feed.ics?token=${encodeURIComponent(token)}`; } function ymd(date: Date): string { return [ date.getUTCFullYear(), String(date.getUTCMonth() + 1).padStart(2, '0'), String(date.getUTCDate()).padStart(2, '0'), ].join('-'); } function icsDate(dateString: unknown): string { return String(dateString).replaceAll('-', ''); } function utcStamp(date: Date = new Date()): string { return date .toISOString() .replace(/[-:]/g, '') .replace(/\.\d{3}Z$/, 'Z'); } function addDays(dateString: unknown, days: number): string { 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: Date, months: number): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)); } function monthIterator(startDate: Date, endDate: Date): { year: number; month: number }[] { const months: { year: number; month: number }[] = []; 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: unknown): string { 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: unknown): string { const value = String(line); if (Buffer.byteLength(value, 'utf8') <= 75) return value; const lines: string[] = []; 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: unknown): string { const normalized = String(value || 'monday') .trim() .toLowerCase(); return WEEKDAY_CODES[normalized] || 'MO'; } function monthForCycleDay(value: unknown): number { const parsed = parseInt(value as string, 10); return Number.isInteger(parsed) && parsed >= 1 && parsed <= 12 ? parsed : 1; } function dayForBill(bill: Row): number { 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: Row = {}): string { 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: Row, dueDate: string): string { return `bill-tracker-bill-${bill.id}-${dueDate}@bill-tracker`; } function eventSummary(bill: Row, detailLevel = 'standard'): string { if (detailLevel === 'private') return 'Bill due'; if (detailLevel === 'full') return `${bill.name} due - $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`; return `${bill.name} due`; } function eventDescription(bill: Row, dueDate: string, detailLevel = 'standard'): string { const lines = ['Bill Tracker reminder']; if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`); if (detailLevel === 'full') lines.push(`Expected amount: $${(fromCents(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(), }: { bill: Row; dueDate: string; detailLevel?: string; dtstamp?: string; }): string[] { 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: number, db: Db = getDb()): Row[] { 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: number, options: Row = {}, db: Db = getDb()): any[] { 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: any[] = []; 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 = [], }: { name?: string; events?: any[] } = {}): string { 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: number, options: Row = {}, db: Db = getDb()) { const events = buildFeedEvents(userId, options, db); return { ics: buildIcsCalendar({ name: options.name || 'Bill Tracker', events }), events, }; } function previewFeed(userId: number, options: Row = {}, db: 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: fromCents(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, };