Files
TREK/shared/src/realtime/events.schema.ts
T
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
2026-09-18 19:23:07 +02:00

448 lines
23 KiB
TypeScript

import { roadtripDayBoundaryListSchema } from '../roadtrip/day-boundary.schema';
import { roadtripPreferencesSchema } from '../roadtrip/preferences.schema';
import { z } from 'zod';
/**
* WS event contract registry — the single source of truth for every realtime
* event TREK broadcasts (roadmap: "Tokenization & registries", paired with the
* server's injectable RealtimeService facade).
*
* Wire envelopes (see server/src/websocket.ts):
* - Trip-scoped (`scope: 'trip'`, sent via `broadcast`): the transport wraps
* the payload as `{ type: <event name>, tripId, ...payload }` — `tripId` is
* always injected by the transport and is NOT part of the payload schema.
* - User-scoped (`scope: 'user'`, sent via `broadcastToUser`): the payload is
* sent verbatim and carries its own `type: <event name>` discriminator; the
* schemas below describe the fields BESIDES `type`.
* - MCP-originated broadcasts add a `_source: 'mcp'` marker key to the payload
* (server/src/mcp/tools/_shared.ts). Zod object schemas strip unknown keys,
* so it needs no per-event modeling.
*
* Deliberately NOT in the registry:
* - The dynamic plugin namespace: trip broadcasts force-namespaced to
* `plugin:<pluginId>:<event>` and user messages typed `plugin:<pluginId>`
* (plugin-host-deps.factory.ts). Names and payloads are plugin-chosen; the
* `plugin:` prefix is the reserved escape hatch (see the template-literal
* types below) and registry names must never collide with it.
* - Transport control frames sent directly over the socket, not through
* broadcast: `welcome`, `joined`, `left`, `error` (server→client) and
* `join`, `leave` (client→server). They belong to the transport, which is
* deliberately-late roadmap work.
*
* Payload schemas are transcribed from the actual server call sites (server
* shape wins over client expectations — parity). Entity rows are `z.unknown()`
* scaffolding on purpose: the key structure and event names are the contract
* being pinned here; row shapes belong to their domain schemas and tighten
* opportunistically. Where the same event is emitted with divergent shapes
* today, the schema is the union of the real shapes and the divergence is
* flagged with a DRIFT comment — do not "fix" a drift here without fixing the
* emitting call site first.
*/
/** An entity row whose shape is owned by its domain schema (scaffolding). */
const entity = z.unknown();
/** Ids cross the wire as numbers or numeric strings depending on the emitting route. */
const id = z.union([z.number(), z.string()]);
const idList = z.array(id);
/**
* A "something changed, refetch" ping — no declared payload fields. Loose on
* purpose (zod v4 infers a plain `z.object({})` as `Record<string, never>`,
* which would make `{ type } & payload` unsatisfiable for user-scoped events
* whose wire payload is exactly `{ type }`).
*/
const empty = z.looseObject({});
export interface TrekWsEventContract {
scope: 'trip' | 'user';
payload: z.ZodType;
}
export const TREK_WS_EVENTS = {
// ── Places ────────────────────────────────────────────────────────────────
'place:created': { scope: 'trip', payload: z.object({ place: entity }) },
'place:updated': { scope: 'trip', payload: z.object({ place: entity }) },
'place:deleted': { scope: 'trip', payload: z.object({ placeId: id }) },
// ── Assignments ──────────────────────────────────────────────────────────
'assignment:created': { scope: 'trip', payload: z.object({ assignment: entity }) },
'assignment:updated': { scope: 'trip', payload: z.object({ assignment: entity }) },
'assignment:deleted': { scope: 'trip', payload: z.object({ assignmentId: id, dayId: id }) },
// (MCP drift fixed 2026-07-29: move_assignment now includes newDayId and
// reorder_day_assignments sends orderedIds, matching REST.)
'assignment:moved': {
scope: 'trip',
payload: z.object({ assignment: entity, oldDayId: id, newDayId: id }),
},
'assignment:reordered': {
scope: 'trip',
payload: z.object({ dayId: id, orderedIds: idList }),
},
'assignment:participants': {
scope: 'trip',
payload: z.object({ assignmentId: id, participants: z.array(z.unknown()) }),
},
// ── Days ─────────────────────────────────────────────────────────────────
'day:created': { scope: 'trip', payload: z.object({ day: entity }) },
'day:updated': { scope: 'trip', payload: z.object({ day: entity }) },
// (MCP drift fixed 2026-07-29: delete_day now sends { dayId }, matching REST.)
'day:deleted': { scope: 'trip', payload: z.object({ dayId: id }) },
// Two legitimate shapes: a plain reorder sends { orderedIds }; the
// insert-at-position create path re-uses the event with { day }.
'day:reordered': {
scope: 'trip',
payload: z.union([z.object({ orderedIds: idList }), z.object({ day: entity })]),
},
// ── Road trip ────────────────────────────────────────────────────────────
// The points a drive is routed through, and the tracks a day follows. Broadcast in one
// event carrying the day's whole list rather than one event per point: a via has no
// identity anyone reads, the client keeps them per day anyway, and a drag is a rapid
// burst of writes whose only interesting state is the one that lands last.
//
// These used to be silent, on the reasoning that a via is how one person draws a route
// rather than a change to the itinerary everybody reads. That does not hold: two people
// planning a road trip see the same line on the same map, and a reshaped drive changes
// every arrival time after it.
'roadtripVia:changed': {
scope: 'trip',
payload: z.object({ dayId: id, vias: z.array(entity) }),
},
'roadtripTrack:changed': {
scope: 'trip',
payload: z.object({ dayId: id, track: entity.nullable() }),
},
'roadtripBoundary:changed': { scope: 'trip', payload: roadtripDayBoundaryListSchema },
// ── Day notes ────────────────────────────────────────────────────────────
'dayNote:created': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) },
'dayNote:updated': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) },
'dayNote:deleted': { scope: 'trip', payload: z.object({ noteId: id, dayId: id }) },
// ── Packing ──────────────────────────────────────────────────────────────
'packing:created': { scope: 'trip', payload: z.object({ item: entity }) },
'packing:updated': { scope: 'trip', payload: z.object({ item: entity }) },
'packing:deleted': { scope: 'trip', payload: z.object({ itemId: id }) },
'packing:reordered': { scope: 'trip', payload: z.object({ orderedIds: idList }) },
'packing:bag-created': { scope: 'trip', payload: z.object({ bag: entity }) },
'packing:bag-updated': { scope: 'trip', payload: z.object({ bag: entity }) },
'packing:bag-deleted': { scope: 'trip', payload: z.object({ bagId: id }) },
'packing:bag-members-updated': {
scope: 'trip',
payload: z.object({ bagId: id, members: z.array(z.unknown()) }),
},
'packing:assignees': {
scope: 'trip',
payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }),
},
'packing:template-applied': { scope: 'trip', payload: z.object({ items: z.array(z.unknown()) }) },
/**
* "A bag's weight may have changed" — deliberately content-free (#2191).
*
* Bag totals are summed server-side across every member's items, so a private
* item nobody else may see still moves the number everybody else reads. The
* item events themselves cannot carry that: a private one is delivered only
* to its owner, which is exactly the privacy rule that made the totals wrong
* in the first place. This ping goes to the whole room and says nothing at
* all; receivers refetch the bags and get numbers, never contents.
*/
'packing:bag-totals': { scope: 'trip', payload: z.object({}) },
// ── Todo ─────────────────────────────────────────────────────────────────
'todo:created': { scope: 'trip', payload: z.object({ item: entity }) },
'todo:updated': { scope: 'trip', payload: z.object({ item: entity }) },
'todo:deleted': { scope: 'trip', payload: z.object({ itemId: id }) },
'todo:assignees': {
scope: 'trip',
payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }),
},
// ── Budget ───────────────────────────────────────────────────────────────
'budget:created': { scope: 'trip', payload: z.object({ item: entity }) },
'budget:updated': { scope: 'trip', payload: z.object({ item: entity }) },
'budget:deleted': { scope: 'trip', payload: z.object({ itemId: id }) },
// Intentional union: item reorder vs category reorder (the client handles both).
'budget:reordered': {
scope: 'trip',
payload: z.union([z.object({ orderedIds: idList }), z.object({ orderedCategories: z.array(z.string()) })]),
},
'budget:members-updated': {
scope: 'trip',
payload: z.object({ itemId: id, members: z.array(z.unknown()), persons: z.unknown() }),
},
'budget:member-paid-updated': {
scope: 'trip',
payload: z.object({ itemId: id, userId: id, paid: z.unknown() }),
},
'budget:settlement-created': { scope: 'trip', payload: z.object({ settlement: entity }) },
'budget:settlement-updated': { scope: 'trip', payload: z.object({ settlement: entity }) },
'budget:settlement-deleted': { scope: 'trip', payload: z.object({ settlementId: id }) },
// ── Reservations ─────────────────────────────────────────────────────────
// DRIFT: the accommodation cascade paths emit an empty refetch ping `{}`;
// the client reads payload.reservation.id unguarded on the `{}` variant.
'reservation:created': {
scope: 'trip',
payload: z.union([z.object({ reservation: entity }), empty]),
},
'reservation:updated': {
scope: 'trip',
payload: z.union([z.object({ reservation: entity }), empty]),
},
'reservation:deleted': { scope: 'trip', payload: z.object({ reservationId: id }) },
// DRIFT: REST sends { positions, day_id }; the MCP tool sends { positions, dayId }.
'reservation:positions': {
scope: 'trip',
payload: z.union([
z.object({ positions: z.array(z.unknown()), day_id: id.nullish() }),
z.object({ positions: z.array(z.unknown()), dayId: id.nullish() }),
]),
},
'reservation:travelers-updated': {
scope: 'trip',
payload: z.object({ reservationId: id, travelers: z.array(z.unknown()) }),
},
// ── Accommodations ───────────────────────────────────────────────────────
// DRIFT: `{ accommodation }` vs empty refetch ping `{}` (reservation cascade
// paths); the client handles neither event today (refetches off trip:updated).
'accommodation:created': {
scope: 'trip',
payload: z.union([z.object({ accommodation: entity }), empty]),
},
'accommodation:updated': {
scope: 'trip',
payload: z.union([z.object({ accommodation: entity }), empty]),
},
// DRIFT: { accommodationId } vs `{}` vs the MCP day-clear path's
// { id, linkedReservationId } (days.mcp.ts).
'accommodation:deleted': {
scope: 'trip',
payload: z.union([z.object({ accommodationId: id }), z.object({ id, linkedReservationId: id.nullish() }), empty]),
},
// ── Trip / members ───────────────────────────────────────────────────────
'trip:updated': { scope: 'trip', payload: z.object({ trip: entity }) },
// Note the key is `id`, not `tripId` (the transport injects tripId anyway).
'trip:deleted': { scope: 'trip', payload: z.object({ id }) },
'member:added': { scope: 'trip', payload: z.object({ member: entity }) },
'member:removed': { scope: 'trip', payload: z.object({ userId: id }) },
// ── Files ────────────────────────────────────────────────────────────────
'file:created': { scope: 'trip', payload: z.object({ file: entity }) },
'file:updated': { scope: 'trip', payload: z.object({ file: entity }) },
'file:deleted': { scope: 'trip', payload: z.object({ fileId: id }) },
// A sync run moved documents. Carries counts rather than the documents
// themselves: the client already learns about each one through file:created,
// and this is what tells the sync panel to refresh its status line.
'docsync:changed': {
scope: 'trip',
payload: z.object({ linkId: id, pulled: z.number(), pushed: z.number() }),
},
// ── Collab ───────────────────────────────────────────────────────────────
'collab:note:created': { scope: 'trip', payload: z.object({ note: entity }) },
'collab:note:updated': { scope: 'trip', payload: z.object({ note: entity }) },
'collab:note:deleted': { scope: 'trip', payload: z.object({ noteId: id }) },
'collab:link:created': { scope: 'trip', payload: z.object({ link: entity }) },
'collab:link:updated': { scope: 'trip', payload: z.object({ link: entity }) },
'collab:link:deleted': { scope: 'trip', payload: z.object({ linkId: id }) },
'collab:poll:created': { scope: 'trip', payload: z.object({ poll: entity }) },
'collab:poll:voted': { scope: 'trip', payload: z.object({ poll: entity }) },
'collab:poll:closed': { scope: 'trip', payload: z.object({ poll: entity }) },
'collab:poll:deleted': { scope: 'trip', payload: z.object({ pollId: id }) },
'collab:message:created': { scope: 'trip', payload: z.object({ message: entity }) },
'collab:message:reacted': {
scope: 'trip',
payload: z.object({ messageId: id, reactions: z.unknown() }),
},
'collab:message:deleted': {
scope: 'trip',
payload: z.object({ messageId: id, username: z.string().nullish() }),
},
// ── Memories ─────────────────────────────────────────────────────────────
'memories:updated': { scope: 'trip', payload: z.object({ userId: id }) },
'roadtripPreferences:changed': { scope: 'trip', payload: z.object({ preferences: roadtripPreferencesSchema }) },
// ── Notifications (user-scoped) ──────────────────────────────────────────
'notification:new': { scope: 'user', payload: z.object({ notification: entity }) },
'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) },
// ── Collections (user-scoped) ────────────────────────────────────────────
'collections:updated': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:accepted': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:declined': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:left': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:deleted': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:cancelled': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:removed': { scope: 'user', payload: z.object({ collectionId: id }) },
'collections:invite': {
scope: 'user',
payload: z.object({ from: z.object({ id, username: z.string() }), collectionId: id }),
},
// ── Vacay (user-scoped) ──────────────────────────────────────────────────
'vacay:update': { scope: 'user', payload: empty },
'vacay:settings': { scope: 'user', payload: empty },
'vacay:accepted': { scope: 'user', payload: empty },
'vacay:declined': { scope: 'user', payload: empty },
'vacay:cancelled': { scope: 'user', payload: empty },
'vacay:dissolved': { scope: 'user', payload: empty },
'vacay:invite': {
scope: 'user',
payload: z.object({ from: z.object({ id, username: z.string() }), planId: id }),
},
'vacay:share': { scope: 'user', payload: z.object({ from: z.object({ id }) }) },
'vacay:share-removed': { scope: 'user', payload: empty },
'vacay:shared-update': { scope: 'user', payload: empty },
// ── Journey (user-scoped; broadcastJourneyEvent injects journeyId) ──────
'journey:trip:synced': { scope: 'user', payload: z.object({ journeyId: id, tripId: id }) },
'journey:entry:created': { scope: 'user', payload: z.object({ journeyId: id, entry: entity }) },
// DRIFT: the soft-delete path emits { journeyId, entryId } under :updated
// instead of { journeyId, entry } (journeyService.ts).
'journey:entry:updated': {
scope: 'user',
payload: z.union([z.object({ journeyId: id, entry: entity }), z.object({ journeyId: id, entryId: id })]),
},
'journey:entry:deleted': { scope: 'user', payload: z.object({ journeyId: id, entryId: id }) },
'journey:entries:reordered': {
scope: 'user',
payload: z.object({ journeyId: id, orderedIds: idList }),
},
'journey:contributor:changed': {
scope: 'user',
payload: z.object({ journeyId: id, targetUserId: id, role: z.unknown() }),
},
/*
* The Studio book was saved (#1973).
*
* Carries the new version rather than the document. Everyone with the book
* open needs to know theirs is behind — which is one integer — and the ones
* who care can ask for the document. Pushing a few hundred kilobytes of JSON
* to every contributor on every autosave would be a broadcast the size of the
* thing being edited.
*/
'journey:book:saved': {
scope: 'user',
payload: z.object({ journeyId: id, version: z.number(), savedBy: id }),
},
/**
* Who else has this book open.
*
* Sent to everyone in the book whenever the set changes — someone arrives,
* someone leaves, someone's tab dies. The whole list rather than a delta,
* because it is a handful of names and a delta protocol would need an
* ordering guarantee to be worth anything.
*/
'journey:book:peers': {
scope: 'user',
payload: z.object({
journeyId: id,
peers: z.array(
z.object({
socketId: z.number(),
userId: id,
username: z.string(),
avatar: z.string().nullable().optional(),
}),
),
}),
},
/**
* Where somebody's pointer is.
*
* In millimetres on the spread, not pixels on a screen: the two people
* looking at a book are at different zoom levels on different monitors, and a
* pixel means nothing to the other one. `spreadIndex` is there so a pointer
* on page 40 is not drawn on page 2.
*
* `null` coordinates mean the pointer left the page — the arrow goes away,
* and the person stays in the list.
*/
'journey:book:cursor': {
scope: 'user',
payload: z.object({
journeyId: id,
socketId: z.number(),
userId: id,
spreadIndex: z.number().int().min(0),
x: z.number().nullable(),
y: z.number().nullable(),
}),
},
// ── Booking import (user-scoped; push() injects jobId + tripId) ─────────
'import:progress': {
scope: 'user',
payload: z.object({
jobId: z.string(),
tripId: id.nullish(),
status: z.string(),
done: z.number(),
total: z.number(),
fileName: z.string().optional(),
}),
},
'import:done': {
scope: 'user',
payload: z.object({ jobId: z.string(), tripId: id.nullish(), result: z.unknown() }),
},
'import:error': {
scope: 'user',
payload: z.object({ jobId: z.string(), tripId: id.nullish(), message: z.string() }),
},
} as const satisfies Record<string, TrekWsEventContract>;
/** Every registered event name. The registry above is the count; this derives from it. */
export type TrekWsEventName = keyof typeof TREK_WS_EVENTS;
/** Names broadcast to a trip room (`{ type, tripId, ...payload }` envelope). */
export type TrekWsTripEventName = {
[K in TrekWsEventName]: (typeof TREK_WS_EVENTS)[K]['scope'] extends 'trip' ? K : never;
}[TrekWsEventName];
/** Names sent to a single user's sockets (payload carries `type` itself). */
export type TrekWsUserEventName = {
[K in TrekWsEventName]: (typeof TREK_WS_EVENTS)[K]['scope'] extends 'user' ? K : never;
}[TrekWsEventName];
/** The payload contract for one event (the part beside type/tripId on the wire). */
export type TrekWsPayload<E extends TrekWsEventName> = z.infer<(typeof TREK_WS_EVENTS)[E]['payload']>;
/**
* The reserved plugin escape hatch: trip broadcasts are force-namespaced to
* `plugin:<pluginId>:<event>` and user-scoped plugin messages are typed
* `plugin:<pluginId>` by the host — names and payloads are plugin-chosen and
* deliberately outside the registry.
*/
export type TrekWsPluginEventName = `plugin:${string}`;
/** All registered names, for runtime enumeration (client ratchet, audits). */
export const TREK_WS_EVENT_NAMES = Object.keys(TREK_WS_EVENTS) as TrekWsEventName[];
/** Trip-scoped subset of {@link TREK_WS_EVENT_NAMES}. */
export const TREK_WS_TRIP_EVENT_NAMES = TREK_WS_EVENT_NAMES.filter(
(name) => TREK_WS_EVENTS[name].scope === 'trip',
) as TrekWsTripEventName[];
/** User-scoped subset of {@link TREK_WS_EVENT_NAMES}. */
export const TREK_WS_USER_EVENT_NAMES = TREK_WS_EVENT_NAMES.filter(
(name) => TREK_WS_EVENTS[name].scope === 'user',
) as TrekWsUserEventName[];
// Compile-time lockstep: the trip/user unions must partition the full name
// union exactly — fails `tsc` if a scope literal widens or a name lands in
// neither bucket (same pattern as the MCP access-group gate).
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
export const TREK_WS_SCOPES_PARTITION_EVENT_NAMES: AssertExact<
TrekWsTripEventName | TrekWsUserEventName,
TrekWsEventName
> = true;