diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 1d9c96d..5e45a21 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -31,7 +31,7 @@ import { createFastmailClient } from './client.js' import { decryptPassword } from './crypto.js' import { syncCalendar } from './sync.js' import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js' -import { buildVeventString, RRULE_PRESETS } from './vevent.js' +import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js' import type { FastmailClient } from './client.js' // ── Constants (D-07) ──────────────────────────────────────────────────────── @@ -183,16 +183,19 @@ async function dispatchRow(row: OutboxRow): Promise { } catch { return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' } } - const { icsString } = buildVeventString({ - uid: row.uid, - summary: fields.title as string, - allDay: fields.allDay as boolean, - dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string), - 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: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] : undefined, - }) + // WR-01: recurrence preservation. The PWA omits `recurrence` from an edit payload + // (it cannot read the existing RRULE — not in the occurrence contract, D-03), so on + // update we must NOT rebuild the VEVENT with no RRULE — that would silently convert a + // recurring series into a single event. When the payload carries no explicit + // recurrence, fall back to the RRULE already stored in calendarEvents.rawVevent. + // An explicit recurrence value (including 'none') still overrides — that is a + // 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') + const rruleFromPayload = + fields.recurrence && fields.recurrence !== 'none' + ? RRULE_PRESETS[fields.recurrence as string] + : undefined // WR-02: re-read the freshest etag from calendarEvents just before PUT. // Rapid successive edits to the same uid enqueue multiple update rows, each // carrying the etag at enqueue time. If a prior edit succeeded and triggered @@ -210,7 +213,7 @@ async function dispatchRow(row: OutboxRow): Promise { // calendarUrl so the freshest etag belongs to the writing member. let etagForPut: string | null = row.etag ?? null const freshEtagRows = (await db - .select({ etag: calendarEvents.etag }) + .select({ etag: calendarEvents.etag, rawVevent: calendarEvents.rawVevent }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where( @@ -220,11 +223,28 @@ async function dispatchRow(row: OutboxRow): Promise { eq(calendars.url, row.calendarUrl), ), ) - .limit(1)) as Array<{ etag: string | null }> + .limit(1)) as Array<{ etag: string | null; rawVevent: string | null }> if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) { etagForPut = freshEtagRows[0].etag } + // WR-01: when the edit payload carries no explicit recurrence, preserve the RRULE + // already on the stored event so an edit does not strip a recurring series. + if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { + preservedRrule = extractRruleString(freshEtagRows[0].rawVevent) + } + + const { icsString } = buildVeventString({ + uid: row.uid, + summary: fields.title as string, + allDay: fields.allDay as boolean, + dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string), + 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), + }) + response = await updateCalendarEvent( client, row.calendarObjectUrl, diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts index de023c6..d01167a 100644 --- a/apps/api/src/broker/vevent.ts +++ b/apps/api/src/broker/vevent.ts @@ -43,6 +43,29 @@ export const RRULE_PRESETS: Record = { yearly: 'FREQ=YEARLY', } +/** + * 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 { + parsed = ICAL.parse(rawVevent) + } catch { + return undefined + } + 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. * diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 7a427ae..87037a5 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -138,7 +138,10 @@ export interface CreateEventPayload { allDay: boolean start: string // 'YYYY-MM-DD' for allDay; ISO 8601 for timed end: string // same format as start - recurrence: RecurrencePreset + // WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve + // the event's existing RRULE (the occurrence contract does not expose recurrence, so + // the form cannot echo it back without silently resetting it to 'none'). + recurrence?: RecurrencePreset location?: string description?: string calendarUrl?: string // omit to use the member's default writable calendar (D-01) diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index 5635fc9..a6e272f 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -305,12 +305,19 @@ export function EventForm() { endTime, ) + // WR-01: on EDIT, omit `recurrence` from the payload. The occurrence/expand contract + // does not expose the event's existing recurrence (D-03), so the form cannot know it + // and would otherwise send 'none' — silently stripping the RRULE and converting a + // recurring series into a single event. Omitting the field signals "unchanged"; the + // outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On + // CREATE the user explicitly chose a recurrence, so it is always sent. + const isEdit = eventFormMode === 'edit' && !!eventFormUid const payload: CreateEventPayload = { title: title.trim(), allDay, start: serializedStart, end: serializedEnd, - recurrence, + ...(isEdit ? {} : { recurrence }), ...(location.trim() ? { location: location.trim() } : {}), ...(description.trim() ? { description: description.trim() } : {}), ...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}), @@ -702,7 +709,11 @@ export function EventForm() { )} - {/* Recurrence picker (D-11: whole-series only) */} + {/* Recurrence picker (D-11: whole-series only). + WR-01: disabled in edit mode — the occurrence contract does not expose the + event's recurrence, so the form cannot show/change it without risking a + silent reset. Editing recurrence is deferred until the contract exposes it; + the existing RRULE is preserved server-side on edit. */}