import type { User } from '../../types'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { logInfo } from '../audit/audit-log.logger'; import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { CalendarService } from '../calendar/calendar.service'; import { contentDisposition } from '../common/content-disposition'; import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; import { StorageReleaseService } from '../storage/storage-release.service'; import { StorageService } from '../storage/storage.service'; import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; import { UnsplashService } from '../unsplash/unsplash.service'; import { TripWritesService } from './trip-writes.service'; import { TripCreateDto, TripUpdateDto, TripCopyDto, TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto, } from './trips.dto'; import { tripUpdatePermissions } from './trips.helpers'; import { TripsService } from './trips.service'; import { NotFoundError, ValidationError } from './trips.service'; import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Param, Post, Put, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { MAX_TRIP_DAYS, type ActiveTripResponse, type TripSearchResponse } from '@trek/shared'; import type { Request, Response } from 'express'; import type { Options } from 'multer'; import path from 'path'; export const MAX_COVER_SIZE = 20 * 1024 * 1024; // Still needed by the Unsplash cover download (a raw-fs writer until the // caches/downloads slice); the multer destination moved to the storage spool. // Consumed by trips.module.ts's MulterModule factory. Quirk preserved on // purpose: a plain Error without statusCode maps to 500, not 400 (parity). export const TRIP_COVER_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) cb(null, true); else cb(new Error('Only jpg, png, gif, webp images allowed')); }; /** * /api/trips — the trip aggregate root. * * Byte-identical to the legacy Express route (server/src/routes/trips.ts): the * same per-field permission checks (trip_create / trip_edit / trip_archive / * trip_cover_upload / trip_delete / member_manage), the date inference on create, * audit logging, the offline bundle, ICS export and member-invite notification. * Uses EXACT strangler prefixes so it never swallows the nested sub-domain mounts. */ @Controller('api/trips') @UseGuards(JwtAuthGuard) export class TripsController { // calendar last: the hand-wired construction sites in the tests stay positional, // so a new dependency does not touch the ones that never reach the ICS route. constructor( private readonly trips: TripsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, private readonly unsplash: UnsplashService, private readonly calendar: CalendarService, private readonly readModel: TripReadModelService, private readonly storage: StorageService, private readonly tripWrites: TripWritesService, private readonly release: StorageReleaseService, ) {} @Get() async list(@CurrentUser() user: User, @Query('archived') archived?: string) { return { trips: await this.trips.list(user.id, archived === '1' ? 1 : 0) }; } /** * Where "open TREK straight in my trip" lands. Declared above @Get(':id') — * a literal segment below it would never be reached. */ @Get('active') async active(@CurrentUser() user: User): Promise { const row = await this.trips.activeTrip(user.id); if (!row) return { trip: null }; const { id, title, start_date, end_date } = row; return { trip: { id, title, start_date, end_date } }; } /** Places across the user's trips for the dashboard search (#2190). Above @Get(':id'). */ @Get('search') async search(@CurrentUser() user: User, @Query('q') q?: string): Promise { return { matches: await this.trips.searchPlaces(user.id, typeof q === 'string' ? q.slice(0, 100) : '') }; } @Get('cover-images/search') async coverImages(@CurrentUser() user: User, @Query('query') query?: string) { try { const result = await this.trips.searchCoverImages(query || '', user.id); return { photos: result.photos }; } catch (err: unknown) { if (err instanceof HttpException) throw err; console.error('Unsplash cover image error:', err); throw new HttpException({ error: 'Error searching for cover images' }, 500); } } @Post() @HttpCode(201) async create( @CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request, @Headers('x-socket-id') socketId?: string, ) { if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) { throw new HttpException({ error: 'No permission to create trips' }, 403); } // Presence/shape validation happens in the ZodValidationPipe (tripCreateRequestSchema). const { title, description, currency, reminder_days, day_count } = body; const start_date: string | null = body.start_date || null; const end_date: string | null = body.end_date || null; if (start_date && end_date && new Date(end_date) < new Date(start_date)) { throw new HttpException({ error: 'End date must be after start date' }, 400); } const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; try { const { trip, tripId, reminderDays } = await this.tripWrites.create( { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }, { userId: user.id, role: user.role, socketId }, ); await this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` }, }); if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`); return { trip }; } catch (e: unknown) { if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); throw e; } } @Get(':id') async get(@CurrentUser() user: User, @Param('id') id: string) { const trip = await this.trips.get(id, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } return { trip }; } @Put(':id') async update( @CurrentUser() user: User, @Param('id') id: string, @Body() body: TripUpdateDto, @Req() req: Request, @Headers('x-socket-id') socketId?: string, ) { const access = await this.trips.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } const ownerId = access.user_id; const isMember = ownerId !== user.id; // A body the caller may only partly write is refused on the first right it lacks, in the list's order. const needed = tripUpdatePermissions(body); const granted = await Promise.all( needed.map(({ action }) => this.trips.can(action, user.role, ownerId, user.id, isMember)), ); const lacking = needed.find((_, i) => !granted[i]); if (lacking) throw new HttpException({ error: lacking.refusal }, 403); // A chosen Unsplash cover arrives as an images.unsplash.com hot-link; download // it into uploads/covers so the cover survives offline + CDN link-rot (#1277). if (this.unsplash.isUnsplashCoverUrl(body.cover_image)) { try { const filename = await this.unsplash.saveUnsplashCover(body.cover_image); body.cover_image = `/uploads/covers/${filename}`; } catch (e) { console.error('Unsplash cover download failed:', e); throw new HttpException({ error: 'Could not save the selected cover image' }, 502); } } try { const result = await this.trips.update(id, user.id, body, user.role); if (Object.keys(result.changes).length > 0) { await this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes, }, }); if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); } if (result.newReminder !== result.oldReminder) { if (result.newReminder > 0) logInfo(`${user.email} set ${result.newReminder}-day reminder for trip "${result.newTitle}"`); else logInfo(`${user.email} removed reminder for trip "${result.newTitle}"`); } this.trips.broadcast(id, 'trip:updated', { trip: result.updatedTrip }, socketId); return { trip: result.updatedTrip }; } catch (e: unknown) { if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404); if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); throw e; } } @Post(':id/cover') @UseInterceptors(FileInterceptor('cover')) async cover( @CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, ) { if (isDemoWriteBlocked(this.env, user.email)) { throw new HttpException(DEMO_WRITE_ERROR, 403); } const access = await this.trips.canAccessTrip(id, user.id); if (!access?.user_id) { throw new HttpException({ error: 'Trip not found' }, 404); } if (!(await this.trips.can('trip_cover_upload', user.role, access.user_id, user.id, access.user_id !== user.id))) { throw new HttpException({ error: 'No permission to change the cover image' }, 403); } const trip = (await this.trips.getRaw(id)) as { cover_image: string | null } | undefined; if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } if (!file) { throw new HttpException({ error: 'No image uploaded' }, 400); } // Commit the spooled upload to its final storage location (atomic // same-volume rename) before anything references the final path. await this.storage.put('covers', file.filename, { tmpPath: file.path }); const coverUrl = `/uploads/covers/${file.filename}`; await this.trips.updateCoverImage(id, coverUrl); // The replaced cover goes unless a trip copy or a journey still shows it. await this.release.releaseValues([trip.cover_image]); return { cover_image: coverUrl }; } @Post(':id/copy') @HttpCode(201) async copy( @CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request, @Headers('x-socket-id') socketId?: string, ) { if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) { throw new HttpException({ error: 'No permission to create trips' }, 403); } if (!(await this.trips.canAccessTrip(id, user.id))) { throw new HttpException({ error: 'Trip not found' }, 404); } const { title } = body; try { const newTripId = await this.tripWrites.copy(id, title, { userId: user.id, role: user.role, socketId }); await this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title }, }); return { trip: await this.trips.getCopiedTrip(newTripId, user.id) }; } catch { throw new HttpException({ error: 'Failed to copy trip' }, 500); } } @Delete(':id') async remove( @CurrentUser() user: User, @Param('id') id: string, @Req() req: Request, @Headers('x-socket-id') socketId?: string, ) { const owner = await this.trips.getOwner(id); if (!owner) { throw new HttpException({ error: 'Trip not found' }, 404); } // Someone with no access at all gets the same 404 as a trip that does not // exist, otherwise the 403 below turns sequential ids into an existence // oracle. Admins are exempt: they may delete trips they are not a member of, // which is what the isAdminDelete branch further down relies on. if (user.role !== 'admin' && !(await this.trips.canAccessTrip(id, user.id))) { throw new HttpException({ error: 'Trip not found' }, 404); } if (!(await this.trips.can('trip_delete', user.role, owner.user_id, user.id, owner.user_id !== user.id))) { throw new HttpException({ error: 'No permission to delete this trip' }, 403); } const info = await this.tripWrites.remove(id, this.tripWrites.restWriter(id, user, socketId)); await this.audit.writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) }, }); if (info.isAdminDelete && info.ownerEmail) logInfo(`Admin ${user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`); return { success: true }; } @Get(':id/bundle') async bundle(@CurrentUser() user: User, @Param('id') id: string) { const trip = (await this.trips.get(id, user.id)) as { user_id: number } | undefined; if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } return this.readModel.bundle(id, trip, user.id); } @Get(':id/export.ics') async exportIcs(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response) { if (!(await this.trips.canAccessTrip(id, user.id))) { throw new HttpException({ error: 'Trip not found' }, 404); } try { const { ics, filename } = await this.calendar.exportICS(id); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment')); res.send(ics); } catch (e: unknown) { if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404); throw e; } } }