From d2abb91bd2c3753d0fd8623ff8ac4ff347f233bf Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 11:00:24 -0400 Subject: [PATCH] feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence - Add assembleRruleString() helper (exported) to outboxWorker.ts (D-06) - Wire UNTIL/COUNT bound assembly into create + update dispatch branches - Add recurrenceUntil (max 10) + recurrenceCount (int min 1) to outboxPayloadSchema - Add recurrenceUntil + recurrenceCount to eventFieldsSchema in events.ts - Series-edit bound change strips existing UNTIL/COUNT via regex before re-apply (Pitfall 3) - All 39 broker tests pass (RED->GREEN); existing CR-01 none-wins test preserved --- apps/api/src/broker/outboxWorker.ts | 123 +++++++++++++++++++++++++++- apps/api/src/routes/events.ts | 4 + 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 3b9adfd..9d20189 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -79,11 +79,62 @@ const outboxPayloadSchema = z recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(), calendarUrl: z.string().url().max(1024).optional(), _preservedRrule: z.string().max(1024).optional(), + // D-06: recurrence bounding (RRULE UNTIL / COUNT) + // T-06-02: max(10) bounds 'YYYY-MM-DD'; int().min(1) prevents zero/negative counts + recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD' → RRULE UNTIL + recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT }) .passthrough() type OutboxPayloadFields = z.infer +// ── D-06: RRULE bound assembly ─────────────────────────────────────────────── + +/** + * Assembles a complete RRULE string by appending a COUNT or UNTIL bound to a + * base preset string (e.g. 'FREQ=WEEKLY'). + * + * Rules (RFC 5545 §3.3.10): + * - COUNT takes precedence over UNTIL when both are supplied (mutual exclusion). + * - For all-day events (allDay=true), UNTIL serializes as DATE form: `YYYYMMDD`. + * - For timed events (allDay=false), UNTIL serializes as DATETIME UTC: `YYYYMMDDTHHMMSSZ`. + * Using T235959Z (end of UTC day) is a safe universal choice per RESEARCH.md Pitfall 2. + * - When neither bound is supplied the base preset is returned unchanged. + * + * T-06-02 security note: `until.replace(/-/g,'')` emits only digits stripped from a + * length-bounded string (max 10 via Zod). The fixed `;UNTIL=` / `;COUNT=` templates + * prevent injection of extra `;`-delimited RRULE parts. The assembled string is later + * passed through `ICAL.Recur.fromString` which rejects malformed RRULE values. + * + * @param basePreset - Base RRULE string, e.g. 'FREQ=DAILY' (from RRULE_PRESETS or extracted) + * @param until - Optional 'YYYY-MM-DD' end date + * @param count - Optional occurrence count (integer ≥ 1) + * @param allDay - Whether the event is all-day (controls UNTIL value-type) + * @returns Assembled RRULE string + */ +export function assembleRruleString( + basePreset: string, + until?: string, + count?: number, + allDay?: boolean, +): string { + let s = basePreset + if (count !== undefined) { + // COUNT wins over UNTIL (mutual exclusion) + s += `;COUNT=${count}` + } else if (until) { + const dateDigits = until.replace(/-/g, '') + if (allDay) { + // DATE form for all-day events: YYYYMMDD (RFC 5545 §3.3.10) + s += `;UNTIL=${dateDigits}` + } else { + // DATETIME UTC form for timed events: YYYYMMDDTHHMMSSZ (end of UTC day) + s += `;UNTIL=${dateDigits}T235959Z` + } + } + return s +} + // ── Drain concurrency guard (CR-05) ────────────────────────────────────────── /** @@ -296,6 +347,41 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule = extractRruleString(freshEtagRows[0].rawVevent) } + // 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 + // RRULE, STRIP any existing UNTIL/COUNT, then re-apply the new bound — never naive- + // concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL. + // WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above), + // so the hasExplicitRecurrence branch always takes precedence over preserved RRULE. + let finalRruleString: string | undefined + if (hasExplicitRecurrence) { + // Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted) + finalRruleString = rruleFromPayload + ? assembleRruleString( + rruleFromPayload, + fields.recurrenceUntil, + fields.recurrenceCount, + fields.allDay as boolean, + ) + : undefined + } else if (preservedRrule) { + if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) { + // Series edit with bound change only: strip existing UNTIL/COUNT, then re-apply + const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '') + finalRruleString = assembleRruleString( + strippedPreset, + fields.recurrenceUntil, + fields.recurrenceCount, + fields.allDay as boolean, + ) + } else { + finalRruleString = preservedRrule + } + } else { + finalRruleString = rruleFromPayload + } + const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title as string, @@ -304,7 +390,7 @@ async function dispatchRow(row: OutboxRow): Promise { dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), location: fields.location as string | undefined, description: fields.description as string | undefined, - rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload), + rruleString: finalRruleString, }) response = await updateCalendarEvent( @@ -354,6 +440,39 @@ async function dispatchRow(row: OutboxRow): Promise { typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0 ? fields._preservedRrule : undefined + + // D-06: assemble the final RRULE string with optional UNTIL/COUNT bound. + // CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice). + // recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present. + // If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through). + let finalRruleString: string | undefined + if (hasExplicitRecurrence) { + // Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted) + finalRruleString = rruleFromPayload + ? assembleRruleString( + rruleFromPayload, + fields.recurrenceUntil, + fields.recurrenceCount, + fields.allDay as boolean, + ) + : undefined + } else if (preservedRrule) { + if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) { + // Bound change on preserved RRULE: strip existing UNTIL/COUNT first (Pitfall 3) + const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '') + finalRruleString = assembleRruleString( + strippedPreset, + fields.recurrenceUntil, + fields.recurrenceCount, + fields.allDay as boolean, + ) + } else { + finalRruleString = preservedRrule + } + } else { + finalRruleString = rruleFromPayload + } + const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title as string, @@ -362,7 +481,7 @@ async function dispatchRow(row: OutboxRow): Promise { dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), location: fields.location as string | undefined, description: fields.description as string | undefined, - rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload), + rruleString: finalRruleString, }) // 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 8e5ccfa..b7a2603 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -106,6 +106,10 @@ const eventFieldsSchema = z.object({ description: z.string().max(2000).optional(), recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(), calendarUrl: z.string().url().max(1024).optional(), + // D-06: recurrence bounding (RRULE UNTIL / COUNT) + // T-06-02: max(10) bounds 'YYYY-MM-DD'; int().min(1) prevents zero/negative counts + recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD' → RRULE UNTIL + recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT }) /** sync-status query params. */