From 2d2f1e88bda97f40bd92c98644d41bc2e9ac4421 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 8 Sep 2026 23:18:53 +0200 Subject: [PATCH] fix(collab): sync shared links live, and teach the harnesses the new columns The three collab:link events were broadcast and landed nowhere: the links panel held its own list and never listened, so a link somebody else added showed up on the next reload. It listens now, the same shape the notes panel uses, and the events are classified in the client's WS policy, which is the ratchet that caught this. Four e2e harnesses build their own trip_files and one asserts the collab feature set, so the new message_id column and the new links flag had to reach them too. Two MCP unit tests pinned the old four-key feature object. --- client/src/api/wsEventPolicy.ts | 3 +++ client/src/components/Collab/CollabLinks.tsx | 26 +++++++++++++++++++ server/tests/e2e/addons.e2e.test.ts | 2 +- server/tests/e2e/admin.e2e.test.ts | 2 +- server/tests/e2e/files.e2e.test.ts | 5 ++-- server/tests/e2e/trips.e2e.test.ts | 5 ++-- .../tests/unit/mcp/tools-help-addons.test.ts | 2 +- server/tests/unit/mcp/tools-prompts.test.ts | 2 +- 8 files changed, 39 insertions(+), 8 deletions(-) diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 5b974a0a0..9a9b3762b 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'collab:note:created', 'collab:note:updated', 'collab:note:deleted', + 'collab:link:created', + 'collab:link:updated', + 'collab:link:deleted', 'collab:poll:created', 'collab:poll:voted', 'collab:poll:closed', diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 2727356a2..e5570cdba 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -1,6 +1,7 @@ import { FormEvent, useEffect, useState } from 'react' import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react' import { collabApi } from '../../api/client' +import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' @@ -44,6 +45,31 @@ export default function CollabLinks({ tripId }: { tripId: number }) { return () => { cancelled = true } }, [tripId, toast, t]) + // Live sync, the same shape the notes panel uses. Without it the three + // collab:link events the server broadcasts would arrive nowhere and a link + // somebody else added would only appear on the next reload. + useEffect(() => { + if (!tripId) return + const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => { + // The panel is not remounted on a trip change, so an event still in + // flight from the trip we just left must not land in this list. + if (String(msg?.tripId) !== String(tripId)) return + if (msg.type === 'collab:link:created' && msg.link) { + setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev]) + } + if (msg.type === 'collab:link:updated' && msg.link) { + setLinks(prev => prev + .map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l) + .sort((a, b) => Number(b.pinned) - Number(a.pinned))) + } + if (msg.type === 'collab:link:deleted' && msg.linkId) { + setLinks(prev => prev.filter(l => l.id !== msg.linkId)) + } + } + addListener(handler) + return () => removeListener(handler) + }, [tripId]) + // Every write says something when it fails. Silence left the pin looking // unchanged and the delete looking ignored, with an unhandled rejection in // the console as the only trace. diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index af47f87cc..64acef2cc 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -84,7 +84,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ - collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true }, + collabFeatures: { chat: true, notes: true, links: true, polls: true, whatsnext: true }, bagTracking: true, addons: [ { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index 829701a2a..f7ef5bd4d 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -38,7 +38,7 @@ const { db } = vi.hoisted(() => { // getStats counts these three alongside users. tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, user_id INTEGER);'); tmp.exec('CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER);'); - tmp.exec('CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER);'); + tmp.exec('CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, message_id INTEGER);'); tmp.exec(`CREATE TABLE invite_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL, max_uses INTEGER, uses INTEGER DEFAULT 0, expires_at TEXT, created_by INTEGER NOT NULL, trip_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index 10b9230e6..19f7ce5e4 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -27,8 +27,9 @@ const { db } = vi.hoisted(() => { // reservations/places/day_assignments). tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT);'); tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, - place_id INTEGER, reservation_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL, - file_size INTEGER, mime_type TEXT, description TEXT, uploaded_by INTEGER, starred INTEGER DEFAULT 0, + place_id INTEGER, reservation_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, + original_name TEXT NOT NULL, file_size INTEGER, mime_type TEXT, description TEXT, + uploaded_by INTEGER, starred INTEGER DEFAULT 0, deleted_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec(`CREATE TABLE file_links (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL, reservation_id INTEGER, assignment_id INTEGER, place_id INTEGER, budget_item_id INTEGER, diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 1a2916d56..16915c91b 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -57,8 +57,9 @@ const { db } = vi.hoisted(() => { // bundle()'s files now runs FilesService's real SQL (DI-injected, no mock) — // empty tables satisfy the FILE_SELECT joins and the file_links batch. tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, - place_id INTEGER, reservation_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL, - file_size INTEGER, mime_type TEXT, description TEXT, uploaded_by INTEGER, starred INTEGER DEFAULT 0, + place_id INTEGER, reservation_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, + original_name TEXT NOT NULL, file_size INTEGER, mime_type TEXT, description TEXT, + uploaded_by INTEGER, starred INTEGER DEFAULT 0, deleted_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec(`CREATE TABLE file_links (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL, reservation_id INTEGER, assignment_id INTEGER, place_id INTEGER, diff --git a/server/tests/unit/mcp/tools-help-addons.test.ts b/server/tests/unit/mcp/tools-help-addons.test.ts index 57ff8e17a..1a71ece2c 100644 --- a/server/tests/unit/mcp/tools-help-addons.test.ts +++ b/server/tests/unit/mcp/tools-help-addons.test.ts @@ -320,7 +320,7 @@ describe('Tool: list_addons', () => { type: row.type, enabled: true, }); - expect(data.collabFeatures).toEqual({ chat: true, notes: true, polls: true, whatsnext: true }); + expect(data.collabFeatures).toEqual({ chat: true, notes: true, links: true, polls: true, whatsnext: true }); expect(data.bagTracking).toBe(false); }); }); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index cb3dc90dc..3caa8ba43 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -52,7 +52,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => { // of behind an import path. const addonsStub = { isAddonEnabled: isAddonEnabledMock, - getCollabFeatures: () => ({ chat: true, notes: true, polls: true, whatsnext: true }), + getCollabFeatures: () => ({ chat: true, notes: true, links: true, polls: true, whatsnext: true }), } as unknown as AddonsService; const { mockGetTripSummary } = vi.hoisted(() => ({