420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
'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<string, any>;
|
|
|
|
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
|
|
const FEED_PAST_MONTHS = 12;
|
|
const FEED_FUTURE_MONTHS = 24;
|
|
|
|
const WEEKDAY_CODES: Record<string, string> = {
|
|
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<string>();
|
|
|
|
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,
|
|
};
|