diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts new file mode 100644 index 0000000..ed2bf9c --- /dev/null +++ b/apps/api/src/broker/vevent.ts @@ -0,0 +1,117 @@ +/** + * VEVENT builder — constructs a valid VCALENDAR/VEVENT iCalendar string for PUT to Fastmail. + * + * D-13 contract (mirrors sync.ts parser in reverse): + * - All-day events: use ICAL.Time({ isDate: true }) → produces VALUE=DATE, no TZID, no time (D-13) + * - Timed events: use ICAL.Time.fromJSDate(date, true) (useUTC=true) → DTSTART:...Z suffix, no TZID + * - NEVER coerce DATE to DATETIME (Pitfall #3) + * + * T-03-03 (Tampering): ical.js ICAL.Component handles line-folding + escaping (commas, semicolons, + * newlines in summary/location/description). Never hand-roll ICS strings. + * + * Sources: + * - https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545) + * - https://github.com/kewisch/ical.js/blob/main/lib/ical/component.js + * - https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js + */ + +import ICAL from 'ical.js' +import { randomUUID } from 'crypto' + +export interface NewEventParams { + uid?: string // omit = generate new UUID (appended with @familysync) + summary: string + allDay: boolean + // All-day: YYYY-MM-DD string (or Date — only the date portion is used) + // Timed: JS Date representing a UTC instant + dtstart: string | Date + dtend: string | Date + location?: string + description?: string + rruleString?: string // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07) + dtstamp?: Date // omit = now() +} + +/** + * Simple RRULE preset strings for whole-series recurring events (D-11 / CAL-07). + * "weekly on Monday" example: 'FREQ=WEEKLY;BYDAY=MO' (not a key in this map; compose manually). + */ +export const RRULE_PRESETS: Record = { + daily: 'FREQ=DAILY', + weekly: 'FREQ=WEEKLY', + monthly: 'FREQ=MONTHLY', + yearly: 'FREQ=YEARLY', +} + +/** + * Builds a VCALENDAR/VEVENT iCalendar string from form parameters. + * + * @returns { uid, icsString } — uid is the generated or provided UID; + * icsString is the full VCALENDAR string ready for PUT to Fastmail. + */ +export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } { + const uid = params.uid ?? `${randomUUID()}@familysync` + + // --- VCALENDAR wrapper --- + const cal = new ICAL.Component(['vcalendar', [], []]) + cal.updatePropertyWithValue('version', '2.0') + cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN') + + // --- VEVENT --- + const vevent = new ICAL.Component('vevent') + vevent.addPropertyWithValue('uid', uid) + vevent.addPropertyWithValue('summary', params.summary) + + // DTSTAMP: always present (RFC 5545 §3.8.7.2 — required property) + const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true) + vevent.addPropertyWithValue('dtstamp', dtstamp) + + if (params.allDay) { + // DATE value (not DATETIME) — isDate:true produces VALUE=DATE, no time component (D-13 contract) + const startStr = + typeof params.dtstart === 'string' + ? params.dtstart + : params.dtstart.toISOString().slice(0, 10) + const endStr = + typeof params.dtend === 'string' + ? params.dtend + : params.dtend.toISOString().slice(0, 10) + + const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number] + const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number] + + const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }) + const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true }) + vevent.addPropertyWithValue('dtstart', startTime) + vevent.addPropertyWithValue('dtend', endTime) + } else { + // DATETIME in UTC (useUTC=true → Z suffix; TZID is NOT added by ical.js) (D-13 contract) + const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true) + const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true) + vevent.addPropertyWithValue('dtstart', startTime) + vevent.addPropertyWithValue('dtend', endTime) + } + + // Optional: RRULE (CAL-07 — whole-series recurring events only in v1, D-11) + // Must use ICAL.Recur.fromString + ICAL.Property to get correct serialization. + // addPropertyWithValue('rrule', string) serializes the string character-by-character + // instead of as a RECUR value type. + if (params.rruleString) { + const recur = ICAL.Recur.fromString(params.rruleString) + const rruleProp = new ICAL.Property('rrule') + rruleProp.setValue(recur) + vevent.addProperty(rruleProp) + } + + // Optional fields — included only when provided (no empty properties in ICS) + if (params.location) { + vevent.addPropertyWithValue('location', params.location) + } + if (params.description) { + vevent.addPropertyWithValue('description', params.description) + } + + cal.addSubcomponent(vevent) + + return { uid, icsString: cal.toString() } +}