Files
familysync/apps/api/src/broker/vevent.ts
T
Lucas Berger bc605e6a42 fix(11-05): WR-01 positive-duration TRIGGER classifies as custom (no Math.abs)
classifyValarms: check sign of dur.toSeconds() before preset lookup.
Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or
TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path.
Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers.

Prevents alarm direction inversion: +PT15M was being stored as 15-min-before
lead and re-fired at dtstartUtc-15min — the opposite of the original intent.
2026-06-14 08:15:31 -04:00

478 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (5120 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<string, string> = {
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:<YYYYMMDDTHHMMSSZ> — 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<typeof ICAL.parse>;
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')); // 023 (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<typeof ICAL.parse>;
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() };
}