// Data-access + iCalendar translation layer for the hand-rolled CalDAV // server in routes/caldav.js. All event mutations funnel through // routes/events.js's createEvent/updateEvent/deleteEvent/getEventDetail so // web and CalDAV writes share one source of truth (source: 'caldav' here). import bcrypt from 'bcryptjs' import ICAL from 'ical.js' import { createEvent as buildIcsEvent } from 'ics' import { createEvent, updateEvent, deleteEvent } from '../routes/events.js' const DAY_MS = 24 * 60 * 60 * 1000 // ---- auth ------------------------------------------------------------- export async function authenticateBasic(pool, username, password) { if (!username || !password) return null const { rows } = await pool.query('SELECT * FROM caldav_credentials WHERE username = $1', [username]) if (!rows.length) return null const cred = rows[0] const ok = await bcrypt.compare(password, cred.password_hash) if (!ok) return null pool.query('UPDATE caldav_credentials SET last_used_at = NOW() WHERE id = $1', [cred.id]).catch(() => {}) return { email: cred.user_email } } // ---- calendar collections ---------------------------------------------- export async function listCalendars(pool) { const { rows } = await pool.query( `SELECT * FROM calendars WHERE deleted_at IS NULL ORDER BY is_system ASC, name ASC` ) return rows } export async function getCalendarBySlug(pool, slug) { const { rows } = await pool.query( `SELECT * FROM calendars WHERE slug = $1 AND deleted_at IS NULL`, [slug] ) return rows[0] || null } // Highest-water-mark timestamp for a calendar's contents — used as the // CalendarServer getctag so clients know when to re-list without diffing // every resource's etag. export async function getCalendarCtag(pool, calendarId) { const { rows } = await pool.query( `SELECT GREATEST( COALESCE((SELECT MAX(updated_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NULL), 'epoch'), COALESCE((SELECT MAX(deleted_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL), 'epoch') ) AS ts`, [calendarId] ) const ts = rows[0]?.ts return ts ? new Date(ts).getTime().toString() : '0' } export async function listEventsInCalendar(pool, calendarId) { const { rows } = await pool.query( `SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL ORDER BY uid`, [calendarId] ) return rows } export async function getFullEvent(pool, calendarId, uid) { const { rows } = await pool.query( `SELECT * FROM events WHERE calendar_id = $1 AND uid = $2 AND deleted_at IS NULL`, [calendarId, uid] ) return rows[0] || null } // Events changed (created/updated) or removed (soft-deleted) since `since` // (a Date, or null for a full initial sync). Minimal RFC 6578 support. export async function getChangesSince(pool, calendarId, since) { const params = [calendarId] let sinceClause = 'TRUE' if (since) { params.push(since) sinceClause = `updated_at > $2` } const { rows: changed } = await pool.query( `SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL AND ${sinceClause} ORDER BY updated_at`, params ) let removed = [] if (since) { const { rows } = await pool.query( `SELECT uid FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2`, [calendarId, since] ) removed = rows } return { changed, removed } } // ---- etags -------------------------------------------------------------- export function etagFor(event) { return `"${event.sequence}-${new Date(event.updated_at).getTime()}"` } // ---- iCalendar <-> our event shape --------------------------------------- function toDateArray(d) { const dt = new Date(d) return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate()] } function toDateTimeArray(d) { const dt = new Date(d) return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate(), dt.getUTCHours(), dt.getUTCMinutes()] } // Generates a full VCALENDAR document containing one VEVENT. Calendar name // is mapped one-way into CATEGORIES for context; it does not round-trip. export function eventToIcsString(event, calendarName) { const attrs = { uid: event.uid, title: event.title, description: event.description || undefined, location: event.location || undefined, sequence: event.sequence, categories: calendarName ? [calendarName] : undefined, calName: calendarName || undefined, startInputType: 'utc', startOutputType: 'utc', endInputType: 'utc', endOutputType: 'utc', } if (event.all_day) { // Our end_at is the inclusive last day; RFC 5545 requires DTEND for a // DATE value to be the day *after* the last day (exclusive). attrs.start = toDateArray(event.start_at) attrs.end = toDateArray(new Date(new Date(event.end_at).getTime() + DAY_MS)) } else { attrs.start = toDateTimeArray(event.start_at) attrs.end = toDateTimeArray(event.end_at) } const { error, value } = buildIcsEvent(attrs) if (error) throw error return value } // Parses an incoming PUT body (a VCALENDAR with one VEVENT) into our event // field shape. RRULE, if present, is ignored — v1 has no recurring events; // every VEVENT is stored as a single one-off occurrence. export function parseIcsEvent(icsBody) { const jcal = ICAL.parse(icsBody) const comp = new ICAL.Component(jcal) const vevent = comp.getFirstSubcomponent('vevent') if (!vevent) throw new Error('No VEVENT found in submitted iCalendar data') const event = new ICAL.Event(vevent) if (!event.uid) throw new Error('VEVENT missing UID') if (!event.startDate) throw new Error('VEVENT missing DTSTART') const allDay = event.startDate.isDate === true const start = event.startDate.toJSDate() let end if (event.endDate) { end = event.endDate.toJSDate() } else if (event.duration) { end = new Date(start.getTime() + event.duration.toSeconds() * 1000) } else { end = start } if (allDay) { // Convert the RFC-mandated exclusive DTEND back to our inclusive // last-day storage convention. end = new Date(end.getTime() - DAY_MS) if (end.getTime() < start.getTime()) end = start } return { uid: event.uid, title: event.summary || '(untitled)', description: event.description || null, location: event.location || null, all_day: allDay, start_at: start.toISOString(), end_at: end.toISOString(), sequence: Number.isFinite(event.sequence) ? event.sequence : 0, } } // ---- mutation pass-through (source: 'caldav') ---------------------------- export async function createEventFromCaldav(pool, calendarId, parsed, actorEmail) { return createEvent(pool, { calendar_id: calendarId, ...parsed }, { email: actorEmail, name: actorEmail }, 'caldav') } export async function updateEventFromCaldav(pool, eventId, parsed, actorEmail) { return updateEvent(pool, eventId, parsed, { email: actorEmail, name: actorEmail }, 'caldav') } export async function deleteEventFromCaldav(pool, eventId, actorEmail) { return deleteEvent(pool, eventId, { email: actorEmail, name: actorEmail }, 'caldav') }