mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
d17457de56
Leaving every replaced cover, place image and deleted journey photo to the nightly pass kept them publicly fetchable by URL for a week at least, and for good while deletion was off. Owners now hand the values a delete or a replace left behind to StorageReleaseService once their write committed. It asks every registered reference source, with the same parser as the nightly pass, and deletes at once what no row names any more: an object a trip copy, a journey or a saved place still shows stays, which the old inline deletes got wrong. The release holds the pass's guards (reclaimable categories at their root only, never an owner marker, never while a restore runs, never with a required source missing or an overlapping location), never fails the request and logs at debug. It runs for trip covers (update, upload, trip delete), collection covers and saved-place images, place images (update, bulk update, delete), journey covers and journey or gallery photos (their thumbnail goes with a file that went), avatars (change, delete, account deletion), trip files (permanent delete, empty trash) and collab note and chat attachments. A trip file row now goes before its bytes, so a failing backend no longer fails the delete. The objects of a deleted trip's places and files are left to the nightly pass: their rows are other domains'. PlacesService drops the collection places repository nothing read any more. Size, warning and boundary baselines follow the shrunk files.
358 lines
14 KiB
TypeScript
358 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 { 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<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.
|
|
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;
|
|
}
|
|
}
|
|
}
|