mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
db0fbc1ccd
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.
356 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|