mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
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:
@@ -79,7 +79,7 @@ export default function JourneyDetailPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
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 mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||||
@@ -97,6 +97,13 @@ export default function JourneyDetailPage() {
|
|||||||
if (id) loadJourney(Number(id))
|
if (id) loadJourney(Number(id))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (notFound) {
|
||||||
|
toast.error(t('journey.notFound'))
|
||||||
|
navigate('/journey')
|
||||||
|
}
|
||||||
|
}, [notFound])
|
||||||
|
|
||||||
// WebSocket real-time updates
|
// WebSocket real-time updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ interface JourneyState {
|
|||||||
journeys: Journey[]
|
journeys: Journey[]
|
||||||
current: JourneyDetail | null
|
current: JourneyDetail | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
notFound: boolean
|
||||||
|
|
||||||
loadJourneys: () => Promise<void>
|
loadJourneys: () => Promise<void>
|
||||||
loadJourney: (id: number) => Promise<void>
|
loadJourney: (id: number) => Promise<void>
|
||||||
@@ -109,6 +110,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
journeys: [],
|
journeys: [],
|
||||||
current: null,
|
current: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
notFound: false,
|
||||||
|
|
||||||
loadJourneys: async () => {
|
loadJourneys: async () => {
|
||||||
set({ loading: true })
|
set({ loading: true })
|
||||||
@@ -121,10 +123,14 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadJourney: async (id) => {
|
loadJourney: async (id) => {
|
||||||
set({ loading: true })
|
set({ loading: true, notFound: false })
|
||||||
try {
|
try {
|
||||||
const data = await journeyApi.get(id)
|
const data = await journeyApi.get(id)
|
||||||
set({ current: data })
|
set({ current: data })
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
set({ current: null, notFound: true })
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false })
|
set({ loading: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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' });
|
if (!result) return res.status(404).json({ error: 'Entry not found' });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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' });
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
}
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -245,7 +245,7 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { entry_date } = req.body || {};
|
const { entry_date } = req.body || {};
|
||||||
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
|
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' });
|
if (!entry) return res.status(404).json({ error: 'Journey not found' });
|
||||||
res.status(201).json(entry);
|
res.status(201).json(entry);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const JP_SELECT = `
|
|||||||
`;
|
`;
|
||||||
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
|
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(
|
const contributors = db.prepare(
|
||||||
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
||||||
).all(journeyId) as { user_id: number }[];
|
).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);
|
if (owner) userIds.add(owner.user_id);
|
||||||
|
|
||||||
for (const uid of userIds) {
|
for (const uid of userIds) {
|
||||||
if (uid === excludeUserId) continue;
|
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
|
||||||
broadcastToUser(uid, { type: event, journeyId, ...data });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ export function addTripToJourney(journeyId: number, tripId: number, userId: numb
|
|||||||
syncTripPlaces(journeyId, tripId, userId);
|
syncTripPlaces(journeyId, tripId, userId);
|
||||||
// import existing trip photos (Immich/Synology) with sharing settings
|
// import existing trip photos (Immich/Synology) with sharing settings
|
||||||
syncTripPhotos(journeyId, tripId);
|
syncTripPhotos(journeyId, tripId);
|
||||||
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
|
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +464,7 @@ export function createEntry(journeyId: number, userId: number, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
pros_cons?: { pros: string[]; cons: string[] };
|
pros_cons?: { pros: string[]; cons: string[] };
|
||||||
visibility?: string;
|
visibility?: string;
|
||||||
}): JourneyEntry | null {
|
}, sid?: string): JourneyEntry | null {
|
||||||
if (!canEdit(journeyId, userId)) return null;
|
if (!canEdit(journeyId, userId)) return null;
|
||||||
|
|
||||||
const now = ts();
|
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;
|
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;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +517,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
|
|||||||
pros_cons: { pros: string[]; cons: string[] };
|
pros_cons: { pros: string[]; cons: string[] };
|
||||||
visibility: string;
|
visibility: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}>): JourneyEntry | null {
|
}>, sid?: string): JourneyEntry | null {
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
if (!canEdit(entry.journey_id, userId)) 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);
|
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;
|
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;
|
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;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (!canEdit(entry.journey_id, userId)) 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)
|
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
|
||||||
`).run(entry.journey_id);
|
`).run(entry.journey_id);
|
||||||
|
|
||||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
|
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user