359 lines
11 KiB
JavaScript
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,
|
|
};
|