Files
TREK/server/src/nest/trips/trips.controller.ts
T
Maurice db0fbc1ccd fix(storage): stop deleting covers, place images and journey photos another row may still show
Replacing a trip or list cover, changing or deleting a place or saved-place
image and deleting a journey photo removed the bytes at once, without asking
whether a trip copy, a journey, a saved place or another photo row still
named them (#2924). Those paths now leave the object in place and the reclaim
job removes it once no row names it. A journey photo the user deletes takes
its local photo row with it when nothing else links it, so its bytes become
reclaimable instead of being kept by a row nobody sees.

A place image copied from a trip file is still dropped at once when the
update that would name it fails: that copy was never anyone else's.

Size and warning baselines follow the shrunk files.
2026-11-01 13:59:28 +01:00

356 lines
14 KiB
TypeScript

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 { 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,
) {}
@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<ActiveTripResponse> {
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<TripSearchResponse> {
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.
// The replaced cover stays: a trip copy or a journey may show the same
// object, and the storage reclaim job removes it once nothing names it.
await this.storage.put('covers', file.filename, { tmpPath: file.path });
const coverUrl = `/uploads/covers/${file.filename}`;
await this.trips.updateCoverImage(id, coverUrl);
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;
}
}
}