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(() => ({