mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user