mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
366 lines
15 KiB
TypeScript
366 lines
15 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 {
|
|
TripCreateDto,
|
|
TripUpdateDto,
|
|
TripCopyDto,
|
|
TripAddMemberDto,
|
|
TripTransferOwnershipDto,
|
|
TripCreateGuestDto,
|
|
TripRenameGuestDto,
|
|
} from './trips.dto';
|
|
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,
|
|
) {}
|
|
|
|
@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) {
|
|
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.trips.create(user.id, {
|
|
title,
|
|
description,
|
|
start_date,
|
|
end_date,
|
|
currency,
|
|
reminder_days,
|
|
day_count: parsedDayCount,
|
|
});
|
|
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;
|
|
if (
|
|
body.is_archived !== undefined &&
|
|
!(await this.trips.can('trip_archive', user.role, ownerId, user.id, isMember))
|
|
) {
|
|
throw new HttpException({ error: 'No permission to archive/unarchive this trip' }, 403);
|
|
}
|
|
if (
|
|
body.cover_image !== undefined &&
|
|
!(await this.trips.can('trip_cover_upload', user.role, ownerId, user.id, isMember))
|
|
) {
|
|
throw new HttpException({ error: 'No permission to change cover image' }, 403);
|
|
}
|
|
const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count'];
|
|
if (
|
|
editFields.some((f) => body[f] !== undefined) &&
|
|
!(await this.trips.can('trip_edit', user.role, ownerId, user.id, isMember))
|
|
) {
|
|
throw new HttpException({ error: 'No permission to edit this trip' }, 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);
|
|
}
|
|
}
|
|
const oldCover =
|
|
body.cover_image !== undefined
|
|
? ((await this.trips.getRaw(id)) as { cover_image: string | null } | undefined)?.cover_image
|
|
: undefined;
|
|
try {
|
|
const result = await this.trips.update(id, user.id, body, user.role);
|
|
if (body.cover_image !== undefined && body.cover_image !== oldCover) {
|
|
await this.trips.deleteOldCover(oldCover);
|
|
}
|
|
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 });
|
|
await this.trips.deleteOldCover(trip.cover_image);
|
|
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) {
|
|
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.trips.copy(id, user.id, title);
|
|
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.trips.remove(id, user.id, user.role);
|
|
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}`);
|
|
this.trips.broadcast(String(info.tripId), 'trip:deleted', { id: info.tripId }, socketId);
|
|
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;
|
|
}
|
|
}
|
|
}
|