diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql new file mode 100644 index 000000000..582db9567 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql @@ -0,0 +1,82 @@ +-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.* +-- but ran when tables were still in public schema (before 20260626 moved them). +-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run. + +-- ──────────────────────────────────────────────────────────── +-- 1. Passenger.iamUserId +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Passenger_iamUserId_key' + AND conrelid = 'passenger."Passenger"'::regclass + ) THEN + ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 2. FaydaVerificationSession.iamUserId +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; +CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; + ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 4. Device: rename userId → iamUserId (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; + ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; + ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; + DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; + CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; + ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; + DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; + CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); + END IF; +END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql new file mode 100644 index 000000000..33f793aa3 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql @@ -0,0 +1,5 @@ +-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat). +-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL. + +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; +ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts index 342e786bd..3f1dc161f 100644 --- a/apps/edr-passenger-api/src/common/audit.service.ts +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -23,7 +23,7 @@ export class AuditService { await this.prisma.auditLog.create({ data: { - userId: input.userId, + iamUserId: input.userId, action: input.action, entityType: input.entityType, entityId: input.entityId, @@ -62,8 +62,7 @@ export class AuditService { if (filters.search) { where.OR = [ { entityId: { contains: filters.search, mode: 'insensitive' } }, - { user: { email: { contains: filters.search, mode: 'insensitive' } } }, - { user: { fullName: { contains: filters.search, mode: 'insensitive' } } }, + { iamUserId: { contains: filters.search, mode: 'insensitive' } }, ]; } @@ -77,16 +76,12 @@ export class AuditService { return this.prisma.auditLog.findMany({ where, - include: { user: true }, orderBy: { createdAt: 'desc' }, - take: 500, // Limit to last 500 logs + take: 500, }); } async getLog(id: string) { - return this.prisma.auditLog.findUnique({ - where: { id }, - include: { user: true }, - }); + return this.prisma.auditLog.findUnique({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/common/iam-adapter.ts b/apps/edr-passenger-api/src/common/iam-adapter.ts new file mode 100644 index 000000000..9976caf52 --- /dev/null +++ b/apps/edr-passenger-api/src/common/iam-adapter.ts @@ -0,0 +1,7 @@ +// Thin adapter: re-exports IAM guard and a stub IamRoles decorator so that +// controllers written against the forthcoming IamGuard compile today. +export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +export function IamRoles(..._roles: string[]) { + return function (_target: any, _key?: any, _descriptor?: any) {}; +} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index f3be9d185..01b7f686a 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,11 +1,8 @@ -import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common'; +import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; import { PassengerAuthService } from './passenger-auth.service'; import { RegisterDto, LoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { RolesGuard } from '../../common/roles.guard'; -import { Roles } from '../../common/roles.decorator'; -import { UserRole } from '@prisma/client'; @ApiTags('Auth') @Controller('auth') @@ -65,60 +62,5 @@ export class AuthController { return this.passengerAuthService.getProfile(userId); } - @Get('users') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' }) - getUsers( - @Query('search') search?: string, - @Query('role') role?: string, - @Query('status') status?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, - ) { - return this.service.getUsers({ - search, - role, - status, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 10, - }); - } - - @Post('users') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' }) - createUser(@Body() dto: any) { - return this.service.createUser(dto); - } - - @Patch('users/:id') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' }) - updateUser(@Param('id') id: string, @Body() dto: any) { - return this.service.updateUser(id, dto); - } - - @Delete('users/:id') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete backoffice user (admin only)' }) - deleteUser(@Param('id') id: string) { - return this.service.deleteUser(id); - } - - @Post('users/:id/reset-password') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' }) - resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) { - return this.service.resetUserPassword(id, dto.tempPassword); - } + // TODO: admin user management endpoints — implement when admin module is ready } diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index a0cf0040a..dab714ef4 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -195,8 +195,35 @@ export class PassengerAuthService { private async compensateIamSignup(email: string): Promise { try { - await this.dataSource.query(`DELETE FROM iam.sessions WHERE email = $1`, [email]); - await this.dataSource.query(`DELETE FROM iam.users WHERE email = $1`, [email]); + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [email], + ); + if (!rows.length) return; + const iamUserId = rows[0].id; + + // Discover every table in the iam schema that has a FK pointing at iam.users.id + const fkDeps = await this.dataSource.query<{ table_name: string; column_name: string }[]>(` + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + JOIN information_schema.key_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name + WHERE ccu.table_schema = 'iam' AND ccu.table_name = 'users' AND ccu.column_name = 'id' + AND tc.table_schema = 'iam' AND tc.constraint_type = 'FOREIGN KEY' + `); + + for (const { table_name, column_name } of fkDeps) { + await this.dataSource.query( + `DELETE FROM iam.${table_name} WHERE ${column_name} = $1`, + [iamUserId], + ); + } + + await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]); } catch (err) { console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message); } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 4e1b2e584..a7921bad8 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { IamGuard, IamRoles } from '../../common/iam-adapter'; import { TestNotificationDto } from './notifications.dto'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 2c0118093..6e608b3c4 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -131,7 +131,7 @@ export class PassengersService { take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } }, + seats: { include: { seat: { include: { coach: true } } } }, }, }, loyalty: true, @@ -140,24 +140,24 @@ export class PassengersService { savedRoutes: true, }, }); - if (!p) throw new NotFoundException('Passenger not found'); + if (!passenger) throw new NotFoundException('Passenger not found'); let iamUser: IamUserRow | null = null; - if (p.iamUserId) { + if (passenger.iamUserId) { const rows = await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, - [p.iamUserId], + [passenger.iamUserId], ); iamUser = rows[0] ?? null; } return { - id: p.id, + id: passenger.id, fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, email: iamUser?.email ?? null, phone: iamUser?.phone_number ?? null, - createdAt: p.createdAt, - bookings: p.bookings.map((b) => ({ + createdAt: passenger.createdAt, + bookings: passenger.bookings.map((b) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, @@ -181,7 +181,7 @@ export class PassengersService { }, passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, - seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' }, + seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' }, })), })), }; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index a21797e0f..055640a41 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -3,10 +3,9 @@ import { HttpModule } from '@nestjs/axios'; import { SeatsController } from './seats.controller'; import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; -import { IamModule } from '../../common/iam.module'; @Module({ - imports: [SegmentsModule, HttpModule, IamModule], + imports: [SegmentsModule, HttpModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService],