fix(03): WR-01 preserve existing RRULE on edit instead of resetting to none

This commit is contained in:
Lucas Berger
2026-06-09 10:39:06 -04:00
parent f645644853
commit 02aa407764
4 changed files with 80 additions and 17 deletions
+33 -13
View File
@@ -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<DispatchResult> {
} 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<DispatchResult> {
// 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<DispatchResult> {
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,
+23
View File
@@ -43,6 +43,29 @@ export const RRULE_PRESETS: Record<string, string> = {
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<typeof ICAL.parse>
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.
*