diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 5e45a21..f4dc79f 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -434,10 +434,23 @@ export async function runOutboxDrain(): Promise { const result = await dispatchRow(row) if (result.conflict) { + // WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict. + // For a move (D-04) the create runs first; on 412 the paired delete is later + // marked failed and the original event survives — so this is NOT a "the event + // changed elsewhere" conflict, it is "the move could not be applied". The PWA + // set lastSyncedUid to the NEW (move) uid, whose only outbox row is this failed + // create, so without a distinct message the user sees the wrong conflict copy + // and has no cue to retry. Emit a move-specific lastError that does NOT contain + // '412' so the toast routes it to the dedicated move-failed copy instead of the + // generic etag-conflict copy. + const isMoveCreate = !!row.groupId && row.operation === 'create' + const conflictError = isMoveCreate + ? 'move-failed: the event could not be moved — re-open it and save again' + : (result.error ?? '412 conflict') // 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08) await db .update(calendarOutbox) - .set({ status: 'failed', lastError: result.error ?? '412 conflict' }) + .set({ status: 'failed', lastError: conflictError }) .where(eq(calendarOutbox.id, row.id)) await triggerTargetedResync(row.calendarUrl, row.userId) diff --git a/apps/pwa/src/components/SyncStateToast.tsx b/apps/pwa/src/components/SyncStateToast.tsx index a82f704..56b3bec 100644 --- a/apps/pwa/src/components/SyncStateToast.tsx +++ b/apps/pwa/src/components/SyncStateToast.tsx @@ -50,7 +50,12 @@ export function SyncStateToast() { }) const status = data?.status - const isConflict = status === 'failed' && data?.error?.includes('412') + // WR-06: an edit-as-move whose create hits 412 dead-ends with no retry path (the + // original event is preserved, but the new uid's only outbox row is failed). The + // worker tags that case with a 'move-failed:' lastError so we can show distinct copy + // guiding the user to re-open and re-save, rather than the etag-conflict copy. + const isMoveFailed = status === 'failed' && !!data?.error?.startsWith('move-failed') + const isConflict = status === 'failed' && !isMoveFailed && data?.error?.includes('412') const isPersistent = status === 'failed' || status === 'dead' // Invalidate events on done OR on conflict (D-06/D-08) @@ -113,6 +118,16 @@ export function SyncStateToast() { aria-hidden="true" /> ) + } else if (isMoveFailed) { + // WR-06: the move could not be applied; the original event is unchanged. + copy = "Couldn't move the event. Open it and save again." + icon = ( +