'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, };