Files
TREK/server/src/routes/journey.ts
T
Maurice 25bdf56d16 add mapbox gl option, gps location, journey reorder + polish
- Mapbox GL provider alongside Leaflet for trip and journey maps (opt-in in
  settings with token, style presets incl. 3D on satellite, quality mode,
  experimental badge).
- GPS "blue dot" with heading cone on mobile; three-state FAB (off / show /
  follow), geodesic accuracy circle, desktop-hidden since browser IP geo is
  too coarse for navigation.
- Marker drift fix: outer wrap no longer carries inline position/transform,
  so mapbox's translate keeps the pin pinned at every zoom and pitch.
- Journey map popup (mapbox-gl): Apple-Maps-style tooltip on marker
  highlight/click showing entry title + location / date subline.
- Journey feed reorder: up/down controls to the left of each entry reorder
  sort_order within a day. Server endpoint, optimistic store update, rollback
  on failure.
- Journey entry editor: desktop modal now centers over the feed column only,
  backdrop still blurs the whole page (map included).
- Scroll-sync guard on journey: marker click locks the sync so smooth-scroll
  can't steer the highlight to a neighbouring entry mid-animation.
- Misc: map top-padding aligned with hero, live/synced badges replaced by a
  compact back-button in the hero, skeleton entries no longer pollute the
  journey map, journey detail no longer shows map on mobile path when
  combined view is active.
2026-04-19 01:41:02 +02:00

335 lines
14 KiB
TypeScript

import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
const router = express.Router();
const uploadsBase = path.join(__dirname, '../../uploads/journey');
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
cb(null, uploadsBase);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${crypto.randomUUID()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ journeys: svc.listJourneys(authReq.user.id) });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { title, subtitle, trip_ids } = req.body || {};
if (!title || typeof title !== 'string' || !title.trim()) {
return res.status(400).json({ error: 'Title is required' });
}
const journey = svc.createJourney(authReq.user.id, {
title: title.trim(),
subtitle,
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
});
res.status(201).json(journey);
});
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.getSuggestions(authReq.user.id) });
});
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.listUserTrips(authReq.user.id) });
});
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
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 || {}, 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, req.headers['x-socket-id'] as string)) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ success: true });
});
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const results: any[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = svc.addPhoto(
Number(req.params.entryId),
authReq.user.id,
relativePath,
undefined,
req.body?.caption
);
if (photo) {
// Mirror to Immich only when the user has explicitly opted in via the
// Immich integration settings. Avoids the "surprise upload" in #730
// where a write-capable API key implicitly enabled mirroring.
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined;
if (prefs?.immich_auto_upload) {
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) {
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
photo.provider = 'immich' as any;
photo.asset_id = immichId;
photo.owner_id = authReq.user.id;
}
} catch {}
}
results.push(photo);
}
}
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos: results });
});
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
// Link an existing photo to a (different) entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photo_id } = req.body || {};
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result);
});
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Photo not found' });
res.json(result);
});
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
// photos imported from Immich (no file_path) are just references — don't touch Immich
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
try {
const { getImmichCredentials } = await import('../services/memories/immichService');
const creds = getImmichCredentials(authReq.user.id);
if (creds) {
const { safeFetch } = await import('../utils/ssrfGuard');
await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'DELETE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [photo.asset_id] }),
});
}
} catch {}
}
res.json({ success: true });
});
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
if (!data) return res.status(404).json({ error: 'Journey not found' });
res.json(data);
});
router.patch('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const relativePath = `journey/${req.file.filename}`;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
return res.status(404).json({ error: 'Journey not found' });
}
res.json({ success: true });
});
// ── Journey trips ────────────────────────────────────────────────────────
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { trip_id } = req.body || {};
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Entries under journey ────────────────────────────────────────────────
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
if (!entries) return res.status(404).json({ error: 'Journey not found' });
res.json({ entries });
});
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, req.headers['x-socket-id'] as string);
if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry);
});
router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const orderedIds = (req.body || {}).orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) {
return res.status(400).json({ error: 'orderedIds must be an array of numbers' });
}
if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Contributors ─────────────────────────────────────────────────────────
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { user_id, role } = req.body || {};
if (!user_id) return res.status(400).json({ error: 'user_id required' });
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
return res.status(403).json({ error: 'Not allowed' });
}
res.status(201).json({ success: true });
});
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { role } = req.body || {};
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── User Preferences ─────────────────────────────────────────────────────
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
// ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const link = getJourneyShareLink(Number(req.params.id));
res.json({ link });
});
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { share_timeline, share_gallery, share_map } = req.body || {};
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
export default router;