diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 608167e..822d7bd 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -26,6 +26,7 @@ * Source: poller.ts pattern (runPoll/startBrokerPoller) */ import { z } from 'zod'; +import ICAL from 'ical.js'; import { and, eq, lte } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'; @@ -33,7 +34,13 @@ import { createFastmailClient } from './client.js'; import { decryptPassword } from './crypto.js'; import { syncCalendar } from './sync.js'; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'; -import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'; +import { + buildVeventString, + extractRruleString, + extractValarms, + computeAlertInstantUtc, + RRULE_PRESETS, +} from './vevent.js'; import type { FastmailClient } from './client.js'; import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'; import { onOutboxDrain } from '../lib/outboxTrigger.js'; @@ -91,6 +98,12 @@ const outboxPayloadSchema = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; preserve existing VALARM verbatim (no-change path, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (fire 9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).nullable().optional(), }) .passthrough(); @@ -423,6 +436,11 @@ async function dispatchRow(row: OutboxRow): Promise { // deliberate user change. Read rawVevent in the same scoped query as the fresh etag. let preservedRrule: string | undefined; const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence'); + // CAL-14: mirrors the WR-01 hasExplicitRecurrence pattern for VALARM preservation. + // When the payload omits `reminderLeadMinutes` entirely (no-change, D-08), we preserve + // the existing VALARM verbatim from rawVevent via extractValarms. An explicit null clears + // the VALARM; an explicit value (timed or all-day) replaces it. + const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes'); const rruleFromPayload = fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] @@ -465,6 +483,26 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule = extractRruleString(freshEtagRows[0].rawVevent); } + // CAL-14: resolve VALARM for the UPDATE branch. + // absent (no hasExplicitReminder) + rawVevent has VALARMs → preserve verbatim (D-08) + // explicit null → clear (no VALARM emitted by buildVeventString) + // explicit value + allDay + valid start → compute 9 AM absolute DATE-TIME trigger (D-04) + // explicit value + timed → pass through to buildTimedValarm + let valarmsToPreserve: ICAL.Component[] | undefined; + let allDayAlertInstantUtcUpdate: Date | undefined; + if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { + valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent); + } else if ( + hasExplicitReminder && + fields.reminderLeadMinutes != null && + fields.allDay && + fields.start + ) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + // D-06: assemble the final RRULE string, combining the preset or preserved RRULE // with an optional UNTIL/COUNT bound from the payload. // Pitfall 3: on series edit with bound change only (no new preset), parse the preserved @@ -489,6 +527,10 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13/CAL-14: VALARM wiring — absent preserves, null clears, value replaces + reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined, + valarms: valarmsToPreserve, + allDayAlertInstantUtc: allDayAlertInstantUtcUpdate, }); response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut); @@ -558,6 +600,15 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule, ); + // CAL-13: VALARM wiring for CREATE branch (no rawVevent source — new event always + // carries an explicit picker value or no reminder at all; no preserve path needed). + let allDayAlertInstantUtcCreate: Date | undefined; + if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title, @@ -567,6 +618,9 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13: per-event reminder — pass through; null=clear, value=set, absent=no VALARM + reminderLeadMinutes: fields.reminderLeadMinutes, + allDayAlertInstantUtc: allDayAlertInstantUtcCreate, }); // Build a minimal DAVCalendar for the write wrapper (only url is needed) const davCalendar = { url: row.calendarUrl } as Parameters[1]; diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index ce8d899..9a0432a 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -117,6 +117,12 @@ const eventFieldsSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; outbox worker preserves existing VALARM (no-change, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).nullable().optional(), }); /** sync-status query params. */