Files
TREK/server/src/nest/journey/journey.controller.ts
T
Maurice fc7d8b5d12 Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
2026-05-30 02:39:26 +02:00

420 lines
18 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
Patch,
Post,
Put,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { JourneyService } from './journey.service';
import { JourneyAddonGuard } from './journey-addon.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getAllowedExtensions } from '../../services/fileService';
const uploadsBase = path.join(__dirname, '../../../uploads/journey');
const IMAGE_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
}),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err, false);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err, false);
}
cb(null, true);
},
};
/**
* /api/journeys — cross-trip travel narrative (journeys, entries, photo gallery
* + provider mirroring, contributors, preferences, share links).
*
* Byte-identical to the legacy Express route (server/src/routes/journey.ts):
* the Journey-addon gate (404) runs before auth, the service owns access
* control (null/false → 403/404), create routes answer 201 while cover/trips/
* share-link/reorder/patch answer 200 and the two unlink/gallery-delete routes
* answer 204. Static prefixes (/suggestions, /available-trips, /entries, /photos)
* are declared before /:id so they win over the param.
*/
@Controller('api/journeys')
@UseGuards(JourneyAddonGuard, JwtAuthGuard)
export class JourneyController {
constructor(private readonly journey: JourneyService) {}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
list(@CurrentUser() user: User) {
return { journeys: this.journey.listJourneys(user.id) };
}
@Post()
create(@CurrentUser() user: User, @Body() body: { title?: string; subtitle?: string; trip_ids?: unknown[] }) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new HttpException({ error: 'Title is required' }, 400);
}
return this.journey.createJourney(user.id, {
title: body.title.trim(),
subtitle: body.subtitle,
trip_ids: Array.isArray(body.trip_ids) ? body.trip_ids.map(Number) : [],
});
}
@Get('suggestions')
suggestions(@CurrentUser() user: User) {
return { trips: this.journey.getSuggestions(user.id) };
}
@Get('available-trips')
availableTrips(@CurrentUser() user: User) {
return { trips: this.journey.listUserTrips(user.id) };
}
// ── Entries (prefix /entries — before /:id) ─────────────────────────────
@Patch('entries/:entryId')
updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: Record<string, unknown>, @Headers('x-socket-id') socketId?: string) {
const result = this.journey.updateEntry(Number(entryId), user.id, body, socketId);
if (!result) {
throw new HttpException({ error: 'Entry not found' }, 404);
}
return result;
}
@Delete('entries/:entryId')
deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) {
throw new HttpException({ error: 'Entry not found' }, 404);
}
return { success: true };
}
@Post('entries/:entryId/photos')
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: { caption?: string }) {
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
const results: unknown[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption);
if (!photo) continue;
// Mirror to Immich only when the user explicitly opted in (#730).
if (this.journey.immichAutoUploadEnabled(user.id)) {
try {
const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname);
if (immichId) {
this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id });
}
} catch {
// best-effort mirror; the local photo is already saved
}
}
results.push(photo);
}
if (!results.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { photos: results };
}
@Post('entries/:entryId/provider-photos')
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; caption?: string; passphrase?: string }) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
for (const id of body.asset_ids) {
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, String(id), body.caption, pp);
if (photo) added.push(photo);
}
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, body.asset_id, body.caption, pp);
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
return photo;
}
@Post('entries/:entryId/link-photo')
linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { journey_photo_id?: unknown; photo_id?: unknown }) {
const journeyPhotoId = body.journey_photo_id ?? body.photo_id;
if (!journeyPhotoId) {
throw new HttpException({ error: 'journey_photo_id required' }, 400);
}
const result = this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return result;
}
@Delete('entries/:entryId/photos/:journeyPhotoId')
@HttpCode(204)
unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): void {
if (!this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id)) {
throw new HttpException({ error: 'Not found or not allowed' }, 404);
}
}
@Patch('photos/:photoId')
updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: Record<string, unknown>) {
const result = this.journey.updatePhoto(Number(photoId), user.id, body);
if (!result) {
throw new HttpException({ error: 'Photo not found' }, 404);
}
return result;
}
@Delete('photos/:photoId')
deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) {
const photo = this.journey.deletePhoto(Number(photoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found' }, 404);
}
if (photo.file_path) {
try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ }
}
return { success: true };
}
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
const filePaths = files.map((f) => ({ path: `journey/${f.filename}` }));
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
if (!photos.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { photos };
}
@Post(':id/gallery/provider-photos')
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string }) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
for (const aid of body.asset_ids) {
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, String(aid), undefined, pp);
if (photo) added.push(photo);
}
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, body.asset_id, undefined, pp);
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
return photo;
}
@Delete(':id/gallery/:journeyPhotoId')
@HttpCode(204)
deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): void {
const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
if (!photo) {
throw new HttpException({ error: 'Photo not found or not allowed' }, 404);
}
if (photo.file_path) {
try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ }
}
}
// ── Journeys /:id ───────────────────────────────────────────────────────
@Get(':id')
get(@CurrentUser() user: User, @Param('id') id: string) {
const data = this.journey.getJourneyFull(Number(id), user.id);
if (!data) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return data;
}
@Patch(':id')
update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>) {
const result = this.journey.updateJourney(Number(id), user.id, body);
if (!result) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return result;
}
@Post(':id/cover')
@HttpCode(200) // Express answers cover with res.json (200).
@UseInterceptors(FileInterceptor('cover', IMAGE_UPLOAD))
cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` });
if (!result) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return result;
}
@Delete(':id')
remove(@CurrentUser() user: User, @Param('id') id: string) {
if (!this.journey.deleteJourney(Number(id), user.id)) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return { success: true };
}
// ── Journey trips ───────────────────────────────────────────────────────
@Post(':id/trips')
@HttpCode(200) // Express answers with res.json (200).
addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { trip_id?: unknown }) {
if (!body.trip_id) {
throw new HttpException({ error: 'trip_id required' }, 400);
}
if (!this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id)) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Delete(':id/trips/:tripId')
removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
if (!this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id)) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
// ── Entries under journey ───────────────────────────────────────────────
@Get(':id/entries')
listEntries(@CurrentUser() user: User, @Param('id') id: string) {
const entries = this.journey.listEntries(Number(id), user.id);
if (!entries) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return { entries };
}
@Post(':id/entries')
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown> & { entry_date?: unknown }, @Headers('x-socket-id') socketId?: string) {
if (!body.entry_date) {
throw new HttpException({ error: 'entry_date is required' }, 400);
}
const entry = this.journey.createEntry(Number(id), user.id, body, socketId);
if (!entry) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return entry;
}
@Put(':id/entries/reorder')
reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { orderedIds?: unknown }, @Headers('x-socket-id') socketId?: string) {
const orderedIds = body.orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) {
throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400);
}
if (!this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId)) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
// ── Contributors ────────────────────────────────────────────────────────
@Post(':id/contributors')
addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { user_id?: unknown; role?: 'editor' | 'viewer' }) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
if (!this.journey.addContributor(Number(id), user.id, Number(body.user_id), body.role || 'viewer')) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Patch(':id/contributors/:userId')
updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: { role?: 'editor' | 'viewer' }) {
if (!this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer')) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Delete(':id/contributors/:userId')
removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
if (!this.journey.removeContributor(Number(id), user.id, Number(userId))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
// ── User Preferences ────────────────────────────────────────────────────
@Patch(':id/preferences')
preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>) {
const result = this.journey.updateJourneyPreferences(Number(id), user.id, body);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return result;
}
// ── Share Link ──────────────────────────────────────────────────────────
@Get(':id/share-link')
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
return { link: this.journey.getJourneyShareLink(Number(id)) };
}
@Post(':id/share-link')
@HttpCode(200) // Express answers with res.json (200).
setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) {
const result = this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, {
share_timeline: body.share_timeline,
share_gallery: body.share_gallery,
share_map: body.share_map,
});
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return result;
}
@Delete(':id/share-link')
deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) {
if (!this.journey.deleteJourneyShareLink(Number(id), user.id)) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
}