/** * 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() /** * Per-event reminder lead in minutes (Phase 11, CAL-13). * null — explicit "None" → no VALARM emitted * 0 — same-day all-day (fire 9 AM on event date); 0 on timed = None (D-06) * positive — N minutes before event start (timed) or N/1440 days before (all-day) * undefined — field absent; use valarms preserve path if provided */ reminderLeadMinutes?: number | null; /** * Pre-parsed VALARM components from rawVevent (preserve-on-edit, D-08 / CAL-14). * When non-empty, these are re-attached verbatim and reminderLeadMinutes is ignored. */ valarms?: ICAL.Component[]; /** * UTC instant for all-day event absolute DATE-TIME trigger (9 AM local on alert day). * Computed by caller (outboxWorker / computeAlertInstantUtc) for the specific alert date. * Required when allDay=true and reminderLeadMinutes != null. */ allDayAlertInstantUtc?: Date; } /** * Preset reminder lead times (minutes) that map to distinct UI options. * Includes both timed (5–120 min) and all-day (0, 1440, 2880, 10080) presets. * Used by classifyValarms to distinguish preset from offlist single-DURATION triggers. * * D-01/D-02: timed presets = 0,5,10,15,30,60,120; all-day = 0,1440,2880,10080. * 0 is included for "Same day" all-day; it is also the "None" sentinel for timed (D-06). */ export const PRESET_MINUTES = new Set([0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080]); /** * Classification of VALARM components found in a stored raw VEVENT (CAL-14, D-07). * * none — no VALARM sub-component present * preset — single relative DURATION trigger whose lead is in PRESET_MINUTES * offlist — single relative DURATION trigger with a lead NOT in PRESET_MINUTES * custom — absolute DATE-TIME trigger OR multiple VALARM sub-components * * Off-list alarms get a synthetic read-only option in the picker showing the real minutes. * Custom alarms get a disabled "Custom (kept)" entry. */ export type AlarmClassification = | { kind: 'none' } | { kind: 'preset'; leadMinutes: number } | { kind: 'offlist'; leadMinutes: number } | { kind: 'custom' }; /** * 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). * * IN-01 (v1 limitation — lossy round-trip): these presets map only to a BARE * FREQ=DAILY|WEEKLY|MONTHLY|YEARLY. extractRruleString() preserves the FULL stored RECUR * (which may carry BYDAY/INTERVAL/COUNT/UNTIL), and the preserve-on-edit path keeps that * rich rule. But if a `recurrence` preset value is ever applied to a previously-rich rule, * it COLLAPSES the rule to the bare preset — silently dropping BYDAY/INTERVAL/UNTIL/COUNT. * This is acceptable in v1 only because the picker offers just these four bare presets and * is disabled on edit (EventForm WR-01). When recurrence EDITING ships, do NOT replace the * stored rule with a preset: parse the existing RECUR and modify it in place so qualifiers * survive. See CR-01/WR-01 preserve paths for the precedent. */ export const RRULE_PRESETS: Record = { daily: 'FREQ=DAILY', weekly: 'FREQ=WEEKLY', monthly: 'FREQ=MONTHLY', yearly: 'FREQ=YEARLY', }; /** * Build a VALARM sub-component with a relative DURATION trigger for timed events. * * Uses resetType('duration') + setValue(ICAL.Duration) to guarantee the correct * jCal value type — preventing the VALUE=TEXT pitfall that occurs when using * addPropertyWithValue('trigger', '-PTNmM') (Pitfall 2). * * @param leadMinutes — minutes before event start (e.g. 15 → TRIGGER:-PT15M) */ export function buildTimedValarm(leadMinutes: number): ICAL.Component { const valarm = new ICAL.Component('valarm'); valarm.addPropertyWithValue('action', 'DISPLAY'); valarm.addPropertyWithValue('description', 'Reminder'); const triggerProp = new ICAL.Property('trigger'); // resetType('duration') ensures the jCal value type is 'duration', not 'text' triggerProp.resetType('duration'); triggerProp.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60)); valarm.addProperty(triggerProp); return valarm; } /** * Build a VALARM sub-component with an absolute DATE-TIME trigger for all-day events. * * RFC 5545 §3.8.6.3: TRIGGER;VALUE=DATE-TIME: — an absolute UTC instant. * Uses resetType('date-time') + setValue(ICAL.Time) to guarantee VALUE=DATE-TIME, no DURATION. * * @param alertInstantUtc — the UTC instant (9 AM local on the computed alert day) */ export function buildAllDayValarm(alertInstantUtc: Date): ICAL.Component { const valarm = new ICAL.Component('valarm'); valarm.addPropertyWithValue('action', 'DISPLAY'); valarm.addPropertyWithValue('description', 'Reminder'); const triggerProp = new ICAL.Property('trigger'); // resetType('date-time') ensures VALUE=DATE-TIME absolute trigger triggerProp.resetType('date-time'); // useUTC=true → Z suffix (UTC instant, not local) triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); valarm.addProperty(triggerProp); return valarm; } /** * Classify the VALARM(s) in a stored raw VEVENT string (CAL-14, D-07). * * Safe on parse failure — returns { kind: 'none' } for any unparseable input. * This matches the threat-model mitigation T-11-01: no eval, no string splicing; * ical.js handles line-folding + escaping. * * @param rawVevent — full VCALENDAR or VEVENT iCalendar string */ export function classifyValarms(rawVevent: string): AlarmClassification { let parsed: ReturnType; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' parsed = ICAL.parse(rawVevent); } catch { return { kind: 'none' }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' const comp = new ICAL.Component(parsed); const vevent = comp.getFirstSubcomponent('vevent'); if (!vevent) return { kind: 'none' }; const valarms = vevent.getAllSubcomponents('valarm'); if (valarms.length === 0) return { kind: 'none' }; if (valarms.length > 1) return { kind: 'custom' }; const alarm = valarms[0]; const triggerProp = alarm.getFirstProperty('trigger'); if (!triggerProp) return { kind: 'none' }; // getFirstValue() returns ICAL.Duration for relative triggers and ICAL.Time for absolute. // The 'instanceof ICAL.Time' check distinguishes the two without relying on VALUE param. const firstValue = triggerProp.getFirstValue() as unknown; if (firstValue instanceof ICAL.Time) return { kind: 'custom' }; // Relative DURATION trigger — extract lead minutes. const dur = firstValue as ICAL.Duration; if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' }; const seconds = dur.toSeconds(); // WR-01 (Phase 11 Plan 05): positive seconds = alarm fires AFTER the event start // (RFC 5545 TRIGGER:+PT15M or TRIGGER;RELATED=END:PT15M). This is a post-event alarm // and cannot be expressed as a before-event lead. Classify as custom so the preserve // path keeps the original VALARM rather than inverting the alarm direction. if (seconds > 0) return { kind: 'custom' }; const leadMinutes = Math.round(-seconds / 60); return PRESET_MINUTES.has(leadMinutes) ? { kind: 'preset', leadMinutes } : { kind: 'offlist', leadMinutes }; } /** * Extract all VALARM sub-components from a stored raw VEVENT string (CAL-14, D-08). * * Returns live ICAL.Component objects that can be re-attached to a new VEVENT via * addSubcomponent() — no re-serialization/re-parsing, preserving exact encoding. * * Safe on parse failure — returns [] for any unparseable input (T-11-01). * * @param rawVevent — full VCALENDAR or VEVENT iCalendar string */ export function extractValarms(rawVevent: string): ICAL.Component[] { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' const parsed = ICAL.parse(rawVevent); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' const comp = new ICAL.Component(parsed); const vevent = comp.getFirstSubcomponent('vevent'); if (!vevent) return []; return vevent.getAllSubcomponents('valarm'); } catch { return []; } } /** * Compute the UTC instant for 9:00 AM local time on the alert day (Phase 11, NOTIF-06). * * The alert day = eventDate minus leadDays. The UTC offset is computed for 9 AM * specifically on the alert date — not midnight — so DST transitions that occur before * 9 AM on the alert date (e.g. spring-forward at 2 AM) use the post-transition offset. * * D-04: all-day reminders fire at 9 AM local on the alert day. * Assumption A2/A5: caller passes the server/household timezone (process.env.TZ or * Intl.DateTimeFormat().resolvedOptions().timeZone). * * @param eventDateStr — event start date as 'YYYY-MM-DD' * @param leadDays — days before event date (0 = same day, 1 = 1 day before, etc.) * @param tz — IANA timezone identifier (e.g. 'America/New_York') */ export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date { // Parse event date as UTC midnight, then subtract lead days to get alert date const [y, m, d] = eventDateStr.split('-').map(Number) as [number, number, number]; const alertDateUtcMs = Date.UTC(y, m - 1, d - leadDays); const alertDateStr = new Date(alertDateUtcMs).toISOString().slice(0, 10); // 'YYYY-MM-DD' // Strategy: compute the UTC offset at 9 AM on the alert date (not at midnight). // This correctly handles DST transitions that occur before 9 AM (e.g. spring-forward // at 2 AM on the alert date means 9 AM uses the post-transition offset). // // Algorithm: // 1. Take a "naive" 9 AM UTC estimate: alertDateUtcMs + 9h (as if tz=UTC). // 2. Ask Intl: what local date+time is this naive instant in `tz`? // 3. The difference between the desired 9 AM local and the actual local time at the // naive instant tells us the UTC offset. // 4. Adjust: target_utc = naive_9am_utc - offset_ms. // // This is a one-pass computation that works across DST boundaries because we probe // the offset near 9 AM, not at midnight. const naive9amUtcMs = alertDateUtcMs + 9 * 3600 * 1000; const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); // What local time does our naive UTC instant correspond to in tz? const parts = formatter.formatToParts(new Date(naive9amUtcMs)); const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0'; const localYear = Number(get('year')); const localMonth = Number(get('month')); const localDay = Number(get('day')); const localHour = Number(get('hour')); // 0–23 (hour12:false) const localMin = Number(get('minute')); const localSec = Number(get('second')); // The local time at naive_9am_utc in tz. We want this to be 09:00:00 on alertDate. // Compute how many ms the local time deviates from 09:00:00 on alertDate. // deviation = (local - desired) in ms, where desired = alertDate at 09:00:00 // Build the local "desired" as a date string for comparison const [ay, am, ad] = alertDateStr.split('-').map(Number) as [number, number, number]; const isSameDay = localYear === ay && localMonth === am && localDay === ad; // Total ms since start of local day at the naive instant const localMsSinceMidnight = (localHour * 3600 + localMin * 60 + localSec) * 1000; // Desired ms since start of local day = 9 AM = 9 * 3600 * 1000 const desired9amMs = 9 * 3600 * 1000; let deviationMs: number; if (isSameDay) { // Same local date: deviation = localMsSinceMidnight - desired9amMs deviationMs = localMsSinceMidnight - desired9amMs; } else { // Different local day: the local clock is on the previous or next day. // Since we're probing near 9 AM UTC+0 and typical offsets are ±12h, the local // time is either earlier in the day (offset negative = west) or earlier the prev day. // Day-before case (west of UTC, e.g. UTC-12): local shows the previous day. // deviation = -(24h - localMsSinceMidnight) - desired9amMs ... complex // Simpler: compute deviation as the ms difference between naive_9am_utc and the // UTC instant for the desired 9 AM local. // Since we know local is offset: local_midnight_utc = naive_9am_utc - localMsSinceMidnight // + (isSameDay ? 0 : 24h) if local day is next day (east of UTC past 15h offset) // In practice for UTC-12 to UTC+14, the offset is ±14h. At naive 9 AM UTC: // UTC-12 → local = 9 AM - 12h = previous day 9 PM → 21h = local midnight +21h // UTC+14 → local = 9 AM + 14h = next day 23 AM → not relevant for this use case // Household timezones are typically within ±12h. Handle common case: // If local date is the PREVIOUS day relative to alertDate, local is west of UTC // (negative offset). The local time displayed (e.g. 21:00 for UTC-12) means we // are 21h into the previous local day. The local midnight of alertDate is at // naive_9am_utc - localMsSinceMidnight + (we haven't reached alertDate midnight yet) // Actually: deviation = localMsSinceMidnight + 24h - desired9amMs when day is before alert // deviation = localMsSinceMidnight - 24h - desired9amMs when day is after alert // Check if local date is day before or day after alertDate const localDateMs = Date.UTC(localYear, localMonth - 1, localDay); const alertDateMsForCompare = Date.UTC(ay, am - 1, ad); const dayDiffDays = (alertDateMsForCompare - localDateMs) / (24 * 3600 * 1000); // dayDiff > 0: local day is before alert date (west of UTC, negative offset) // dayDiff < 0: local day is after alert date (east of UTC, large positive offset) deviationMs = localMsSinceMidnight + dayDiffDays * 24 * 3600 * 1000 - desired9amMs; } // target_utc = naive_9am_utc - deviation // (deviation > 0: local is ahead of desired, shift target earlier in UTC) return new Date(naive9amUtcMs - deviationMs); } /** * WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the * outbox worker can preserve recurrence on an edit whose payload omits it (the PWA * occurrence contract does not expose recurrence, D-03). Returns the RRULE value as a * 'FREQ=…' string (e.g. 'FREQ=WEEKLY'), or undefined when the event has no RRULE or * the input cannot be parsed. */ export function extractRruleString(rawVevent: string): string | undefined { let parsed: ReturnType; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it parsed = ICAL.parse(rawVevent); } catch { return undefined; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value const comp = new ICAL.Component(parsed); const vevent = comp.getFirstSubcomponent('vevent'); if (!vevent) return undefined; const rrule = vevent.getFirstPropertyValue('rrule'); if (!rrule) return undefined; // ICAL.Recur#toString() yields the RECUR value, e.g. 'FREQ=WEEKLY'. return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString(); } /** * 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]; // WR-04 (owning boundary): RFC-5545 §3.6.1 — DTEND for an all-day event is the // EXCLUSIVE end date. Advance the user-entered inclusive end by one calendar day. // Building a Date from UTC components ensures no DST ambiguity during the roll-over. const endDate = new Date(Date.UTC(ey, em - 1, ed)); endDate.setUTCDate(endDate.getUTCDate() + 1); const ey2 = endDate.getUTCFullYear(); const em2 = endDate.getUTCMonth() + 1; const ed2 = endDate.getUTCDate(); // ICAL.Timezone.localTimezone is passed as the zone arg required by TS types. // isDate:true suppresses any time/TZID output regardless of zone. (D-13) const startTime = new ICAL.Time( { year: sy, month: sm, day: sd, isDate: true }, ICAL.Timezone.localTimezone, ); const endTime = new ICAL.Time( { year: ey2, month: em2, day: ed2, isDate: true }, ICAL.Timezone.localTimezone, ); 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: VALARM (Phase 11, CAL-13/CAL-14 — per-event reminders) // // Branch order: // 1. Preserve path (D-08/CAL-14): if valarms[] non-empty, re-attach verbatim. // reminderLeadMinutes is ignored — preserve wins over synthesize. // 2. All-day new alarm: if allDay + reminderLeadMinutes != null + allDayAlertInstantUtc. // Emits absolute DATE-TIME trigger (RFC 5545 §3.8.6.3). // 3. Timed new alarm: if !allDay + reminderLeadMinutes > 0. // Emits relative DURATION trigger. 0 on timed = None (D-06). // // No VALARM is emitted when: reminderLeadMinutes is null/undefined, or // timed with leadMinutes=0, or no valarms and no explicit reminder. if (params.valarms && params.valarms.length > 0) { // Preserve path: re-attach existing VALARMs from rawVevent verbatim (D-08) for (const alarm of params.valarms) { vevent.addSubcomponent(alarm); } } else if (params.reminderLeadMinutes != null) { if (params.allDay && params.allDayAlertInstantUtc) { // All-day: absolute DATE-TIME trigger at 9 AM local on the computed alert day vevent.addSubcomponent(buildAllDayValarm(params.allDayAlertInstantUtc)); } else if (!params.allDay && params.reminderLeadMinutes > 0) { // Timed: relative DURATION trigger; 0 = None (D-06) vevent.addSubcomponent(buildTimedValarm(params.reminderLeadMinutes)); } // allDay=false && reminderLeadMinutes===0: no VALARM (timed 0 = None per D-06) } // 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() }; }