From 24bcf6ded850047a2e9ffa94b3be9ec6480ab8c7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Apr 2026 23:03:58 +0200 Subject: [PATCH] fix(journey): websocket sync across devices + 404 redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - broadcastJourneyEvent now excludes by socket ID instead of user ID, so other devices of the same user receive real-time updates (#615) - Routes pass x-socket-id header through to broadcast functions - loadJourney handles 404 gracefully — redirects to /journey with toast instead of infinite spinner (#616) --- client/src/pages/JourneyDetailPage.tsx | 9 ++++++++- client/src/store/journeyStore.ts | 8 +++++++- server/src/routes/journey.ts | 6 +++--- server/src/services/journeyService.ts | 19 +++++++++---------- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index ad832d94..fe501459 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -79,7 +79,7 @@ export default function JourneyDetailPage() { const navigate = useNavigate() const toast = useToast() const { t } = useTranslation() - const { current, loading, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore() + const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore() const mapRef = useRef(null) const fullMapRef = useRef(null) const [activeLocationId, setActiveLocationId] = useState(null) @@ -97,6 +97,13 @@ export default function JourneyDetailPage() { if (id) loadJourney(Number(id)) }, [id]) + useEffect(() => { + if (notFound) { + toast.error(t('journey.notFound')) + navigate('/journey') + } + }, [notFound]) + // WebSocket real-time updates useEffect(() => { if (!id) return diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 51abdb1e..9234136c 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -88,6 +88,7 @@ interface JourneyState { journeys: Journey[] current: JourneyDetail | null loading: boolean + notFound: boolean loadJourneys: () => Promise loadJourney: (id: number) => Promise @@ -109,6 +110,7 @@ export const useJourneyStore = create((set, get) => ({ journeys: [], current: null, loading: false, + notFound: false, loadJourneys: async () => { set({ loading: true }) @@ -121,10 +123,14 @@ export const useJourneyStore = create((set, get) => ({ }, loadJourney: async (id) => { - set({ loading: true }) + set({ loading: true, notFound: false }) try { const data = await journeyApi.get(id) set({ current: data }) + } catch (err: any) { + if (err?.response?.status === 404) { + set({ current: null, notFound: true }) + } } finally { set({ loading: false }) } diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index 9b59240d..edb9a674 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -64,14 +64,14 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => { router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}); + const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string); if (!result) return res.status(404).json({ error: 'Entry not found' }); res.json(result); }); router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) { + if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) { return res.status(404).json({ error: 'Entry not found' }); } res.json({ success: true }); @@ -245,7 +245,7 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { entry_date } = req.body || {}; if (!entry_date) return res.status(400).json({ error: 'entry_date is required' }); - const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body); + const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string); if (!entry) return res.status(404).json({ error: 'Journey not found' }); res.status(201).json(entry); }); diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 465f0d40..168677ab 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -14,7 +14,7 @@ const JP_SELECT = ` `; const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id'; -function broadcastJourneyEvent(journeyId: number, event: string, data: Record, excludeUserId?: number) { +function broadcastJourneyEvent(journeyId: number, event: string, data: Record, excludeSocketId?: string | number) { const contributors = db.prepare( 'SELECT user_id FROM journey_contributors WHERE journey_id = ?' ).all(journeyId) as { user_id: number }[]; @@ -24,8 +24,7 @@ function broadcastJourneyEvent(journeyId: number, event: string, data: Record): JourneyEntry | null { +}>, sid?: string): JourneyEntry | null { const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; if (!canEdit(entry.journey_id, userId)) return null; @@ -557,11 +556,11 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{ db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id); const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry; - broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); return updated; } -export function deleteEntry(entryId: number, userId: number): boolean { +export function deleteEntry(entryId: number, userId: number, sid?: string): boolean { const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return false; if (!canEdit(entry.journey_id, userId)) return false; @@ -576,7 +575,7 @@ export function deleteEntry(entryId: number, userId: number): boolean { AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos) `).run(entry.journey_id); - broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); return true; }