diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts new file mode 100644 index 000000000..c86a5ec9a --- /dev/null +++ b/client/src/api/wsEventPolicy.ts @@ -0,0 +1,96 @@ +import type { TrekWsEventName } from '@trek/shared' + +/** + * Client-side handling policy for every event in the shared WS registry + * (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups + * (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts), + * these lists partition the registry exactly — the registry-parity test + * fails if a registry event is missing from all of them, or listed twice. + * A new server event therefore forces an explicit client decision (handle + * it, or add it here) instead of being dropped by a silent `default:`. + */ + +/** + * Events consumed by dedicated listeners outside the tripStore reducer. + * Every entry names real handling code — if that code is removed, remove + * the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot). + */ +export const HANDLED_OUTSIDE_TRIP_STORE = [ + // Collab — Collab/MCollab components + useTripWebSocket's collabFileSync + 'collab:note:created', + 'collab:note:updated', + 'collab:note:deleted', + 'collab:poll:created', + 'collab:poll:voted', + 'collab:poll:closed', + 'collab:poll:deleted', + 'collab:message:created', + 'collab:message:reacted', + 'collab:message:deleted', + // In-app notifications — hooks/useInAppNotificationListener + 'notification:new', + 'notification:updated', + // Collections — pages/collections/useCollections ('collections:' prefix listener) + 'collections:updated', + 'collections:accepted', + 'collections:declined', + 'collections:left', + 'collections:deleted', + 'collections:cancelled', + 'collections:removed', + 'collections:invite', + // Vacay — pages/vacay/useVacay + 'vacay:update', + 'vacay:settings', + 'vacay:accepted', + 'vacay:declined', + 'vacay:cancelled', + 'vacay:dissolved', + 'vacay:invite', + 'vacay:share', + 'vacay:share-removed', + 'vacay:shared-update', + // Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener) + 'journey:trip:synced', + 'journey:entry:created', + 'journey:entry:updated', + 'journey:entry:deleted', + 'journey:entries:reordered', + 'journey:contributor:changed', + // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) + 'import:progress', + 'import:done', + 'import:error', +] as const satisfies readonly TrekWsEventName[] + +/** + * Events the client deliberately does not act on today (state of the world + * when the registry landed — every one of these was already dropped by the + * old silent `default:` branches). Removing an entry means the event is now + * handled somewhere; ADDING an entry is a product decision that a new server + * event should have no client reaction — never add one just to silence the + * registry-parity test. + */ +export const IGNORED_WS_EVENTS = [ + 'assignment:participants', + 'packing:reordered', + 'packing:bag-created', + 'packing:bag-updated', + 'packing:bag-deleted', + 'packing:bag-members-updated', + 'packing:assignees', + 'packing:template-applied', + 'todo:assignees', + 'budget:settlement-created', + 'budget:settlement-updated', + 'budget:settlement-deleted', + 'reservation:positions', + // Accommodations live in page-local planner state; the client refetches + // them off trip:updated date changes, never off these events. + 'accommodation:created', + 'accommodation:updated', + 'accommodation:deleted', + 'trip:deleted', + 'member:added', + 'member:removed', +] as const satisfies readonly TrekWsEventName[] diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 281b33658..594ee81f1 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -1,4 +1,5 @@ import type { StoreApi } from 'zustand' +import type { TrekWsTripEventName } from '@trek/shared' import type { TripStoreState } from '../tripStore' import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' import { offlineDb } from '../../db/offlineDb' @@ -8,6 +9,132 @@ type GetState = StoreApi['getState'] // ── Dexie write-through ─────────────────────────────────────────────────────── +type DexieWriter = (payload: Record, state: TripStoreState) => Promise + +// Shared writer bodies (one function referenced by every event that used to +// share a switch fallthrough group). +const putPlace: DexieWriter = async payload => { + await offlineDb.places.put(payload.place as Place) +} +const writeAssignmentDay: DexieWriter = async (payload, state) => { + const assignment = payload.assignment as Assignment + await _writeDayToDb(assignment.day_id, state) +} +const writePayloadDay: DexieWriter = async (payload, state) => { + await _writeDayToDb(payload.dayId as number, state) +} +const writeDayById: DexieWriter = async (payload, state) => { + const day = payload.day as Day + await _writeDayToDb(day.id, state) +} +const putPackingItem: DexieWriter = async payload => { + await offlineDb.packingItems.put(payload.item as PackingItem) +} +const putTodoItem: DexieWriter = async payload => { + await offlineDb.todoItems.put(payload.item as TodoItem) +} +const putBudgetItem: DexieWriter = async payload => { + await offlineDb.budgetItems.put(payload.item as BudgetItem) +} +// Partial update — read the canonical item from the updated Zustand state. +const putCanonicalBudgetItem: DexieWriter = async (payload, state) => { + const item = state.budgetItems.find(i => i.id === (payload.itemId as number)) + if (item) await offlineDb.budgetItems.put(item) +} +const putReservation: DexieWriter = async payload => { + await offlineDb.reservations.put(payload.reservation as Reservation) +} +const putTripFile: DexieWriter = async payload => { + await offlineDb.tripFiles.put(payload.file as TripFile) +} + +/** + * Per-event IndexedDB write-through, keyed by the shared WS event registry. + * The key set is enumerable on purpose: the registry-parity test asserts every + * registry event is either handled here/in STATE_APPLIERS, handled by a + * dedicated listener elsewhere, or explicitly listed as ignored — a new server + * event without a client decision fails that test instead of dropping silently. + */ +export const DEXIE_WRITERS: Partial> = { + // ── Places ────────────────────────────────────────────────────────────── + 'place:created': putPlace, + 'place:updated': putPlace, + 'place:deleted': async payload => { + await offlineDb.places.delete(payload.placeId as number) + }, + + // ── Assignments (embedded in Day rows) ────────────────────────────────── + // Read the already-updated Day from the Zustand state and persist it. + 'assignment:created': writeAssignmentDay, + 'assignment:updated': writeAssignmentDay, + 'assignment:deleted': writePayloadDay, + 'assignment:moved': async (payload, state) => { + const movedAssignment = payload.assignment as Assignment + await Promise.all([ + _writeDayToDb(payload.oldDayId as number, state), + _writeDayToDb(movedAssignment.day_id, state), + ]) + }, + 'assignment:reordered': writePayloadDay, + + // ── Days ───────────────────────────────────────────────────────────────── + 'day:created': writeDayById, + 'day:updated': writeDayById, + 'day:deleted': async payload => { + await offlineDb.days.delete(payload.dayId as number) + }, + + // ── Day notes (embedded in Day rows) ───────────────────────────────────── + 'dayNote:created': writePayloadDay, + 'dayNote:updated': writePayloadDay, + 'dayNote:deleted': writePayloadDay, + + // ── Packing ────────────────────────────────────────────────────────────── + 'packing:created': putPackingItem, + 'packing:updated': putPackingItem, + 'packing:deleted': async payload => { + await offlineDb.packingItems.delete(payload.itemId as number) + }, + + // ── Todo ───────────────────────────────────────────────────────────────── + 'todo:created': putTodoItem, + 'todo:updated': putTodoItem, + 'todo:deleted': async payload => { + await offlineDb.todoItems.delete(payload.itemId as number) + }, + + // ── Budget ─────────────────────────────────────────────────────────────── + 'budget:created': putBudgetItem, + 'budget:updated': putBudgetItem, + 'budget:deleted': async payload => { + await offlineDb.budgetItems.delete(payload.itemId as number) + }, + 'budget:members-updated': putCanonicalBudgetItem, + 'budget:member-paid-updated': putCanonicalBudgetItem, + 'budget:reordered': async (_payload, state) => { + await offlineDb.budgetItems.bulkPut(state.budgetItems) + }, + + // ── Reservations ───────────────────────────────────────────────────────── + 'reservation:created': putReservation, + 'reservation:updated': putReservation, + 'reservation:deleted': async payload => { + await offlineDb.reservations.delete(payload.reservationId as number) + }, + + // ── Trip ───────────────────────────────────────────────────────────────── + 'trip:updated': async payload => { + await offlineDb.trips.put(payload.trip as Trip) + }, + + // ── Files ───────────────────────────────────────────────────────────────── + 'file:created': putTripFile, + 'file:updated': putTripFile, + 'file:deleted': async payload => { + await offlineDb.tripFiles.delete(payload.fileId as number) + }, +} + /** * Persist remote event to IndexedDB so the data is available offline. * Fire-and-forget: errors are swallowed to never block the Zustand update. @@ -20,123 +147,7 @@ function writeToDexie( ): void { ;(async () => { try { - switch (type) { - // ── Places ────────────────────────────────────────────────────────── - case 'place:created': - case 'place:updated': - await offlineDb.places.put(payload.place as Place) - break - case 'place:deleted': - await offlineDb.places.delete(payload.placeId as number) - break - - // ── Assignments (embedded in Day rows) ────────────────────────────── - // Read the already-updated Day from the Zustand state and persist it. - case 'assignment:created': - case 'assignment:updated': { - const assignment = payload.assignment as Assignment - await _writeDayToDb(assignment.day_id, state) - break - } - case 'assignment:deleted': { - await _writeDayToDb(payload.dayId as number, state) - break - } - case 'assignment:moved': { - const movedAssignment = payload.assignment as Assignment - await Promise.all([ - _writeDayToDb(payload.oldDayId as number, state), - _writeDayToDb(movedAssignment.day_id, state), - ]) - break - } - case 'assignment:reordered': - await _writeDayToDb(payload.dayId as number, state) - break - - // ── Days ───────────────────────────────────────────────────────────── - case 'day:created': - case 'day:updated': { - const day = payload.day as Day - await _writeDayToDb(day.id, state) - break - } - case 'day:deleted': - await offlineDb.days.delete(payload.dayId as number) - break - - // ── Day notes (embedded in Day rows) ───────────────────────────────── - case 'dayNote:created': - case 'dayNote:updated': - case 'dayNote:deleted': - await _writeDayToDb(payload.dayId as number, state) - break - - // ── Packing ────────────────────────────────────────────────────────── - case 'packing:created': - case 'packing:updated': - await offlineDb.packingItems.put(payload.item as PackingItem) - break - case 'packing:deleted': - await offlineDb.packingItems.delete(payload.itemId as number) - break - - // ── Todo ───────────────────────────────────────────────────────────── - case 'todo:created': - case 'todo:updated': - await offlineDb.todoItems.put(payload.item as TodoItem) - break - case 'todo:deleted': - await offlineDb.todoItems.delete(payload.itemId as number) - break - - // ── Budget ─────────────────────────────────────────────────────────── - case 'budget:created': - case 'budget:updated': - await offlineDb.budgetItems.put(payload.item as BudgetItem) - break - case 'budget:deleted': - await offlineDb.budgetItems.delete(payload.itemId as number) - break - case 'budget:members-updated': - case 'budget:member-paid-updated': - case 'budget:reordered': { - // Partial update — read canonical item(s) from updated Zustand state - if (type === 'budget:reordered') { - await offlineDb.budgetItems.bulkPut(state.budgetItems) - } else { - const item = state.budgetItems.find(i => i.id === (payload.itemId as number)) - if (item) await offlineDb.budgetItems.put(item) - } - break - } - - // ── Reservations ───────────────────────────────────────────────────── - case 'reservation:created': - case 'reservation:updated': - await offlineDb.reservations.put(payload.reservation as Reservation) - break - case 'reservation:deleted': - await offlineDb.reservations.delete(payload.reservationId as number) - break - - // ── Trip ───────────────────────────────────────────────────────────── - case 'trip:updated': - await offlineDb.trips.put(payload.trip as Trip) - break - - // ── Files ───────────────────────────────────────────────────────────── - case 'file:created': - case 'file:updated': - await offlineDb.tripFiles.put(payload.file as TripFile) - break - case 'file:deleted': - await offlineDb.tripFiles.delete(payload.fileId as number) - break - - default: - break - } + await DEXIE_WRITERS[type as TrekWsTripEventName]?.(payload, state) } catch { // Dexie write failures are non-fatal — online state is source of truth } @@ -156,6 +167,301 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise, state: TripStoreState) => Partial + +/** + * Per-event immutable state update (create/update/delete for the relevant + * entity), keyed by the shared WS event registry. An event with no applier is + * a no-op here — but only because the registry-parity test proves it is either + * handled by a dedicated listener elsewhere or explicitly ignored. + */ +export const STATE_APPLIERS: Partial> = { + // Places + 'place:created': (payload, state) => { + if (state.places.some(p => p.id === (payload.place as Place).id)) return {} + return { places: [payload.place as Place, ...state.places] } + }, + 'place:updated': (payload, state) => ({ + places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p), + assignments: Object.fromEntries( + Object.entries(state.assignments).map(([dayId, items]) => [ + dayId, + items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a) + ]) + ), + }), + 'place:deleted': (payload, state) => ({ + places: state.places.filter(p => p.id !== payload.placeId), + assignments: Object.fromEntries( + Object.entries(state.assignments).map(([dayId, items]) => [ + dayId, + items.filter(a => a.place?.id !== payload.placeId) + ]) + ), + }), + + // Assignments + 'assignment:created': (payload, state) => { + const incoming = payload.assignment as Assignment + const dayKey = String(incoming.day_id) + const existing = state.assignments[dayKey] || [] + const placeId = incoming.place?.id ?? incoming.place_id + + // Already have this exact assignment id → duplicate broadcast or the + // echo of an already-committed assignment. No-op. + if (existing.some(a => a.id === incoming.id)) return {} + + // Reconcile our own optimistic create: replace the temp (negative-id) + // assignment of the same place on this day with the real one. Guarded on + // a real placeId so an assignment with no place can never collapse onto + // another place-less one (undefined === undefined). + if (placeId != null) { + const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId) + if (tempIdx !== -1) { + const next = existing.slice() + next[tempIdx] = incoming + return { assignments: { ...state.assignments, [dayKey]: next } } + } + } + + // Genuinely new — including a legitimate second assignment of a place + // already on this day (no temp version to reconcile). Append. + return { + assignments: { + ...state.assignments, + [dayKey]: [...existing, incoming], + } + } + }, + 'assignment:updated': (payload, state) => { + const dayKey = String((payload.assignment as Assignment).day_id) + return { + assignments: { + ...state.assignments, + [dayKey]: (state.assignments[dayKey] || []).map(a => + a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a + ), + } + } + }, + 'assignment:deleted': (payload, state) => { + const dayKey = String(payload.dayId) + return { + assignments: { + ...state.assignments, + [dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId), + } + } + }, + 'assignment:moved': (payload, state) => { + const oldKey = String(payload.oldDayId) + const newKey = String(payload.newDayId) + const movedAssignment = payload.assignment as Assignment + return { + assignments: { + ...state.assignments, + [oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id), + [newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment], + } + } + }, + 'assignment:reordered': (payload, state) => { + const dayKey = String(payload.dayId) + const currentItems = state.assignments[dayKey] || [] + const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || [] + const reordered = orderedIds.map((id, idx) => { + const item = currentItems.find(a => a.id === id) + return item ? { ...item, order_index: idx } : null + }).filter((item): item is Assignment => item !== null) + return { + assignments: { + ...state.assignments, + [dayKey]: reordered, + } + } + }, + + // Days + 'day:created': (payload, state) => { + if (state.days.some(d => d.id === (payload.day as Day).id)) return {} + return { days: [...state.days, payload.day as Day] } + }, + 'day:updated': (payload, state) => ({ + days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d), + }), + 'day:deleted': (payload, state) => { + const removedDayId = String(payload.dayId) + const newAssignments = { ...state.assignments } + delete newAssignments[removedDayId] + const newDayNotes = { ...state.dayNotes } + delete newDayNotes[removedDayId] + return { + days: state.days.filter(d => d.id !== payload.dayId), + assignments: newAssignments, + dayNotes: newDayNotes, + } + }, + 'day:reordered': (payload, state) => { + // Apply the new order instantly when we know all ids; the authoritative + // dates + re-stamped booking times are pulled by the refresh below. + const orderedIds = payload.orderedIds as number[] | undefined + if (!orderedIds || orderedIds.length !== state.days.length) return {} + const byId = new Map(state.days.map(d => [d.id, d])) + if (!orderedIds.every(id => byId.has(id))) return {} + return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) } + }, + + // Day Notes + 'dayNote:created': (payload, state) => { + const dayKey = String(payload.dayId) + const existingNotes = (state.dayNotes[dayKey] || []) + if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {} + return { + dayNotes: { + ...state.dayNotes, + [dayKey]: [...existingNotes, payload.note as DayNote], + } + } + }, + 'dayNote:updated': (payload, state) => { + const dayKey = String(payload.dayId) + return { + dayNotes: { + ...state.dayNotes, + [dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n), + } + } + }, + 'dayNote:deleted': (payload, state) => { + const dayKey = String(payload.dayId) + return { + dayNotes: { + ...state.dayNotes, + [dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId), + } + } + }, + + // Packing + 'packing:created': (payload, state) => { + if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {} + return { packingItems: [...state.packingItems, payload.item as PackingItem] } + }, + 'packing:updated': (payload, state) => ({ + packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i), + }), + 'packing:deleted': (payload, state) => ({ + packingItems: state.packingItems.filter(i => i.id !== payload.itemId), + }), + + // Todo + 'todo:created': (payload, state) => { + if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {} + return { todoItems: [...state.todoItems, payload.item as TodoItem] } + }, + 'todo:updated': (payload, state) => ({ + todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i), + }), + 'todo:deleted': (payload, state) => ({ + todoItems: state.todoItems.filter(i => i.id !== payload.itemId), + }), + + // Budget + 'budget:created': (payload, state) => { + if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {} + return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] } + }, + 'budget:updated': (payload, state) => ({ + budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i), + }), + 'budget:deleted': (payload, state) => ({ + budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId), + }), + 'budget:members-updated': (payload, state) => ({ + budgetItems: state.budgetItems.map(i => + i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i + ), + }), + 'budget:member-paid-updated': (payload, state) => ({ + budgetItems: state.budgetItems.map(i => + i.id === payload.itemId + // `paid` arrives over the wire as the raw value the server emits; + // it's stored verbatim. The member type models it as a number, so + // narrow without changing the value. + ? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) } + : i + ), + }), + 'budget:reordered': (payload, state) => { + if (payload.orderedIds) { + const orderedIds = payload.orderedIds as number[] + const byId = new Map(state.budgetItems.map(i => [i.id, i])) + const reordered = orderedIds.map((id, idx): BudgetItem | null => { + const item = byId.get(id) + return item ? { ...item, sort_order: idx } : null + }).filter((i): i is BudgetItem => i !== null) + const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id)) + return { budgetItems: [...reordered, ...remaining] } + } + if (payload.orderedCategories) { + const orderedCategories = payload.orderedCategories as string[] + const grouped = new Map() + for (const item of state.budgetItems) { + const cat = item.category || 'Other' + if (!grouped.has(cat)) grouped.set(cat, []) + grouped.get(cat)!.push(item) + } + const reordered: BudgetItem[] = [] + for (const cat of orderedCategories) { + const items = grouped.get(cat) + if (items) reordered.push(...items) + } + for (const [cat, items] of grouped) { + if (!orderedCategories.includes(cat)) reordered.push(...items) + } + return { budgetItems: reordered } + } + return {} + }, + + // Reservations + 'reservation:created': (payload, state) => { + if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {} + return { reservations: [payload.reservation as Reservation, ...state.reservations] } + }, + 'reservation:updated': (payload, state) => ({ + reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r), + }), + 'reservation:travelers-updated': (payload, state) => ({ + reservations: state.reservations.map(r => r.id === (payload.reservationId as number) + ? { ...r, travelers: payload.travelers as Reservation['travelers'] } : r), + }), + 'reservation:deleted': (payload, state) => ({ + reservations: state.reservations.filter(r => r.id !== payload.reservationId), + }), + + // Trip + 'trip:updated': payload => ({ trip: payload.trip as Trip }), + + // Files + 'file:created': (payload, state) => { + if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {} + return { files: [payload.file as TripFile, ...state.files] } + }, + 'file:updated': (payload, state) => ({ + files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f), + }), + 'file:deleted': (payload, state) => ({ + files: state.files.filter(f => f.id !== payload.fileId), + }), + + // Memories / Photos + 'memories:updated': payload => { + window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload })) + return {} + }, +} + /** * Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators. * Each event type maps to an immutable state update (create/update/delete) for the relevant entity. @@ -168,306 +474,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket // date-change check made after it would compare the new trip against itself. const prevTrip = get().trip - set(state => { - switch (type) { - // Places - case 'place:created': - if (state.places.some(p => p.id === (payload.place as Place).id)) return {} - return { places: [payload.place as Place, ...state.places] } - case 'place:updated': - return { - places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a) - ]) - ), - } - case 'place:deleted': - return { - places: state.places.filter(p => p.id !== payload.placeId), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.filter(a => a.place?.id !== payload.placeId) - ]) - ), - } - - // Assignments - case 'assignment:created': { - const incoming = payload.assignment as Assignment - const dayKey = String(incoming.day_id) - const existing = state.assignments[dayKey] || [] - const placeId = incoming.place?.id ?? incoming.place_id - - // Already have this exact assignment id → duplicate broadcast or the - // echo of an already-committed assignment. No-op. - if (existing.some(a => a.id === incoming.id)) return {} - - // Reconcile our own optimistic create: replace the temp (negative-id) - // assignment of the same place on this day with the real one. Guarded on - // a real placeId so an assignment with no place can never collapse onto - // another place-less one (undefined === undefined). - if (placeId != null) { - const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId) - if (tempIdx !== -1) { - const next = existing.slice() - next[tempIdx] = incoming - return { assignments: { ...state.assignments, [dayKey]: next } } - } - } - - // Genuinely new — including a legitimate second assignment of a place - // already on this day (no temp version to reconcile). Append. - return { - assignments: { - ...state.assignments, - [dayKey]: [...existing, incoming], - } - } - } - case 'assignment:updated': { - const dayKey = String((payload.assignment as Assignment).day_id) - return { - assignments: { - ...state.assignments, - [dayKey]: (state.assignments[dayKey] || []).map(a => - a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a - ), - } - } - } - case 'assignment:deleted': { - const dayKey = String(payload.dayId) - return { - assignments: { - ...state.assignments, - [dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId), - } - } - } - case 'assignment:moved': { - const oldKey = String(payload.oldDayId) - const newKey = String(payload.newDayId) - const movedAssignment = payload.assignment as Assignment - return { - assignments: { - ...state.assignments, - [oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id), - [newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment], - } - } - } - case 'assignment:reordered': { - const dayKey = String(payload.dayId) - const currentItems = state.assignments[dayKey] || [] - const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || [] - const reordered = orderedIds.map((id, idx) => { - const item = currentItems.find(a => a.id === id) - return item ? { ...item, order_index: idx } : null - }).filter((item): item is Assignment => item !== null) - return { - assignments: { - ...state.assignments, - [dayKey]: reordered, - } - } - } - - // Days - case 'day:created': - if (state.days.some(d => d.id === (payload.day as Day).id)) return {} - return { days: [...state.days, payload.day as Day] } - case 'day:updated': - return { - days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d), - } - case 'day:deleted': { - const removedDayId = String(payload.dayId) - const newAssignments = { ...state.assignments } - delete newAssignments[removedDayId] - const newDayNotes = { ...state.dayNotes } - delete newDayNotes[removedDayId] - return { - days: state.days.filter(d => d.id !== payload.dayId), - assignments: newAssignments, - dayNotes: newDayNotes, - } - } - case 'day:reordered': { - // Apply the new order instantly when we know all ids; the authoritative - // dates + re-stamped booking times are pulled by the refresh below. - const orderedIds = payload.orderedIds as number[] | undefined - if (!orderedIds || orderedIds.length !== state.days.length) return {} - const byId = new Map(state.days.map(d => [d.id, d])) - if (!orderedIds.every(id => byId.has(id))) return {} - return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) } - } - - // Day Notes - case 'dayNote:created': { - const dayKey = String(payload.dayId) - const existingNotes = (state.dayNotes[dayKey] || []) - if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {} - return { - dayNotes: { - ...state.dayNotes, - [dayKey]: [...existingNotes, payload.note as DayNote], - } - } - } - case 'dayNote:updated': { - const dayKey = String(payload.dayId) - return { - dayNotes: { - ...state.dayNotes, - [dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n), - } - } - } - case 'dayNote:deleted': { - const dayKey = String(payload.dayId) - return { - dayNotes: { - ...state.dayNotes, - [dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId), - } - } - } - - // Packing - case 'packing:created': - if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {} - return { packingItems: [...state.packingItems, payload.item as PackingItem] } - case 'packing:updated': - return { - packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i), - } - case 'packing:deleted': - return { - packingItems: state.packingItems.filter(i => i.id !== payload.itemId), - } - - // Todo - case 'todo:created': - if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {} - return { todoItems: [...state.todoItems, payload.item as TodoItem] } - case 'todo:updated': - return { - todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i), - } - case 'todo:deleted': - return { - todoItems: state.todoItems.filter(i => i.id !== payload.itemId), - } - - // Budget - case 'budget:created': - if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {} - return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] } - case 'budget:updated': - return { - budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i), - } - case 'budget:deleted': - return { - budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId), - } - case 'budget:members-updated': - return { - budgetItems: state.budgetItems.map(i => - i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i - ), - } - case 'budget:member-paid-updated': - return { - budgetItems: state.budgetItems.map(i => - i.id === payload.itemId - // `paid` arrives over the wire as the raw value the server emits; - // it's stored verbatim. The member type models it as a number, so - // narrow without changing the value. - ? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) } - : i - ), - } - case 'budget:reordered': { - if (payload.orderedIds) { - const orderedIds = payload.orderedIds as number[] - const byId = new Map(state.budgetItems.map(i => [i.id, i])) - const reordered = orderedIds.map((id, idx): BudgetItem | null => { - const item = byId.get(id) - return item ? { ...item, sort_order: idx } : null - }).filter((i): i is BudgetItem => i !== null) - const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id)) - return { budgetItems: [...reordered, ...remaining] } - } - if (payload.orderedCategories) { - const orderedCategories = payload.orderedCategories as string[] - const grouped = new Map() - for (const item of state.budgetItems) { - const cat = item.category || 'Other' - if (!grouped.has(cat)) grouped.set(cat, []) - grouped.get(cat)!.push(item) - } - const reordered: BudgetItem[] = [] - for (const cat of orderedCategories) { - const items = grouped.get(cat) - if (items) reordered.push(...items) - } - for (const [cat, items] of grouped) { - if (!orderedCategories.includes(cat)) reordered.push(...items) - } - return { budgetItems: reordered } - } - return {} - } - - // Reservations - case 'reservation:created': - if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {} - return { reservations: [payload.reservation as Reservation, ...state.reservations] } - case 'reservation:updated': - return { - reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r), - } - case 'reservation:travelers-updated': - return { - reservations: state.reservations.map(r => r.id === (payload.reservationId as number) - ? { ...r, travelers: payload.travelers as Reservation['travelers'] } : r), - } - case 'reservation:deleted': - return { - reservations: state.reservations.filter(r => r.id !== payload.reservationId), - } - - // Trip - case 'trip:updated': - return { trip: payload.trip as Trip } - - // Files - case 'file:created': - if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {} - return { files: [payload.file as TripFile, ...state.files] } - case 'file:updated': - return { - files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f), - } - case 'file:deleted': - return { - files: state.files.filter(f => f.id !== payload.fileId), - } - - // Memories / Photos - case 'memories:updated': - window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload })) - return {} - - default: - return {} - } - }) + set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {}) // A reorder/insert re-pins dates and re-stamps booking times server-side, so // pull the authoritative days + reservations for collaborators. diff --git a/client/src/types.ts b/client/src/types.ts index d8976283f..bc7fab35a 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -6,6 +6,8 @@ // each documented against the producing service). Re-exported here so the rest // of the client keeps importing from '../types' unchanged. import type { + TrekWsEventName, + TrekWsPluginEventName, Trip, TripMember, Day, @@ -256,9 +258,14 @@ export interface AppConfig { // Translation function type export type TranslationFn = (key: string, params?: Record) => string -// WebSocket event type +// WebSocket event type — `type` is derived from the shared WS event registry +// (TREK_WS_EVENTS): a registered name, the reserved plugin namespace, or (via +// the `string & {}` widening) a transport control frame the registry +// deliberately excludes (welcome/joined/left/error). Payload fields stay +// index-typed at this boundary; per-event payload contracts live in +// TrekWsPayload from @trek/shared. export interface WebSocketEvent { - type: string + type: TrekWsEventName | TrekWsPluginEventName | (string & {}) [key: string]: unknown } diff --git a/client/tests/unit/remoteEventHandler/registry-parity.test.ts b/client/tests/unit/remoteEventHandler/registry-parity.test.ts new file mode 100644 index 000000000..6044d51a2 --- /dev/null +++ b/client/tests/unit/remoteEventHandler/registry-parity.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared'; +import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler'; +import { HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy'; + +/** + * The exhaustiveness ratchet (roadmap: WS event contract registry). Every + * event in the shared registry must be classified on the client — applied by + * the tripStore reducer, consumed by a dedicated listener, or explicitly + * ignored. A server-side event added without a client decision turns this + * test red instead of vanishing into the old silent `default:` branches. + */ + +const handledInStore = new Set([ + ...Object.keys(STATE_APPLIERS), + ...Object.keys(DEXIE_WRITERS), +]); +const handledElsewhere = new Set(HANDLED_OUTSIDE_TRIP_STORE); +const ignored = new Set(IGNORED_WS_EVENTS); + +describe('remoteEventHandler > registry parity', () => { + it('FE-WSEVT-REGISTRY-001: every registry event is handled or explicitly ignored', () => { + const unclassified = TREK_WS_EVENT_NAMES.filter( + name => !handledInStore.has(name) && !handledElsewhere.has(name) && !ignored.has(name), + ); + expect( + unclassified, + `New WS event(s) with no client decision: ${unclassified.join(', ')}. ` + + 'Handle them (remoteEventHandler / a dedicated listener) or add them to ' + + 'IGNORED_WS_EVENTS in src/api/wsEventPolicy.ts as a deliberate choice.', + ).toEqual([]); + }); + + it('FE-WSEVT-REGISTRY-002: the three buckets are disjoint (exactly one decision per event)', () => { + const doubleClassified = TREK_WS_EVENT_NAMES.filter(name => { + const buckets = + Number(handledInStore.has(name)) + Number(handledElsewhere.has(name)) + Number(ignored.has(name)); + return buckets > 1; + }); + expect( + doubleClassified, + `Event(s) classified in more than one bucket: ${doubleClassified.join(', ')}`, + ).toEqual([]); + }); + + it('FE-WSEVT-REGISTRY-003: no stale names — every classified event still exists in the registry', () => { + const registry = new Set(TREK_WS_EVENT_NAMES); + const stale = [...handledInStore, ...handledElsewhere, ...ignored].filter( + name => !registry.has(name), + ); + expect(stale, `Name(s) not in the shared registry (renamed or removed?): ${stale.join(', ')}`).toEqual([]); + }); + + it('FE-WSEVT-REGISTRY-004: the tripStore lookups only handle trip-scoped events', () => { + const tripScoped = new Set(TREK_WS_TRIP_EVENT_NAMES); + const misScoped = [...handledInStore].filter(name => !tripScoped.has(name)); + expect(misScoped, `User-scoped event(s) in the tripStore lookups: ${misScoped.join(', ')}`).toEqual([]); + }); + + it('FE-WSEVT-REGISTRY-005: every Dexie write-through event also has a state applier', () => { + // writeToDexie reads the post-set() Zustand state, so a Dexie writer for an + // event the reducer ignores would persist stale state. + const writersWithoutApplier = Object.keys(DEXIE_WRITERS).filter( + name => !(name in STATE_APPLIERS), + ); + expect(writersWithoutApplier).toEqual([]); + }); +});