diff --git a/backend/apps/api/src/trips/trip-members.controller.spec.ts b/backend/apps/api/src/trips/trip-members.controller.spec.ts new file mode 100644 index 0000000..a7f59a2 --- /dev/null +++ b/backend/apps/api/src/trips/trip-members.controller.spec.ts @@ -0,0 +1,47 @@ +import { TripMembersController } from './trip-members.controller'; + +describe('TripMembersController', () => { + it('GET /trips/:tripId/members lists members', async () => { + const members = [ + { id: 'm1', tripId: 't1', userId: 'u1', role: 'OWNER', status: 'ACTIVE' }, + ]; + const tripMembersService = { + listMembers: jest.fn().mockResolvedValue(members), + }; + const controller = new TripMembersController(tripMembersService as never); + + await expect(controller.list('t1')).resolves.toEqual(members); + expect(tripMembersService.listMembers).toHaveBeenCalledWith('t1'); + }); + + it('PATCH /trips/:tripId/members/:memberId updates the member', async () => { + const updated = { + id: 'm1', + tripId: 't1', + userId: 'u2', + role: 'MEMBER', + status: 'ACTIVE', + }; + const tripMembersService = { + updateMember: jest.fn().mockResolvedValue(updated), + }; + const controller = new TripMembersController(tripMembersService as never); + + await expect( + controller.update('t1', 'm1', { role: 'MEMBER' }), + ).resolves.toEqual(updated); + expect(tripMembersService.updateMember).toHaveBeenCalledWith('t1', 'm1', { + role: 'MEMBER', + }); + }); + + it('DELETE /trips/:tripId/members/:memberId removes the member', async () => { + const tripMembersService = { + removeMember: jest.fn().mockResolvedValue(undefined), + }; + const controller = new TripMembersController(tripMembersService as never); + + await controller.remove('t1', 'm1'); + expect(tripMembersService.removeMember).toHaveBeenCalledWith('t1', 'm1'); + }); +}); diff --git a/backend/apps/api/src/trips/trip-members.controller.ts b/backend/apps/api/src/trips/trip-members.controller.ts new file mode 100644 index 0000000..7d3ebf5 --- /dev/null +++ b/backend/apps/api/src/trips/trip-members.controller.ts @@ -0,0 +1,55 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + UseGuards, +} from '@nestjs/common'; +import { OidcAuthGuard } from '../../../../libs/auth/src'; +import { + TripMembersService, + TripMembershipGuard, + TripRoles, +} from '../../../../libs/trips/src'; +import type { + TripMember, + TripMemberRole, + TripMemberStatus, +} from '../../../../libs/trips/src'; + +interface UpdateTripMemberDto { + role?: TripMemberRole; + status?: TripMemberStatus; +} + +@Controller('trips/:tripId/members') +@UseGuards(OidcAuthGuard, TripMembershipGuard) +export class TripMembersController { + constructor(private readonly tripMembersService: TripMembersService) {} + + @Get() + list(@Param('tripId') tripId: string): Promise { + return this.tripMembersService.listMembers(tripId); + } + + @Patch(':memberId') + @TripRoles('OWNER') + update( + @Param('tripId') tripId: string, + @Param('memberId') memberId: string, + @Body() dto: UpdateTripMemberDto, + ): Promise { + return this.tripMembersService.updateMember(tripId, memberId, dto); + } + + @Delete(':memberId') + @TripRoles('OWNER') + remove( + @Param('tripId') tripId: string, + @Param('memberId') memberId: string, + ): Promise { + return this.tripMembersService.removeMember(tripId, memberId); + } +} diff --git a/backend/apps/api/src/trips/trips.controller.spec.ts b/backend/apps/api/src/trips/trips.controller.spec.ts index 47eb7e9..c711813 100644 --- a/backend/apps/api/src/trips/trips.controller.spec.ts +++ b/backend/apps/api/src/trips/trips.controller.spec.ts @@ -9,13 +9,23 @@ describe('TripsController', () => { email: 'a@example.com', }; + function controllerWith( + tripsService: object, + tripSettingsService: object = {}, + ): TripsController { + return new TripsController( + tripsService as never, + tripSettingsService as never, + ); + } + it('GET /trips lists trips for the current user', async () => { const tripsService = { listTripsForUser: jest .fn() .mockResolvedValue([{ id: 't1', name: 'Slovenia 2027' }]), }; - const controller = new TripsController(tripsService as never); + const controller = controllerWith(tripsService); await expect(controller.list(currentUser)).resolves.toEqual([ { id: 't1', name: 'Slovenia 2027' }, @@ -31,7 +41,7 @@ describe('TripsController', () => { version: 1, }; const tripsService = { createTrip: jest.fn().mockResolvedValue(created) }; - const controller = new TripsController(tripsService as never); + const controller = controllerWith(tripsService); await expect( controller.create(currentUser, { name: 'Slovenia 2027' }), @@ -44,7 +54,7 @@ describe('TripsController', () => { it('GET /trips/:tripId returns a single trip', async () => { const trip = { id: 't1', name: 'Slovenia 2027' }; const tripsService = { getTrip: jest.fn().mockResolvedValue(trip) }; - const controller = new TripsController(tripsService as never); + const controller = controllerWith(tripsService); await expect(controller.getOne('t1')).resolves.toEqual(trip); }); @@ -52,7 +62,7 @@ describe('TripsController', () => { it('PATCH /trips/:tripId forwards the update dto including version', async () => { const updated = { id: 't1', name: 'New name', version: 2 }; const tripsService = { updateTrip: jest.fn().mockResolvedValue(updated) }; - const controller = new TripsController(tripsService as never); + const controller = controllerWith(tripsService); await expect( controller.update('t1', { name: 'New name', version: 1 }), @@ -65,9 +75,41 @@ describe('TripsController', () => { it('DELETE /trips/:tripId deletes the trip', async () => { const tripsService = { deleteTrip: jest.fn().mockResolvedValue(undefined) }; - const controller = new TripsController(tripsService as never); + const controller = controllerWith(tripsService); await controller.remove('t1'); expect(tripsService.deleteTrip).toHaveBeenCalledWith('t1'); }); + + it('GET /trips/:tripId/settings returns the trip settings', async () => { + const settings = { tripId: 't1', webResearchEnabled: false }; + const tripSettingsService = { + getSettings: jest.fn().mockResolvedValue(settings), + }; + const controller = controllerWith({}, tripSettingsService); + + await expect(controller.getSettings('t1')).resolves.toEqual(settings); + expect(tripSettingsService.getSettings).toHaveBeenCalledWith('t1'); + }); + + it('PUT /trips/:tripId/settings replaces the trip settings', async () => { + const dto = { + webResearchEnabled: true, + periodicAgentReviewEnabled: false, + notificationEmailEnabled: true, + notificationPushEnabled: true, + defaultResearchDepth: null, + defaultPlanningStyle: null, + }; + const updated = { tripId: 't1', ...dto }; + const tripSettingsService = { + replaceSettings: jest.fn().mockResolvedValue(updated), + }; + const controller = controllerWith({}, tripSettingsService); + + await expect(controller.replaceSettings('t1', dto)).resolves.toEqual( + updated, + ); + expect(tripSettingsService.replaceSettings).toHaveBeenCalledWith('t1', dto); + }); }); diff --git a/backend/apps/api/src/trips/trips.controller.ts b/backend/apps/api/src/trips/trips.controller.ts index 254bf4d..b2ce469 100644 --- a/backend/apps/api/src/trips/trips.controller.ts +++ b/backend/apps/api/src/trips/trips.controller.ts @@ -6,22 +6,33 @@ import { Param, Patch, Post, + Put, UseGuards, } from '@nestjs/common'; import { OidcAuthGuard } from '../../../../libs/auth/src'; import type { AuthenticatedUser } from '../../../../libs/auth/src'; -import { TripsService } from '../../../../libs/trips/src'; +import { + TripMembershipGuard, + TripRoles, + TripSettingsService, + TripsService, +} from '../../../../libs/trips/src'; import type { CreateTripDto, Trip, + TripSettings, UpdateTripDto, + UpdateTripSettingsDto, } from '../../../../libs/trips/src'; import { CurrentUser } from '../auth/current-user.decorator'; @Controller('trips') @UseGuards(OidcAuthGuard) export class TripsController { - constructor(private readonly tripsService: TripsService) {} + constructor( + private readonly tripsService: TripsService, + private readonly tripSettingsService: TripSettingsService, + ) {} @Get() list(@CurrentUser() currentUser: AuthenticatedUser): Promise { @@ -37,11 +48,14 @@ export class TripsController { } @Get(':tripId') + @UseGuards(TripMembershipGuard) getOne(@Param('tripId') tripId: string): Promise { return this.tripsService.getTrip(tripId); } @Patch(':tripId') + @UseGuards(TripMembershipGuard) + @TripRoles('OWNER') update( @Param('tripId') tripId: string, @Body() dto: UpdateTripDto, @@ -50,7 +64,25 @@ export class TripsController { } @Delete(':tripId') + @UseGuards(TripMembershipGuard) + @TripRoles('OWNER') remove(@Param('tripId') tripId: string): Promise { return this.tripsService.deleteTrip(tripId); } + + @Get(':tripId/settings') + @UseGuards(TripMembershipGuard) + getSettings(@Param('tripId') tripId: string): Promise { + return this.tripSettingsService.getSettings(tripId); + } + + @Put(':tripId/settings') + @UseGuards(TripMembershipGuard) + @TripRoles('OWNER') + replaceSettings( + @Param('tripId') tripId: string, + @Body() dto: UpdateTripSettingsDto, + ): Promise { + return this.tripSettingsService.replaceSettings(tripId, dto); + } } diff --git a/backend/apps/api/src/trips/trips.module.ts b/backend/apps/api/src/trips/trips.module.ts index cec2767..cf95983 100644 --- a/backend/apps/api/src/trips/trips.module.ts +++ b/backend/apps/api/src/trips/trips.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../../../../libs/auth/src'; import { TripsLibModule } from '../../../../libs/trips/src'; import { TripsController } from './trips.controller'; +import { TripMembersController } from './trip-members.controller'; @Module({ imports: [AuthModule, TripsLibModule], - controllers: [TripsController], + controllers: [TripsController, TripMembersController], }) export class TripsApiModule {} diff --git a/backend/libs/trips/src/index.ts b/backend/libs/trips/src/index.ts index 29998d4..d04ef17 100644 --- a/backend/libs/trips/src/index.ts +++ b/backend/libs/trips/src/index.ts @@ -1,4 +1,7 @@ export * from './trip.types'; export * from './trips.service'; export * from './trip-settings.service'; +export * from './trip-members.service'; +export * from './trip-membership.guard'; +export * from './trip-roles.decorator'; export * from './trips.module'; diff --git a/backend/libs/trips/src/trip-members.repository.ts b/backend/libs/trips/src/trip-members.repository.ts new file mode 100644 index 0000000..06a2888 --- /dev/null +++ b/backend/libs/trips/src/trip-members.repository.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Kysely } from 'kysely'; +import { KYSELY_DB } from '../../database/src'; +import type { Database } from '../../database/src'; +import type { + TripMember, + TripMemberRole, + TripMemberStatus, +} from './trip.types'; + +function toTripMember(row: { + id: string; + trip_id: string; + user_id: string; + role: string; + status: string; + joined_at: Date | null; + created_at: Date; + updated_at: Date; +}): TripMember { + return { + id: row.id, + tripId: row.trip_id, + userId: row.user_id, + role: row.role as TripMemberRole, + status: row.status as TripMemberStatus, + joinedAt: row.joined_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +@Injectable() +export class TripMembersRepository { + constructor(@Inject(KYSELY_DB) private readonly db: Kysely) {} + + async findByTripAndUser( + tripId: string, + userId: string, + ): Promise { + const row = await this.db + .selectFrom('trip_members') + .selectAll() + .where('trip_id', '=', tripId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row ? toTripMember(row) : undefined; + } + + async listByTrip(tripId: string): Promise { + const rows = await this.db + .selectFrom('trip_members') + .selectAll() + .where('trip_id', '=', tripId) + .execute(); + return rows.map(toTripMember); + } + + async upsertActiveMember( + tripId: string, + userId: string, + role: TripMemberRole = 'MEMBER', + ): Promise { + const row = await this.db + .insertInto('trip_members') + .values({ + trip_id: tripId, + user_id: userId, + role, + status: 'ACTIVE', + joined_at: new Date().toISOString(), + }) + .onConflict((oc) => + oc.columns(['trip_id', 'user_id']).doUpdateSet({ + status: 'ACTIVE', + joined_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + return toTripMember(row); + } + + async updateRoleOrStatus( + tripId: string, + memberId: string, + patch: { role?: TripMemberRole; status?: TripMemberStatus }, + ): Promise { + const row = await this.db + .updateTable('trip_members') + .set({ ...patch, updated_at: new Date().toISOString() }) + .where('trip_id', '=', tripId) + .where('id', '=', memberId) + .returningAll() + .executeTakeFirst(); + return row ? toTripMember(row) : undefined; + } + + async remove(tripId: string, memberId: string): Promise { + await this.db + .deleteFrom('trip_members') + .where('trip_id', '=', tripId) + .where('id', '=', memberId) + .execute(); + } +} diff --git a/backend/libs/trips/src/trip-members.service.ts b/backend/libs/trips/src/trip-members.service.ts new file mode 100644 index 0000000..7585e88 --- /dev/null +++ b/backend/libs/trips/src/trip-members.service.ts @@ -0,0 +1,34 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { TripMembersRepository } from './trip-members.repository'; +import type { + TripMember, + TripMemberRole, + TripMemberStatus, +} from './trip.types'; + +@Injectable() +export class TripMembersService { + constructor(private readonly repository: TripMembersRepository) {} + + listMembers(tripId: string): Promise { + return this.repository.listByTrip(tripId); + } + + async updateMember( + tripId: string, + memberId: string, + patch: { role?: TripMemberRole; status?: TripMemberStatus }, + ): Promise { + const updated = await this.repository.updateRoleOrStatus( + tripId, + memberId, + patch, + ); + if (!updated) throw new NotFoundException('Trip member not found'); + return updated; + } + + removeMember(tripId: string, memberId: string): Promise { + return this.repository.remove(tripId, memberId); + } +} diff --git a/backend/libs/trips/src/trip-membership.guard.spec.ts b/backend/libs/trips/src/trip-membership.guard.spec.ts new file mode 100644 index 0000000..29927eb --- /dev/null +++ b/backend/libs/trips/src/trip-membership.guard.spec.ts @@ -0,0 +1,94 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { TripMembershipGuard } from './trip-membership.guard'; + +describe('TripMembershipGuard', () => { + function context( + params: Record, + user: { id: string }, + ): ExecutionContext { + const req = { params, user }; + return { + switchToHttp: () => ({ getRequest: () => req }), + getHandler: () => ({}), + getClass: () => ({}), + } as unknown as ExecutionContext; + } + + it('denies a user who has no trip_members row for the trip', async () => { + const members = { + findByTripAndUser: jest.fn().mockResolvedValue(undefined), + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(undefined), + }; + const guard = new TripMembershipGuard(members as never, reflector as never); + + await expect( + guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })), + ).rejects.toThrow(ForbiddenException); + }); + + it('denies an ACTIVE MEMBER when the route requires OWNER', async () => { + const members = { + findByTripAndUser: jest + .fn() + .mockResolvedValue({ role: 'MEMBER', status: 'ACTIVE' }), + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['OWNER']), + }; + const guard = new TripMembershipGuard(members as never, reflector as never); + + await expect( + guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })), + ).rejects.toThrow(ForbiddenException); + }); + + it('denies an INVITED (not yet ACTIVE) member', async () => { + const members = { + findByTripAndUser: jest + .fn() + .mockResolvedValue({ role: 'MEMBER', status: 'INVITED' }), + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(undefined), + }; + const guard = new TripMembershipGuard(members as never, reflector as never); + + await expect( + guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows an ACTIVE OWNER through an OWNER-only route', async () => { + const members = { + findByTripAndUser: jest + .fn() + .mockResolvedValue({ role: 'OWNER', status: 'ACTIVE' }), + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['OWNER']), + }; + const guard = new TripMembershipGuard(members as never, reflector as never); + + await expect( + guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })), + ).resolves.toBe(true); + }); + + it('allows an ACTIVE MEMBER through a route with no role restriction', async () => { + const members = { + findByTripAndUser: jest + .fn() + .mockResolvedValue({ role: 'MEMBER', status: 'ACTIVE' }), + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(undefined), + }; + const guard = new TripMembershipGuard(members as never, reflector as never); + + await expect( + guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })), + ).resolves.toBe(true); + }); +}); diff --git a/backend/libs/trips/src/trip-membership.guard.ts b/backend/libs/trips/src/trip-membership.guard.ts new file mode 100644 index 0000000..e490686 --- /dev/null +++ b/backend/libs/trips/src/trip-membership.guard.ts @@ -0,0 +1,59 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TripMembersRepository } from './trip-members.repository'; +import { TRIP_ROLES_KEY } from './trip-roles.decorator'; +import type { TripRole } from './trip-roles.decorator'; + +interface RequestWithTripMembership { + params: Record; + user: { id: string }; + tripMembership?: { role: string; status: string }; +} + +@Injectable() +export class TripMembershipGuard implements CanActivate { + constructor( + private readonly tripMembers: TripMembersRepository, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.getAllAndOverride< + TripRole[] | undefined + >(TRIP_ROLES_KEY, [context.getHandler(), context.getClass()]); + + const request = context + .switchToHttp() + .getRequest(); + const tripId = request.params.tripId; + const membership = await this.tripMembers.findByTripAndUser( + tripId, + request.user.id, + ); + + if (!membership || membership.status !== 'ACTIVE') { + throw new ForbiddenException('You are not an active member of this trip'); + } + + if ( + requiredRoles && + requiredRoles.length > 0 && + !requiredRoles.includes(membership.role) + ) { + throw new ForbiddenException( + 'You do not have the required role for this action', + ); + } + + request.tripMembership = { + role: membership.role, + status: membership.status, + }; + return true; + } +} diff --git a/backend/libs/trips/src/trip-roles.decorator.ts b/backend/libs/trips/src/trip-roles.decorator.ts new file mode 100644 index 0000000..8692787 --- /dev/null +++ b/backend/libs/trips/src/trip-roles.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +export type TripRole = 'OWNER' | 'MEMBER'; + +export const TRIP_ROLES_KEY = 'tripRoles'; + +export const TripRoles = (...roles: TripRole[]) => + SetMetadata(TRIP_ROLES_KEY, roles); diff --git a/backend/libs/trips/src/trip.types.ts b/backend/libs/trips/src/trip.types.ts index 254e3cb..b21014e 100644 --- a/backend/libs/trips/src/trip.types.ts +++ b/backend/libs/trips/src/trip.types.ts @@ -64,6 +64,20 @@ export interface UpdateTripSettingsDto { defaultPlanningStyle: TripPlanningStyle | null; } +export type TripMemberRole = 'OWNER' | 'MEMBER'; +export type TripMemberStatus = 'INVITED' | 'ACTIVE' | 'DECLINED'; + +export interface TripMember { + id: string; + tripId: string; + userId: string; + role: TripMemberRole; + status: TripMemberStatus; + joinedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + export const DEFAULT_TRIP_SETTINGS: Omit = { webResearchEnabled: false, periodicAgentReviewEnabled: false, diff --git a/backend/libs/trips/src/trips.module.ts b/backend/libs/trips/src/trips.module.ts index 4099ad7..cdcb8b3 100644 --- a/backend/libs/trips/src/trips.module.ts +++ b/backend/libs/trips/src/trips.module.ts @@ -4,6 +4,9 @@ import { TripsRepository } from './trips.repository'; import { TripsService } from './trips.service'; import { TripSettingsRepository } from './trip-settings.repository'; import { TripSettingsService } from './trip-settings.service'; +import { TripMembersRepository } from './trip-members.repository'; +import { TripMembersService } from './trip-members.service'; +import { TripMembershipGuard } from './trip-membership.guard'; @Module({ imports: [DatabaseModule], @@ -12,7 +15,15 @@ import { TripSettingsService } from './trip-settings.service'; TripsService, TripSettingsRepository, TripSettingsService, + TripMembersRepository, + TripMembersService, + TripMembershipGuard, + ], + exports: [ + TripsService, + TripSettingsService, + TripMembersService, + TripMembershipGuard, ], - exports: [TripsService, TripSettingsService], }) export class TripsLibModule {}