fix(journey): websocket sync across devices + 404 redirect

- 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)
This commit is contained in:
Maurice
2026-04-13 23:03:58 +02:00
parent 240b10a192
commit 24bcf6ded8
4 changed files with 27 additions and 15 deletions
+8 -1
View File
@@ -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<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(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
+7 -1
View File
@@ -88,6 +88,7 @@ interface JourneyState {
journeys: Journey[]
current: JourneyDetail | null
loading: boolean
notFound: boolean
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
@@ -109,6 +110,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
journeys: [],
current: null,
loading: false,
notFound: false,
loadJourneys: async () => {
set({ loading: true })
@@ -121,10 +123,14 @@ export const useJourneyStore = create<JourneyState>((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 })
}
+3 -3
View File
@@ -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);
});
+9 -10
View File
@@ -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<string, unknown>, excludeUserId?: number) {
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, 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<st
if (owner) userIds.add(owner.user_id);
for (const uid of userIds) {
if (uid === excludeUserId) continue;
broadcastToUser(uid, { type: event, journeyId, ...data });
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
}
}
@@ -218,7 +217,7 @@ export function addTripToJourney(journeyId: number, tripId: number, userId: numb
syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -465,7 +464,7 @@ export function createEntry(journeyId: number, userId: number, data: {
tags?: string[];
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
}): JourneyEntry | null {
}, sid?: string): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null;
const now = ts();
@@ -499,7 +498,7 @@ export function createEntry(journeyId: number, userId: number, data: {
);
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
return created;
}
@@ -518,7 +517,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
pros_cons: { pros: string[]; cons: string[] };
visibility: string;
sort_order: number;
}>): 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;
}