fix(03): WR-01 preserve existing RRULE on edit instead of resetting to none
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user