mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
Replace the copied requireTrip helpers with a TripAccessGuard
Every trip-scoped controller carried its own three-line requireTrip, and a requireEdit next to it: eighteen copies of a security check, each of which had to remember that no access is a 404 'Trip not found' and never a 403 — the distinction that stops a stranger learning which trip ids exist. One of them getting it wrong would have looked like nothing. TripAccessGuard resolves :tripId once, refuses what the user cannot reach and hands the row to the handler through a @Trip() param decorator (a param decorator rather than a req.trip cast, because the request type that used to be widened for that is gone). @RequirePermission carries the same action string the domain services pass to checkPermission, so the HTTP and MCP paths cannot drift apart on which right they demand. Status codes and bodies are unchanged. Piloted on days, day-notes, todo, packing and files. Two things worth knowing: Files applies the guard PER HANDLER and leaves the upload route on its own check. Guards run before interceptors, so a class-level guard would answer 404 before multer had read the multipart body, and a response sent while the client is still streaming gets the socket destroyed — the caller sees ECONNRESET instead of the 404. Refusing after the body has been read costs the bandwidth and keeps the answer readable. The services keep verifyTripAccess and canEdit: the large majority of their callers are *.mcp.ts tools, which never pass through an HTTP guard. canEdit lost its incidental controller-test coverage in the move, so it now has direct tests asserting the action and the shared flag — the same pair the guard reproduces. Also fixes the backup zip-slip test that failed only on CI: it asserted a drive-letter path, which is absolute on win32 and an ordinary relative segment on Linux, so the branch it was guarding never fired there.
This commit is contained in:
@@ -15,6 +15,7 @@ import { DayNotesService } from './day-notes.service';
|
||||
import { DayNoteCreateDto, DayNoteUpdateDto } from './day-notes.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days/:dayId/notes — free-text annotations on a day.
|
||||
@@ -27,30 +28,21 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
* forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days/:dayId/notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('day_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class DayNotesController {
|
||||
constructor(private readonly notes: DayNotesService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.notes.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<DayNotesService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.notes.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { notes: this.notes.list(dayId, tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -59,8 +51,6 @@ export class DayNotesController {
|
||||
@Body() body: DayNoteCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.notes.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
@@ -72,6 +62,7 @@ export class DayNotesController {
|
||||
return { note };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -81,8 +72,6 @@ export class DayNotesController {
|
||||
@Body() body: DayNoteUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.notes.getNote(id, dayId, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
@@ -92,6 +81,7 @@ export class DayNotesController {
|
||||
return { note };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -100,8 +90,6 @@ export class DayNotesController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.notes.getNote(id, dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DaysService, DayReorderError } from './days.service';
|
||||
import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days — trip itinerary days.
|
||||
@@ -25,30 +26,20 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
* broadcasts with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach, so every
|
||||
// handler below is already scoped. Mutations add @RequirePermission('day_edit'),
|
||||
// which is the same action string days.service.canEdit passes — the MCP tools still
|
||||
// go through that method, and this keeps the two from drifting.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class DaysController {
|
||||
constructor(private readonly days: DaysService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.days.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<DaysService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.days.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return this.days.list(tripId);
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -56,8 +47,6 @@ export class DaysController {
|
||||
@Body() body: DayCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// A `position` means "insert a new empty day here" (which on a dated trip
|
||||
// extends the trip and re-pins dates); without it, the legacy append.
|
||||
const day = body.position !== undefined
|
||||
@@ -70,6 +59,7 @@ export class DaysController {
|
||||
return { day };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@@ -77,8 +67,6 @@ export class DaysController {
|
||||
@Body() body: DayReorderDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(body.orderedIds)) {
|
||||
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
|
||||
}
|
||||
@@ -94,6 +82,7 @@ export class DaysController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -102,8 +91,6 @@ export class DaysController {
|
||||
@Body() body: DayUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.days.getDay(id, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
@@ -116,6 +103,7 @@ export class DaysController {
|
||||
return { day };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/transport')
|
||||
transport(
|
||||
@CurrentUser() user: User,
|
||||
@@ -124,8 +112,6 @@ export class DaysController {
|
||||
@Body() body: DayTransportDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.days.getDay(id, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
@@ -134,6 +120,7 @@ export class DaysController {
|
||||
return { day };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -141,8 +128,6 @@ export class DaysController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.days.getDay(id, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import type { User } from '../../types';
|
||||
import { FilesService } from './files.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import { Trip } from '../permissions/trip.decorator';
|
||||
import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, isVideoExtension } from './files.constants';
|
||||
import { getAllowedExtensions } from './files.bridge';
|
||||
import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto';
|
||||
@@ -67,6 +70,17 @@ const UPLOAD = {
|
||||
* the WebSocket broadcasts with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/files')
|
||||
// TripAccessGuard carries the trip access check, applied PER HANDLER rather than on
|
||||
// the class. The per-handler RIGHT stays inline because files use three different ones
|
||||
// (file_upload, file_edit, file_delete), which one class-level @RequirePermission could
|
||||
// not express.
|
||||
//
|
||||
// `upload` is deliberately left off the guard and keeps its own check. Guards run
|
||||
// before interceptors, so a class-level guard would answer 404 before multer had read
|
||||
// the multipart body — and a response sent while the client is still streaming gets the
|
||||
// socket destroyed, so the caller sees ECONNRESET instead of the 404. Refusing after
|
||||
// the body has been read costs the bandwidth but keeps the answer readable, which is
|
||||
// the behaviour every client already handles.
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FilesController {
|
||||
constructor(
|
||||
@@ -74,13 +88,6 @@ export class FilesController {
|
||||
private readonly env: RuntimeEnvService,
|
||||
) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.files.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
// A file may only point at reservations/assignments/places from its own trip.
|
||||
// Reject cross-trip ids before they are stored — the reservation JOIN would
|
||||
@@ -91,9 +98,9 @@ export class FilesController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
|
||||
return { files: this.files.listFiles(tripId, trash === 'true') };
|
||||
}
|
||||
|
||||
@@ -111,9 +118,12 @@ export class FilesController {
|
||||
// leaves up to the 500 MB video cap on disk (#823).
|
||||
const cleanup = () => { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } };
|
||||
try {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
// Inline rather than DemoWriteGuard: requireTrip above answers 404 for a
|
||||
// trip the caller cannot reach, and a guard would run before it.
|
||||
const trip = this.files.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
// Inline rather than DemoWriteGuard: the 404 above has to come first, so a demo
|
||||
// user still cannot learn which trips exist.
|
||||
if (isDemoWriteBlocked(this.env, user.email)) {
|
||||
throw new HttpException(DEMO_WRITE_ERROR, 403);
|
||||
}
|
||||
@@ -150,9 +160,9 @@ export class FilesController {
|
||||
return { file: created };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Put(':id')
|
||||
update(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to edit files' }, 403);
|
||||
}
|
||||
@@ -166,9 +176,9 @@ export class FilesController {
|
||||
return { file: updated };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Patch(':id/star')
|
||||
star(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -181,9 +191,9 @@ export class FilesController {
|
||||
return { file: updated };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete('trash/empty')
|
||||
async emptyTrash(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -191,9 +201,9 @@ export class FilesController {
|
||||
return { success: true, deleted };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id/permanent')
|
||||
async permanent(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
async permanent(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -206,9 +216,9 @@ export class FilesController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to delete files' }, 403);
|
||||
}
|
||||
@@ -221,10 +231,10 @@ export class FilesController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
|
||||
restore(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -237,10 +247,10 @@ export class FilesController {
|
||||
return { file: restored };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Post(':id/link')
|
||||
@HttpCode(200) // Express answers link with res.json (200).
|
||||
link(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -253,9 +263,9 @@ export class FilesController {
|
||||
return { success: true, links };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id/link/:linkId')
|
||||
unlink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -263,9 +273,9 @@ export class FilesController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get(':id/links')
|
||||
links(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
return { links: this.files.getFileLinks(id) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PackingService } from './packing.service';
|
||||
import { isUpdateConflict } from '../common/conflictResult';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import {
|
||||
PackingApplyTemplateDto,
|
||||
PackingBagMembersDto,
|
||||
@@ -52,32 +53,23 @@ type PackingItemRow = { is_private?: number; owner_id?: number | null; recipient
|
||||
* import arrays, the admin template gate) keep their exact legacy strings.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/packing')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('packing_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class PackingController {
|
||||
constructor(private readonly packing: PackingService) {}
|
||||
|
||||
/** Loads the trip or throws the legacy 404; returns it for the permission check. */
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.packing.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<PackingService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.packing.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
// Pass the viewer so private items (#858) owned by other members are hidden.
|
||||
return { items: this.packing.listItems(tripId, user.id) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post('import')
|
||||
importItems(
|
||||
@CurrentUser() user: User,
|
||||
@@ -85,8 +77,6 @@ export class PackingController {
|
||||
@Body() body: PackingImportDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// The schema guarantees an array; the empty-array rejection stays a bespoke 400.
|
||||
if (body.items.length === 0) {
|
||||
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
|
||||
@@ -98,6 +88,7 @@ export class PackingController {
|
||||
return { items: created, count: created.length };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -105,14 +96,13 @@ export class PackingController {
|
||||
@Body() body: PackingCreateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// checked arrives as boolean or legacy 0/1 — the service coerces by truthiness.
|
||||
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
|
||||
this.packing.emitToViewers(tripId, 'packing:created', { item }, item, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@@ -120,12 +110,11 @@ export class PackingController {
|
||||
@Body() body: PackingReorderDto,
|
||||
@Headers('x-socket-id') _socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.packing.reorderItems(tripId, body.orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -135,8 +124,6 @@ export class PackingController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
@Headers('x-base-updated-at') ifMatch?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// Privacy state before the change, so a public↔private toggle (#858) can route
|
||||
// the broadcast correctly instead of leaking a freshly-privatized item.
|
||||
const before = this.packing.getItemPrivacy(tripId, id);
|
||||
@@ -156,6 +143,7 @@ export class PackingController {
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -163,8 +151,6 @@ export class PackingController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const deleted = this.packing.deleteItem(tripId, id);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
@@ -174,6 +160,7 @@ export class PackingController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put(':id/sharing')
|
||||
setSharing(
|
||||
@CurrentUser() user: User,
|
||||
@@ -182,8 +169,6 @@ export class PackingController {
|
||||
@Body() body: PackingSetSharingDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const updated = this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
@@ -198,6 +183,7 @@ export class PackingController {
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post(':id/clone')
|
||||
@HttpCode(201)
|
||||
clone(
|
||||
@@ -206,8 +192,6 @@ export class PackingController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const item = this.packing.cloneItem(tripId, id, user.id);
|
||||
if (!item) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
@@ -217,6 +201,7 @@ export class PackingController {
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post(':id/contributors')
|
||||
addContributor(
|
||||
@CurrentUser() user: User,
|
||||
@@ -224,8 +209,6 @@ export class PackingController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const item = this.packing.addContributor(tripId, id, user.id);
|
||||
if (!item) {
|
||||
throw new HttpException({ error: 'Item not found or not a shared list item' }, 404);
|
||||
@@ -235,6 +218,7 @@ export class PackingController {
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Delete(':id/contributors/:userId')
|
||||
removeContributor(
|
||||
@CurrentUser() user: User,
|
||||
@@ -243,8 +227,6 @@ export class PackingController {
|
||||
@Param('userId') userId: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// You can drop your own pledge; the owner can remove anyone's.
|
||||
const target = parseInt(userId);
|
||||
const item = this.packing.removeContributor(tripId, id, target);
|
||||
@@ -257,10 +239,10 @@ export class PackingController {
|
||||
|
||||
@Get('bags')
|
||||
listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { bags: this.packing.listBags(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post('bags')
|
||||
createBag(
|
||||
@CurrentUser() user: User,
|
||||
@@ -268,8 +250,6 @@ export class PackingController {
|
||||
@Body() body: PackingCreateBagDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// The schema requires a non-empty name; whitespace-only still 400s here.
|
||||
if (!body.name.trim()) {
|
||||
throw new HttpException({ error: 'Name is required' }, 400);
|
||||
@@ -279,6 +259,7 @@ export class PackingController {
|
||||
return { bag };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('bags/:bagId')
|
||||
updateBag(
|
||||
@CurrentUser() user: User,
|
||||
@@ -287,8 +268,6 @@ export class PackingController {
|
||||
@Body() body: PackingUpdateBagDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, color, weight_limit_grams, user_id } = body;
|
||||
// bodyKeys carries which keys the request actually provided (the presence-
|
||||
// sentinel protocol); the parsed body only ever holds known schema keys.
|
||||
@@ -300,6 +279,7 @@ export class PackingController {
|
||||
return { bag: updated };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Delete('bags/:bagId')
|
||||
deleteBag(
|
||||
@CurrentUser() user: User,
|
||||
@@ -307,8 +287,6 @@ export class PackingController {
|
||||
@Param('bagId') bagId: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.packing.deleteBag(tripId, bagId)) {
|
||||
throw new HttpException({ error: 'Bag not found' }, 404);
|
||||
}
|
||||
@@ -318,10 +296,10 @@ export class PackingController {
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { templates: this.packing.listTemplates() };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post('apply-template/:templateId')
|
||||
@HttpCode(200)
|
||||
applyTemplate(
|
||||
@@ -331,8 +309,6 @@ export class PackingController {
|
||||
@Body() body: PackingApplyTemplateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const visibility = body?.visibility === 'personal' ? 'personal' : 'common';
|
||||
const added = this.packing.applyTemplate(tripId, templateId, visibility, user.id);
|
||||
if (!added) {
|
||||
@@ -342,6 +318,7 @@ export class PackingController {
|
||||
return { items: added, count: added.length };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('bags/:bagId/members')
|
||||
setBagMembers(
|
||||
@CurrentUser() user: User,
|
||||
@@ -350,8 +327,6 @@ export class PackingController {
|
||||
@Body() body: PackingBagMembersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const members = this.packing.setBagMembers(tripId, bagId, body.user_ids);
|
||||
if (!members) {
|
||||
throw new HttpException({ error: 'Bag not found' }, 404);
|
||||
@@ -360,13 +335,13 @@ export class PackingController {
|
||||
return { members };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post('save-as-template')
|
||||
saveAsTemplate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: PackingSaveTemplateDto,
|
||||
) {
|
||||
this.requireTrip(tripId, user);
|
||||
if (user.role !== 'admin') {
|
||||
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||
}
|
||||
@@ -383,10 +358,10 @@ export class PackingController {
|
||||
|
||||
@Get('category-assignees')
|
||||
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { assignees: this.packing.getCategoryAssignees(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('category-assignees/:categoryName')
|
||||
updateCategoryAssignees(
|
||||
@CurrentUser() user: User,
|
||||
@@ -395,8 +370,6 @@ export class PackingController {
|
||||
@Body() body: PackingCategoryAssigneesDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const category = decodeURIComponent(categoryName);
|
||||
const rows = this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
|
||||
this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import { TripAccessGuard } from './trip-access.guard';
|
||||
|
||||
/** Cross-cutting permissions domain (Wave 2). No controller/MCP surface of its
|
||||
* own — the admin HTTP surface stays with AdminModule. Exports
|
||||
* PermissionsService for the 17 in-container consumers; deliberately NOT
|
||||
* @Global so e2e TestingModules resolve it transitively through each
|
||||
* consumer's explicit import. Registered in AppModule. */
|
||||
* PermissionsService for the 17 in-container consumers and TripAccessGuard for
|
||||
* every trip-scoped controller; deliberately NOT @Global so e2e TestingModules
|
||||
* resolve it transitively through each consumer's explicit import. Registered in
|
||||
* AppModule. */
|
||||
@Module({
|
||||
providers: [PermissionsService],
|
||||
exports: [PermissionsService],
|
||||
providers: [PermissionsService, TripAccessGuard],
|
||||
exports: [PermissionsService, TripAccessGuard],
|
||||
})
|
||||
export class PermissionsModule {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/** Where the guard parks the resolved trip row for `@Trip()` to pick up. */
|
||||
export const TRIP_REQUEST_KEY = 'trekTrip';
|
||||
|
||||
/** The metadata key `@RequirePermission()` writes and the guard reads. */
|
||||
export const TRIP_PERMISSION_KEY = 'trekTripPermission';
|
||||
|
||||
/**
|
||||
* Requires a permission on the trip the route is scoped to, on top of access.
|
||||
*
|
||||
* The action string is the same one the domain services pass to
|
||||
* `PermissionsService.checkPermission` ('day_edit', 'budget_edit', …), so a route
|
||||
* and its MCP counterpart cannot drift apart on which right they demand.
|
||||
*/
|
||||
export const RequirePermission = (action: string) => SetMetadata(TRIP_PERMISSION_KEY, action);
|
||||
|
||||
type TripRequest = Request & { user?: User; [TRIP_REQUEST_KEY]?: TripAccess };
|
||||
|
||||
/**
|
||||
* Resolves `:tripId` once per request, refuses what the user cannot reach, and hands
|
||||
* the trip row to the handler.
|
||||
*
|
||||
* Every trip-scoped controller used to carry its own `requireTrip` helper doing the
|
||||
* same three lines, and a `requireEdit` next to it. That is 18 copies of a security
|
||||
* check, each of which had to remember that no access is a 404 "Trip not found" and
|
||||
* never a 403 — the distinction that keeps a stranger from learning which trip ids
|
||||
* exist. One of them getting it wrong would have looked like nothing.
|
||||
*
|
||||
* The status codes and bodies here are byte-identical to those copies, because the
|
||||
* e2e suites assert them and the client branches on them.
|
||||
*
|
||||
* It deliberately does NOT replace the `verifyTripAccess`/`canEdit` methods on the
|
||||
* domain services. Of their callers, the large majority are `*.mcp.ts` tools, which
|
||||
* never pass through an HTTP guard; those methods stay exactly where they are.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripAccessGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<TripRequest>();
|
||||
const user = request.user;
|
||||
// JwtAuthGuard runs first and 401s an anonymous request, so a missing user here
|
||||
// means the route was wired without it. Refuse rather than read `user.id` off
|
||||
// undefined and turn a wiring mistake into a 500.
|
||||
if (!user) throw new HttpException({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const tripId = Number((request.params as Record<string, string>)?.tripId);
|
||||
const trip = Number.isFinite(tripId) ? this.db.canAccessTrip(tripId, user.id) : undefined;
|
||||
// A trip the user may not see is reported as absent, never as forbidden: a 403
|
||||
// would confirm the id exists to someone who has no business knowing.
|
||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
|
||||
const action = this.reflector.getAllAndOverride<string | undefined>(TRIP_PERMISSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (action) {
|
||||
const shared = trip.user_id !== user.id;
|
||||
if (!this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
request[TRIP_REQUEST_KEY] = trip;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import { TRIP_REQUEST_KEY } from './trip-access.guard';
|
||||
|
||||
/**
|
||||
* The trip row `TripAccessGuard` resolved for this request.
|
||||
*
|
||||
* A param decorator rather than a `req.trip` cast on purpose: the request type the
|
||||
* controllers used to widen for that (`AuthRequest`) is gone, and re-introducing a
|
||||
* per-controller cast would put the same untyped hole back. Only usable on a route
|
||||
* that carries `@UseGuards(TripAccessGuard)` — without it there is nothing to read,
|
||||
* and the handler would silently receive undefined, so it throws instead.
|
||||
*/
|
||||
export const Trip = createParamDecorator((_data: unknown, context: ExecutionContext): TripAccess => {
|
||||
const request = context.switchToHttp().getRequest<Record<string, unknown>>();
|
||||
const trip = request[TRIP_REQUEST_KEY] as TripAccess | undefined;
|
||||
if (!trip) {
|
||||
throw new Error('@Trip() used on a route without @UseGuards(TripAccessGuard)');
|
||||
}
|
||||
return trip;
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { TodoService } from './todo.service';
|
||||
import { TodoCreateItemDto, TodoUpdateItemDto, TodoReorderDto, TodoCategoryAssigneesDto } from './todo.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/todo — trip-scoped task list.
|
||||
@@ -28,30 +29,21 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
* bespoke 'Item name is required' check).
|
||||
*/
|
||||
@Controller('api/trips/:tripId/todo')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('packing_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class TodoController {
|
||||
constructor(private readonly todo: TodoService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.todo.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<TodoService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.todo.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.todo.listItems(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -59,26 +51,24 @@ export class TodoController {
|
||||
@Body() body: TodoCreateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, category, due_date, description, assigned_user_id, priority } = body;
|
||||
const item = this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
this.todo.broadcast(tripId, 'todo:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: TodoReorderDto,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.todo.reorderItems(tripId, body.orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -87,8 +77,6 @@ export class TodoController {
|
||||
@Body() body: TodoUpdateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, checked, category, due_date, description, assigned_user_id, priority } = body;
|
||||
// bodyKeys carries which keys the request actually provided (the null-clear
|
||||
// protocol); the parsed body only ever holds known schema keys.
|
||||
@@ -106,6 +94,7 @@ export class TodoController {
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -113,8 +102,6 @@ export class TodoController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.todo.deleteItem(tripId, id)) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
@@ -124,10 +111,10 @@ export class TodoController {
|
||||
|
||||
@Get('category-assignees')
|
||||
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { assignees: this.todo.getCategoryAssignees(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('category-assignees/:categoryName')
|
||||
updateCategoryAssignees(
|
||||
@CurrentUser() user: User,
|
||||
@@ -136,8 +123,6 @@ export class TodoController {
|
||||
@Body() body: TodoCategoryAssigneesDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const category = decodeURIComponent(categoryName);
|
||||
const rows = this.todo.updateCategoryAssignees(tripId, category, body.user_ids);
|
||||
this.todo.broadcast(tripId, 'todo:assignees', { category, assignees: rows }, socketId);
|
||||
|
||||
Reference in New Issue
Block a user