diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 482a0df2c..a4774af84 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -2,17 +2,46 @@ NODE_ENV=development PORT=4000 -# Database (Prisma) -DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger +# Database (Prisma) — owns the `passenger` schema in edr_database +DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger + +# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database. +# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource. +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=edr_database +DATABASE_USER=edr +DATABASE_PASSWORD=edr_secret +DATABASE_SCHEMA=iam + +# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications). +# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev. +RABBITMQ_URL=amqp://localhost:5672 + +# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these). +# Placeholders for dev; only contacted when file upload/download features are actually used. +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=edr-dev # CORS FRONTEND_URL=http://localhost:5174 BACK_OFFICE_URL=http://localhost:5184 -# JWT +# JWT (legacy passenger auth — being replaced by IAM) JWT_SECRET=edr-platform-secret-change-in-production JWT_EXPIRES_IN=7d +# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with +# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.) +JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me +JWT_ACCESS_TOKEN_EXPIRES=1h +JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me +JWT_REFRESH_TOKEN_EXPIRES=7d + # SendGrid SENDGRID_API_KEY= SENDGRID_FROM_EMAIL=noreply@edr-platform.com diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 8ef7e669a..5ab08b399 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -11,6 +11,8 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "type-check": "tsc --noEmit", + "iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs", + "iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy", "prisma:migrate:dev": "prisma migrate dev", @@ -27,26 +29,30 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.19", "@nestjs/event-emitter": "^2.0.4", - "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^11.1.24", - "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", + "@nestjs/typeorm": "^11.0.1", "@prisma/client": "^6.19.3", + "@sendgrid/mail": "^8.1.0", + "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "amqp-connection-manager": "^5.0.0", + "amqplib": "^2.0.1", "axios": "^1.7.7", - "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "dotenv": "^17.4.2", "express": "^4.18.2", "jose": "^5.10.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", + "pg": "^8.21.0", "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", + "typeorm": "^0.3.30", "uuid": "^10.0.0" }, "devDependencies": { @@ -55,11 +61,9 @@ "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", - "@types/bcrypt": "^5.0.2", - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", - "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.0", diff --git a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql index 0f8484179..4ae48ea16 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql @@ -20,7 +20,7 @@ CREATE TYPE "IdDocumentType" AS ENUM ('NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENS CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); -- CreateEnum -CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED'); +CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED'); -- CreateEnum CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); @@ -76,7 +76,9 @@ CREATE TABLE "SeatClass" ( "coachTypeId" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, - "baseFareMinor" INTEGER NOT NULL, + "baseFareMinor" INTEGER NOT NULL DEFAULT 0, + "premiumMinor" INTEGER NOT NULL DEFAULT 0, + "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -94,6 +96,8 @@ CREATE TABLE "User" ( "role" "UserRole" NOT NULL DEFAULT 'PASSENGER', "nationality" TEXT, "nationalityCode" TEXT, + "gender" TEXT, + "dateOfBirth" TIMESTAMP(3), "passportNumber" TEXT, "nationalId" TEXT, "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, @@ -155,10 +159,11 @@ CREATE TABLE "Station" ( "name" TEXT NOT NULL, "city" TEXT NOT NULL, "countryCode" TEXT, + "sequence" INTEGER NOT NULL DEFAULT 0, "isOperational" BOOLEAN NOT NULL DEFAULT true, "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', - "lat" DECIMAL(9,6) NOT NULL, - "lng" DECIMAL(9,6) NOT NULL, + "lat" DECIMAL(9,6), + "lng" DECIMAL(9,6), CONSTRAINT "Station_pkey" PRIMARY KEY ("id") ); @@ -234,6 +239,7 @@ CREATE TABLE "Coach" ( "number" TEXT NOT NULL, "arrangement" TEXT NOT NULL DEFAULT '2+2', "capacity" INTEGER NOT NULL DEFAULT 0, + "sequence" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'ACTIVE', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql new file mode 100644 index 000000000..9ccd3d52e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql @@ -0,0 +1,14 @@ +-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced) +ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT; + +-- Unique constraint: one IAM user maps to exactly one Passenger +ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); + +-- Index for fast lookup by iamUserId on every protected request +CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId"); + +-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users) +ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT; + +-- Index for Fayda callback to resolve IAM user +CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql new file mode 100644 index 000000000..a9fa8190b --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql @@ -0,0 +1,30 @@ +-- DropForeignKey +ALTER TABLE "Passenger" DROP CONSTRAINT "Passenger_userId_fkey"; + +-- AlterTable +ALTER TABLE "Passenger" ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "TicketSeat" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "seatId" TEXT NOT NULL, + "seatIndex" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId"); + +-- CreateIndex +CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId"); + +-- AddForeignKey +ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql new file mode 100644 index 000000000..ec1cfd078 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql @@ -0,0 +1,13 @@ +-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema) +ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; +ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; +ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; + +-- Rename columns (preserves all existing data) +ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; +ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; +ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; + +-- Rename indexes on FraudAlert to match new column name +DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; +CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql new file mode 100644 index 000000000..52914b220 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql @@ -0,0 +1,10 @@ +-- AuditLog: drop FK, rename column, update index +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"); + +-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data) +ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey"; +ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId"; +DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql new file mode 100644 index 000000000..125074c12 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3); + +-- RenameIndex +ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql index 3b5beccb8..577312395 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql @@ -140,11 +140,7 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -- AlterTable ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; --- AlterTable --- gender is created here on a clean migration history (no prior migration adds it); --- on an already-drifted DB where it exists as varchar, normalize it to TEXT. -ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT; -ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT; +-- gender column already TEXT from init migration -- CreateIndex CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql new file mode 100644 index 000000000..7e7d9bd58 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql @@ -0,0 +1,2 @@ +-- Empty placeholder migration +SELECT 1; diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql index 6c8da0d2c..1673a795b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql @@ -1,36 +1,9 @@ --- Add sequence column to Station table if it doesn't exist -ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence"); --- Add index on sequence for Station -CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence"); - --- Add sequence column to Coach table if it doesn't exist -ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; - --- Add index on sequence for Coach -CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence"); - --- Add missing columns to SeatClass if they don't exist -ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0; -ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0; - --- Add missing columns to User if they don't exist -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255); - --- Ensure Ticket has all required columns -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3); -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); - --- Add missing columns to Booking if they don't exist -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY'; -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255); -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER; +CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence"); -- Ensure all indexes exist -CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode"); -CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId"); -CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId"); -CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status"); +CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); +CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); +CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId"); +CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql index d047e5a0c..9f70a96b1 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql @@ -1,164 +1,164 @@ -- Add CASCADE delete to all foreign key constraints that are missing it -- TrainSchedule relations -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE; -- Coach relation -ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; -ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE; +ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE; -- CoachAssignment relations -ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; -ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; -ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE; +ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE; -- Booking relations -ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; -ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; -ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- BookingSeat relations -ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; -ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; -ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- PaymentIntent -ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; -ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- PaymentRefund -ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; -ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE; +ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE; -- Ticket -ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; -ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- TicketSeat -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; -ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- WalletLedgerEntry -ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; -ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE; +ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; +ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE; -- Notification -ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; -ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -- MenuItem -ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; -ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; -ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE; +ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE; -- FoodOrder -ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; -ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; +ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- FoodOrderItem -ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; -ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE; +ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; +ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE; -- FaqArticle -ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; -ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE; +ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; +ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE; -- SupportMessage -ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; -ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE; +ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; +ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE; -- TripStopTime -ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; -ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; +ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- TripLiveStatus -ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; -ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; +ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- JourneySegment -ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; -ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; +ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; -ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- AgentBooking -ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; -ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; -ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- AgentShift -ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; -ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; +ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -- AgentCommission -ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; -ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; +ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -- BookingModification -ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; -ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; +ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- BookingCancellation -ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; -ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; +ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- GateValidationLog -ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; -ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE; +ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; +ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE; -- BaggageBooking -ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; -ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; +ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- RouteFareRule -ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; +ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; -- SegmentFareRule -ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; -- StationCrowdSignal -ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; -ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; +ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE; -- SeatBlock -ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; -ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; +ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- SavedRoute -ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; -ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; +ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -- LoyaltyLedgerEntry -ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; -ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; +ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; +ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; -- LoyaltyReward -ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; -ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; +ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; -- FareRule -ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; +ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; 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/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql new file mode 100644 index 000000000..dcd55c066 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql @@ -0,0 +1,39 @@ +-- ──────────────────────────────────────────────────────────── +-- 1. Add iamUserId to Agent +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Agent_iamUserId_key' + AND conrelid = 'passenger."Agent"'::regclass + ) THEN + ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 2. Populate iamUserId for existing agent records +-- Match via User.email → iam.users.email +-- ──────────────────────────────────────────────────────────── +UPDATE passenger."Agent" a +SET "iamUserId" = iu.id +FROM passenger."User" u +JOIN iam.users iu ON iu.email = u.email +WHERE a."userId" = u.id + AND a."iamUserId" IS NULL; + +-- ──────────────────────────────────────────────────────────── +-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; +DROP INDEX IF EXISTS passenger."Agent_userId_key"; +ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; + +-- ──────────────────────────────────────────────────────────── +-- 4. Drop Passenger.userId FK (column stays as plain nullable string) +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql index e93fb8320..bc6e6c2a2 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql @@ -1,18 +1,18 @@ -- CreateEnum -CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); +CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); -- AlterTable: add return leg tracking columns to Booking -ALTER TABLE "passenger"."Booking" - ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', +ALTER TABLE "Booking" + ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), ADD COLUMN "returnBoardedAt" TIMESTAMP(3); -- Set NEITHER_USED for existing confirmed round-trip bookings -UPDATE "passenger"."Booking" +UPDATE "Booking" SET "returnLegStatus" = 'NEITHER_USED' WHERE "bookingType" = 'ROUND_TRIP' - AND "status" IN ('CONFIRMED', 'COMPLETED'); + AND "status" IN ('CONFIRMED', 'BOARDED'); -- AlterTable: add leg column to GateValidationLog -ALTER TABLE "passenger"."GateValidationLog" +ALTER TABLE "GateValidationLog" ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql index f252642f6..b0a5bc0b4 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql @@ -1,7 +1,7 @@ -- Create passenger schema if it doesn't exist CREATE SCHEMA IF NOT EXISTS passenger; --- Move all enums from public to passenger schema +-- Move enums from public to passenger schema (only if they exist in public) DO $$ DECLARE e text; @@ -13,9 +13,10 @@ BEGIN LOOP EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); END LOOP; +EXCEPTION WHEN others THEN NULL; END $$; --- Move all tables from public to passenger schema +-- Move tables from public to passenger schema (only if they exist in public) DO $$ DECLARE t text; @@ -26,6 +27,7 @@ BEGIN LOOP EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); END LOOP; +EXCEPTION WHEN others THEN NULL; END $$; -- Add missing columns to Booking diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql new file mode 100644 index 000000000..3f824b335 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql @@ -0,0 +1,14 @@ +-- Add bookingId to Journey for per-booking segment release +ALTER TABLE "passenger"."Journey" + ADD COLUMN IF NOT EXISTS "bookingId" TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId"); +CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId"); + +-- Ensure JourneySegment cascades on Journey delete +ALTER TABLE "passenger"."JourneySegment" + DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; + +ALTER TABLE "passenger"."JourneySegment" + ADD CONSTRAINT "JourneySegment_journeyId_fkey" + FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 423a03ec9..7cbbc0af9 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -108,7 +108,7 @@ enum BookingStatus { PENDING_PAYMENT CONFIRMED CANCELLED - COMPLETED + BOARDED NO_SHOW REFUNDED @@ -260,15 +260,8 @@ model User { faydaVerifiedAt DateTime? faydaSub String? @unique - passenger Passenger? - agent Agent? - sessions Session[] - devices Device[] - preferences UserPreferences? - auditLogs AuditLog[] - fraudAlerts FraudAlert[] + sessions Session[] - faydaVerificationSessions FaydaVerificationSession[] @@schema("passenger") } @@ -286,20 +279,21 @@ model Session { } model Passenger { - id String @id @default(uuid()) - userId String @unique + id String @id @default(uuid()) + userId String? @unique + iamUserId String? @unique defaultTravelerProfileId String? - preferredLanguage String? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - bookings Booking[] - loyalty LoyaltyAccount? - wallet WalletAccount? - notifications Notification[] - travelerProfiles TravelerProfile[] - savedRoutes SavedRoute[] - + preferredLanguage String? + blockedUntil DateTime? + createdAt DateTime @default(now()) + bookings Booking[] + loyalty LoyaltyAccount? + wallet WalletAccount? + notifications Notification[] + travelerProfiles TravelerProfile[] + savedRoutes SavedRoute[] @@index([userId]) + @@index([iamUserId]) @@schema("passenger") } @@ -325,8 +319,8 @@ model Station { sequence Int @default(0) isOperational Boolean @default(true) timezone String @default("Africa/Addis_Ababa") - lat Decimal @db.Decimal(9, 6) - lng Decimal @db.Decimal(9, 6) + lat Decimal? @db.Decimal(9, 6) + lng Decimal? @db.Decimal(9, 6) originSchedules TrainSchedule[] @relation("OriginTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] @@ -548,6 +542,7 @@ model Booking { modifications BookingModification[] cancellation BookingCancellation? baggage BaggageBooking[] + journey Journey? @@index([passengerId, status]) @@index([bookingType]) @@ -894,7 +889,7 @@ model SupportMessage { model UserPreferences { id String @id @default(uuid()) - userId String @unique + iamUserId String @unique pushEnabled Boolean @default(true) emailEnabled Boolean @default(true) smsEnabled Boolean @default(false) @@ -907,19 +902,19 @@ model UserPreferences { locale String @default("en") darkMode Boolean @default(false) language String @default("en") - user User @relation(fields: [userId], references: [id]) + @@schema("passenger") } model Device { id String @id @default(uuid()) - userId String + iamUserId String platform DevicePlatform name String pushToken String? trusted Boolean @default(false) lastSeenAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + @@schema("passenger") } @@ -939,10 +934,12 @@ model SavedRoute { model Journey { id String @id @default(uuid()) passengerId String + bookingId String? @unique status String totalMinor Int currency String @default("ETB") createdAt DateTime @default(now()) + booking Booking? @relation(fields: [bookingId], references: [id]) journeySegments JourneySegment[] @@schema("passenger") } @@ -1060,16 +1057,16 @@ model SegmentFareRule { model Agent { id String @id @default(uuid()) - userId String @unique + iamUserId String? @unique agentCode String @unique stationId String? commissionRate Int @default(5) active Boolean @default(true) createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) bookings AgentBooking[] shifts AgentShift[] commissions AgentCommission[] + @@index([iamUserId]) @@schema("passenger") } @@ -1188,19 +1185,17 @@ model BaggageBooking { } model AuditLog { - id String @id @default(uuid()) - userId String? - action String - entityType String - entityId String? - oldData Json? - newData Json? - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id]) - - @@index([userId, createdAt]) + id String @id @default(uuid()) + iamUserId String? + action String + entityType String + entityId String? + oldData Json? + newData Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + @@index([iamUserId, createdAt]) @@index([entityType, entityId]) @@schema("passenger") } @@ -1256,16 +1251,14 @@ model FraudRule { model FraudAlert { id String @id @default(uuid()) - userId String + iamUserId String eventType String triggeredRules String[] context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, createdAt]) + @@index([iamUserId, createdAt]) @@index([acknowledged]) @@schema("passenger") } @@ -1324,7 +1317,7 @@ model FaydaVerificationSession { id String @id @default(uuid()) state String @unique codeVerifier String - purpose String @default("PURCHASE") + purpose String @default("VERIFY") // VERIFY | LOGIN platform String @default("WEB") // WEB | MOBILE — recorded for audit saveToAccount Boolean @default(false) status String @default("PENDING") @@ -1335,12 +1328,10 @@ model FaydaVerificationSession { expiresAt DateTime completedAt DateTime? - userId String? + iamUserId String? bookingId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) + @@index([iamUserId]) @@index([bookingId]) @@index([state]) @@index([expiresAt]) diff --git a/apps/edr-passenger-api/scripts/run-iam-migrations.cjs b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs new file mode 100644 index 000000000..1a448070b --- /dev/null +++ b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs @@ -0,0 +1,46 @@ +/** + * Dev helper: run the @tria-plc/iamapi-common TypeORM migrations against the shared `iam` schema. + * + * The package ships its migration CLI assuming you run it from inside the package repo (it needs + * the package's devDeps). As a consumer we instead drive the shipped (compiled) migrations with the + * passenger app's own installed TypeORM. + * + * Reads the same DATABASE_* env vars as the app's IAM DataSource (see config/iam-database.config.ts). + * Run via: pnpm --filter @edr/passenger-api iam:migrate + * (the npm script loads .env with `node --env-file`). + * + * NOTE: in production the central IAM team owns/runs these migrations — this helper is for local dev. + */ +const path = require('path'); +const { DataSource } = require('typeorm'); + +const iamDist = path + .dirname(require.resolve('@tria-plc/iamapi-common')) + .replace(/\\/g, '/'); + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT || 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + schema: process.env.DATABASE_SCHEMA || 'iam', + entities: [], // migrations are raw SQL — no entities needed to run them + migrations: [`${iamDist}/db/migrations/*.js`], + migrationsTableName: 'typeorm_migrations', +}); + +(async () => { + await ds.initialize(); + // The IAM migrations rely on uuid_generate_v4() but never CREATE the extension themselves. + await ds.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + const applied = await ds.runMigrations({ transaction: 'each' }); + console.log(`[iam-migrations] applied ${applied.length} migration(s)`); + applied.slice(-5).forEach((m) => console.log(' +', m.name)); + await ds.destroy(); + console.log('[iam-migrations] DONE'); +})().catch((e) => { + console.error('[iam-migrations] FAIL:', e.message); + process.exit(1); +}); diff --git a/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs b/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs new file mode 100644 index 000000000..37451f871 --- /dev/null +++ b/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs @@ -0,0 +1,74 @@ +/** + * Dev helper: create a dev IAM user + an ACTIVE session, and print a ready-to-use Bearer token. + * + * Why this exists: in prod the central IAM service issues tokens (via password login at + * /v1/auth/login). For local dev of the passenger API (a token *consumer*), this seeds a session + * directly and mints a matching token with the package's own `generateToken`, so you can call + * protected routes immediately (paste the token into Swagger's Authorize box or `curl -H`). + * + * Run: pnpm --filter @edr/passenger-api iam:seed-dev-user + * Reads DATABASE_* + JWT_ACCESS_TOKEN_SECRET/EXPIRES from .env (loaded via `node --env-file`). + */ +const crypto = require('crypto'); +const { DataSource } = require('typeorm'); +const { generateToken } = require('@tria-plc/api-common/utils/token'); + +const DEV_EMAIL = process.env.DEV_IAM_EMAIL || 'dev@edr.local'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT || 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, +}); + +(async () => { + await ds.initialize(); + + // Upsert the dev user (users.email is UNIQUE). + const name = { en: 'Dev User', am: 'የሙከራ ተጠቃሚ' }; + const [user] = await ds.query( + `INSERT INTO iam.users (name, username, email, user_type, status, is_active) + VALUES ($1::jsonb, $2, $3, 'individual', 'accepted', true) + ON CONFLICT (email) DO UPDATE SET updated_at = now() + RETURNING id`, + [JSON.stringify(name), 'dev-user', DEV_EMAIL], + ); + const userId = user.id; + + // Fresh ACTIVE session; userInfo is the denormalized TCurrentUser the guard puts on req.user. + const sessionId = crypto.randomUUID(); + const userInfo = { + id: userId, + email: DEV_EMAIL, + name, + username: 'dev-user', + userType: 'individual', + status: 'accepted', + roles: [], + permissions: [], + }; + await ds.query( + `INSERT INTO iam.sessions (id, email, device, "userInfo", user_id, status, expiry_time) + VALUES ($1, $2, 'dev-seeder', $3::jsonb, $4, 'ACTIVE', now() + interval '7 days')`, + [sessionId, DEV_EMAIL, JSON.stringify(userInfo), userId], + ); + + // The package JwtGuard looks up the session by the token's `id` claim. + const token = generateToken({ id: sessionId }); + + console.log('\n=== IAM dev user seeded ==='); + console.log('user id :', userId); + console.log('email :', DEV_EMAIL); + console.log('session id:', sessionId); + console.log('\nBearer token (valid 7 days):\n' + token); + console.log('\nTry it: curl -H "Authorization: Bearer " http://localhost:3002/v1/auth/me'); + console.log('(Run again any time for a fresh token/session.)\n'); + + await ds.destroy(); +})().catch((e) => { + console.error('[seed-iam-dev-user] FAIL:', e.message); + process.exit(1); +}); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 757432c3b..1c659d117 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,14 +1,29 @@ -import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { + MiddlewareConsumer, + Module, + NestModule, + OnApplicationBootstrap, +} from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; +import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; +import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; +import { + EDR_PASSENGER_APPLICATION, + EDR_PASSENGER_PERMISSIONS, +} from './seed/edr-passenger.seed'; +import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder'; +import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder'; import { PrismaModule } from './common/prisma.module'; import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; -import { IamModule } from './common/iam.module'; import { LocaleMiddleware } from './common/i18n/locale.middleware'; import appConfig from './config/app.config'; import dbConfig from './config/database.config'; +import iamDatabaseConfig from './config/iam-database.config'; import telebirrConfig from './config/telebirr.config'; import cbeConfig from './config/cbe.config'; import ebirrConfig from './config/ebirr.config'; @@ -50,6 +65,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; load: [ appConfig, dbConfig, + iamDatabaseConfig, telebirrConfig, cbeConfig, ebirrConfig, @@ -61,11 +77,20 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): TypeOrmModuleOptions => + config.get('iamDatabase')!, + }), + TriaIamModule.forRoot({ + applications: [EDR_PASSENGER_APPLICATION], + permissions: EDR_PASSENGER_PERMISSIONS, + }), + SharedAuthModule, PrismaModule, AuditModule, I18nModule, - IamModule, - AuthModule, + AuthModule, StationsModule, FleetModule, SchedulesModule, @@ -92,9 +117,25 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; AuditModuleFeature, CurrenciesModule, ], + providers: [ + EdrPassengerOrgSeeder, + PassengerStaffUsersSeeder, + ], }) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(LocaleMiddleware).forRoutes('*'); +export class AppModule implements OnApplicationBootstrap { + constructor( + private readonly seeder: DataSeeder, + private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, + private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, + ) {} + + async onApplicationBootstrap() { + try { + await this.seeder.run(); + } catch (err) { + console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + } + await this.edrPassengerOrgSeeder.run(); + await this.passengerStaffUsersSeeder.run(); } } 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.spec.ts b/apps/edr-passenger-api/src/common/iam-adapter.spec.ts deleted file mode 100644 index d0c404366..000000000 --- a/apps/edr-passenger-api/src/common/iam-adapter.spec.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { IamGuard } from './iam-adapter'; -import { of, throwError } from 'rxjs'; - -describe('IamGuard', () => { - let guard: IamGuard; - let httpService: HttpService; - let configService: ConfigService; - let reflector: Reflector; - - const mockConfigService = { - get: jest.fn((key: string) => { - const config: Record = { - IAM_API_URL: 'https://iam.test.com/api', - IAM_ENABLED: 'true', - IAM_API_KEY: 'test-api-key', - }; - return config[key]; - }), - }; - - const mockHttpService = { - post: jest.fn(), - }; - - const mockReflector = { - get: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - IamGuard, - { provide: ConfigService, useValue: mockConfigService }, - { provide: HttpService, useValue: mockHttpService }, - { provide: Reflector, useValue: mockReflector }, - ], - }).compile(); - - guard = module.get(IamGuard); - httpService = module.get(HttpService); - configService = module.get(ConfigService); - reflector = module.get(Reflector); - - jest.clearAllMocks(); - }); - - const createMockContext = (token?: string, roles?: string[]): ExecutionContext => { - const request = { - headers: token ? { authorization: `Bearer ${token}` } : {}, - user: undefined, - }; - - return { - switchToHttp: () => ({ - getRequest: () => request, - }), - getHandler: () => ({}), - } as ExecutionContext; - }; - - describe('canActivate', () => { - it('should allow access when IAM is disabled', async () => { - mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED - - const context = createMockContext(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - }); - - it('should throw UnauthorizedException when no token provided', async () => { - const context = createMockContext(); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should validate token and allow access', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('valid-token'); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(mockHttpService.post).toHaveBeenCalledWith( - 'https://iam.test.com/api/v1/auth/validate', - { token: 'valid-token' }, - expect.objectContaining({ - headers: expect.objectContaining({ - 'X-API-Key': 'test-api-key', - }), - }), - ); - }); - - it('should throw UnauthorizedException for invalid token', async () => { - const mockValidationResponse = { - data: { - valid: false, - error: 'Token expired', - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - - const context = createMockContext('invalid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should check required roles', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'agent@test.com', - roles: ['AGENT'], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']); - - const context = createMockContext('valid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); - }); - - it('should allow access when user has required role', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']); - - const context = createMockContext('valid-token'); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - }); - - it('should handle HTTP errors gracefully', async () => { - mockHttpService.post.mockReturnValue( - throwError(() => new Error('Network error')), - ); - - const context = createMockContext('valid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should attach user to request', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - organizationId: 'org-456', - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('valid-token'); - await guard.canActivate(context); - - const request = context.switchToHttp().getRequest(); - expect(request.user).toEqual({ - userId: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - organizationId: 'org-456', - }); - }); - }); - - describe('token extraction', () => { - it('should extract token from Bearer header', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'test@test.com', - roles: [], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('my-token-123'); - await guard.canActivate(context); - - expect(mockHttpService.post).toHaveBeenCalledWith( - expect.any(String), - { token: 'my-token-123' }, - expect.any(Object), - ); - }); - - it('should reject malformed authorization header', async () => { - const request = { - headers: { authorization: 'InvalidFormat token' }, - }; - - const context = { - switchToHttp: () => ({ - getRequest: () => request, - }), - getHandler: () => ({}), - } as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - }); -}); diff --git a/apps/edr-passenger-api/src/common/iam-adapter.ts b/apps/edr-passenger-api/src/common/iam-adapter.ts index fb32d9ec6..96168dba8 100644 --- a/apps/edr-passenger-api/src/common/iam-adapter.ts +++ b/apps/edr-passenger-api/src/common/iam-adapter.ts @@ -1,144 +1 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; - -/** - * IAM Adapter for @tria-plc corporate identity integration - * - * This adapter wraps the corporate IAM guards and provides a bridge - * between the corporate identity system and the EDR passenger API. - * - * For back-office roles (agent, supervisor, admin, staff), this guard - * validates tokens against the corporate IAM service. - * - * For passenger-facing routes, the existing JWT guard is used. - */ - -export interface IamTokenPayload { - sub: string; - email: string; - roles: string[]; - permissions: string[]; - organizationId?: string; - exp: number; - iat: number; -} - -export interface IamValidationResponse { - valid: boolean; - payload?: IamTokenPayload; - error?: string; -} - -@Injectable() -export class IamGuard implements CanActivate { - private readonly iamApiUrl: string; - private readonly iamEnabled: boolean; - - constructor( - private readonly reflector: Reflector, - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - this.iamApiUrl = this.config.get('IAM_API_URL') || 'https://iam.tria-plc.com/api'; - this.iamEnabled = this.config.get('IAM_ENABLED') === 'true'; - } - - async canActivate(context: ExecutionContext): Promise { - if (!this.iamEnabled) { - // IAM disabled - allow access (for development) - return true; - } - - const request = context.switchToHttp().getRequest(); - const token = this.extractToken(request); - - if (!token) { - throw new UnauthorizedException('No authentication token provided'); - } - - const validation = await this.validateToken(token); - - if (!validation.valid || !validation.payload) { - throw new UnauthorizedException(validation.error || 'Invalid token'); - } - - // Check required roles - const requiredRoles = this.reflector.get('roles', context.getHandler()); - if (requiredRoles && requiredRoles.length > 0) { - const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role)); - if (!hasRole) { - throw new ForbiddenException('Insufficient permissions'); - } - } - - // Attach user to request - request.user = { - userId: validation.payload.sub, - email: validation.payload.email, - roles: validation.payload.roles, - permissions: validation.payload.permissions, - organizationId: validation.payload.organizationId, - }; - - return true; - } - - private extractToken(request: any): string | null { - const authHeader = request.headers.authorization; - if (!authHeader) return null; - - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') return null; - - return parts[1]; - } - - private async validateToken(token: string): Promise { - try { - const response = await firstValueFrom( - this.http.post( - `${this.iamApiUrl}/v1/auth/validate`, - { token }, - { - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.config.get('IAM_API_KEY') || '', - }, - timeout: 5000, - }, - ), - ); - - return response.data; - } catch (err) { - return { - valid: false, - error: err instanceof Error ? err.message : 'Token validation failed', - }; - } - } -} - -/** - * Decorator to mark routes as requiring IAM authentication - */ -export const UseIamAuth = () => { - // This is a marker decorator that can be used with @UseGuards(IamGuard) - return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => { - // Marker only - actual guard is applied via @UseGuards - }; -}; - -/** - * Decorator to specify required roles for IAM-protected routes - */ -export const IamRoles = (...roles: string[]) => { - return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => { - if (descriptor) { - Reflect.defineMetadata('roles', roles, descriptor.value); - } - }; -}; +export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; diff --git a/apps/edr-passenger-api/src/common/iam-typeorm.config.ts b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts new file mode 100644 index 000000000..42ce89c9e --- /dev/null +++ b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts @@ -0,0 +1,56 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import * as path from 'path'; + +/** + * TypeORM DataSource options for the shared `iam` schema. + * + * Context (see docs/iam-package-understanding-guide.md): + * - The `iam` schema is owned by `@tria-plc/iamapi-common` (TypeORM). Prisma owns the + * `passenger` schema. Both ORMs point at the same database (`edr_database`). + * - `@tria-plc/api-common`'s `JwtGuard` injects the *default* TypeORM `DataSource` and runs a + * raw `SELECT ... FROM iam.sessions`, so the app must expose a DataSource that can reach it. + * + * Connection env vars intentionally mirror the package's own migration DataSource + * (`@tria-plc/api-common/dist/modules/typeorm/typeorm.config.internal.js`) so the app and the + * package CLI read the same configuration: + * DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_SCHEMA + * + * This NEVER manages the schema: `synchronize: false` and `migrationsRun: false`. The `iam` + * schema is created by the IAM package migrations (dev: self-hosted; prod: central IAM team). + */ +function resolvePackageDist(pkg: string): string { + // Node honors each package's `exports` map at runtime even though TS `moduleResolution: "Node"` + // does not — so `require.resolve` on the barrel resolves to the package's dist `index.js`. + const resolved = require.resolve(pkg); + // Normalize to forward slashes so the glob works on Windows too. + return path.dirname(resolved).replace(/\\/g, '/'); +} + +export function buildIamTypeOrmOptions(): TypeOrmModuleOptions { + const iamDist = resolvePackageDist('@tria-plc/iamapi-common'); + // Some IAM entities (e.g. PositionType) relate to the notification entities that physically + // live in @tria-plc/api-common (the IAM barrel only re-exports them), so BOTH dist trees must + // be registered or TypeORM throws "Entity metadata ... was not found". + const apiDist = resolvePackageDist('@tria-plc/api-common'); + return { + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT ?? 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + schema: process.env.DATABASE_SCHEMA ?? 'iam', + // IAM entities live in the packages; registered so the same default DataSource also serves + // IamModule in the dev self-host phase (Phase 3). Harmless before the tables exist. + entities: [ + `${iamDist}/entities/**/*.entity.{ts,js}`, + `${apiDist}/entities/**/*.entity.{ts,js}`, + ], + synchronize: false, // schema is owned by IAM migrations — never auto-sync + migrationsRun: false, // migrations are run by the IAM package CLI (dev) / IAM team (prod) + autoLoadEntities: false, + migrationsTableName: 'typeorm_migrations', + retryAttempts: 0, // fail fast in dev if the iam schema / DB is unreachable + logging: ['error'], + }; +} diff --git a/apps/edr-passenger-api/src/common/iam.module.ts b/apps/edr-passenger-api/src/common/iam.module.ts deleted file mode 100644 index 7a8ec9599..000000000 --- a/apps/edr-passenger-api/src/common/iam.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { IamGuard } from './iam-adapter'; - -@Global() -@Module({ - imports: [HttpModule.register({ timeout: 5000 })], - providers: [IamGuard], - exports: [IamGuard], -}) -export class IamModule {} diff --git a/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts b/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts index 9c962ba60..d5735231d 100644 --- a/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts +++ b/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts @@ -1,7 +1,8 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { PrismaService } from '../prisma.service'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @Injectable() @@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor { private readonly inactivityMinutes: number; constructor( - private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, ) { this.inactivityMinutes = parseInt(this.config.get('SESSION_INACTIVITY_MINUTES') || '30', 10); @@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); - const user = request.user; + const sessionId: string | undefined = request.user?.sessionId; - if (user?.userId) { - const session = await this.prisma.session.findFirst({ - where: { userId: user.userId }, - orderBy: { lastActivityAt: 'desc' }, - }); + if (sessionId) { + const rows = await this.dataSource.query>( + `SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`, + [sessionId], + ); - if (session) { - const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000; - - if (inactiveMinutes > this.inactivityMinutes) { - await this.prisma.session.delete({ where: { id: session.id } }); - throw new UnauthorizedException('Session expired due to inactivity'); + if (rows.length) { + const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000; + if (minutesLeft < this.inactivityMinutes * 0.2) { + response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString()); } - const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes); - response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString()); - - await this.prisma.session.update({ - where: { id: session.id }, - data: { lastActivityAt: new Date() }, - }); + // Extend session on every authenticated request + await this.dataSource.query( + `UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`, + [this.inactivityMinutes, sessionId], + ); } } diff --git a/apps/edr-passenger-api/src/common/jwt.guard.ts b/apps/edr-passenger-api/src/common/jwt.guard.ts index f65f8455d..dfdeed190 100644 --- a/apps/edr-passenger-api/src/common/jwt.guard.ts +++ b/apps/edr-passenger-api/src/common/jwt.guard.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class JwtGuard extends AuthGuard('jwt') {} +// Compatibility alias while passenger auth moves to @tria-plc IAM. +// Existing controllers can keep importing `../../common/jwt.guard`, but the +// guard now validates IAM-issued session tokens from `iam.sessions`. +export { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; diff --git a/apps/edr-passenger-api/src/common/jwt.strategy.ts b/apps/edr-passenger-api/src/common/jwt.strategy.ts deleted file mode 100644 index c1f532ba1..000000000 --- a/apps/edr-passenger-api/src/common/jwt.strategy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { - const secret = config.get('JWT_SECRET'); - if (!secret) throw new Error('JWT_SECRET environment variable is not set'); - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: secret, - }); - } - async validate(payload: any) { - return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId }; - } -} diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts new file mode 100644 index 000000000..cadd56889 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -0,0 +1,14 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerPermissionGuard } from './passenger-permission.guard'; +import { PASSENGER_PERMS } from '../seed/passenger-permissions.registry'; + +export const PassengerStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission]), + ), + ); + +export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.guard.ts b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts new file mode 100644 index 000000000..a93b2060b --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import { hasPassengerPermission } from './passenger-permission.util'; + +export function PassengerPermissionGuard(permissions: string[]): Type { + @Injectable() + class PassengerPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: any }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) throw new UnauthorizedException('Authentication required'); + + if (permissions.some((p) => hasPassengerPermission(user, p))) return true; + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return PassengerPermissionsGuard; +} diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts new file mode 100644 index 000000000..62df74603 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -0,0 +1,74 @@ +import { ForbiddenException } from '@nestjs/common'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] } + | { positions?: { permissions?: PermissionLike[] }[] }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE) ?? false; +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false; +} + +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) return [...keys]; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user) || isOrganizationAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasPassengerPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} diff --git a/apps/edr-passenger-api/src/common/roles.decorator.ts b/apps/edr-passenger-api/src/common/roles.decorator.ts index ec0c377c6..e038e1682 100644 --- a/apps/edr-passenger-api/src/common/roles.decorator.ts +++ b/apps/edr-passenger-api/src/common/roles.decorator.ts @@ -1,5 +1,4 @@ import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '@prisma/client'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/edr-passenger-api/src/common/roles.guard.ts b/apps/edr-passenger-api/src/common/roles.guard.ts index 7b4b3eafc..b654bfa28 100644 --- a/apps/edr-passenger-api/src/common/roles.guard.ts +++ b/apps/edr-passenger-api/src/common/roles.guard.ts @@ -1,6 +1,5 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { UserRole } from '@prisma/client'; import { ROLES_KEY } from './roles.decorator'; @Injectable() @@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!requiredRoles) return true; const { user } = context.switchToHttp().getRequest(); - return requiredRoles.some((role) => user?.role === role); + // Support IAM roles array [{key, id}][] and legacy role string + return requiredRoles.some( + (role) => user?.roles?.some((r: { key: string }) => r.key === role) || user?.role === role, + ); } } diff --git a/apps/edr-passenger-api/src/config/iam-database.config.ts b/apps/edr-passenger-api/src/config/iam-database.config.ts new file mode 100644 index 000000000..4223a01c3 --- /dev/null +++ b/apps/edr-passenger-api/src/config/iam-database.config.ts @@ -0,0 +1,18 @@ +import { registerAs } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { buildIamTypeOrmOptions } from '../common/iam-typeorm.config'; + +/** + * Dedicated config namespace for the IAM **TypeORM** connection — the shared `iam` schema ONLY. + * + * This is intentionally separate from Prisma: Prisma remains the app's primary ORM and owns the + * `passenger` schema via `DATABASE_URL` (see prisma.service.ts). This second connection exists + * solely because `@tria-plc/api-common` / `@tria-plc/iamapi-common` are TypeORM-based and the + * `JwtGuard` reads `iam.sessions` through a TypeORM `DataSource`. + * + * Consumed by `TypeOrmModule.forRootAsync` in app.module.ts. + */ +export default registerAs( + 'iamDatabase', + (): TypeOrmModuleOptions => buildIamTypeOrmOptions(), +); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 928789c04..dae085bc4 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,6 +1,10 @@ +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), +// which happens before ConfigModule.forRoot() would populate it. Must be the very first import. +import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; -import { ValidationPipe } from "@nestjs/common"; +import { ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; @@ -12,6 +16,11 @@ async function bootstrap() { // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. const app = await NestFactory.create(AppModule, { rawBody: true }); + // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under + // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + app.enableVersioning({ type: VersioningType.URI }); + app.enableCors({ origin: [ process.env.PORTAL_URL ?? "http://localhost:5174", diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index aa23fe6d0..378a2a361 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -2,39 +2,37 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @Controller('agents') -@UseGuards(IamGuard) +// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM +// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. +@UseGuards(IamJwtGuard) @ApiBearerAuth('IAM-auth') export class AgentsController { constructor(private service: AgentsService) {} @Post('bookings') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { return this.service.createAgentBooking(dto); } @Post('shifts/open') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Open agent shift' }) openShift(@Body() dto: OpenShiftDto) { return this.service.openShift(dto); } @Post('shifts/close') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Close agent shift' }) closeShift(@Body() dto: CloseShiftDto) { return this.service.closeShift(dto); } @Get(':agentId/commissions') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Get agent commissions' }) getCommissions( @Param('agentId') agentId: string, @@ -49,7 +47,6 @@ export class AgentsController { } @Get(':agentId/shifts') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Get agent shifts' }) getShifts(@Param('agentId') agentId: string) { return this.service.getShifts(agentId); diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 12982f570..1cee0b4e4 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -13,9 +13,13 @@ export class AgentsService { constructor(private prisma: PrismaService) {} async createAgentBooking(dto: CreateAgentBookingDto) { - const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } }); + const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } }); if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive'); - if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account'); + + const passenger = agent.iamUserId + ? await this.prisma.passenger.findUnique({ where: { iamUserId: agent.iamUserId } }) + : null; + if (!passenger) throw new BadRequestException('Agent must have a linked passenger account'); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -30,7 +34,7 @@ export class AgentsService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: agent.user.passenger.id, + passengerId: passenger.id, scheduleId: dto.scheduleId, status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT', totalMinor, diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 37bc89855..1202e4d45 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -1,11 +1,12 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuditService } from '../../common/audit.service'; -import { IamGuard } from '../../common/iam-adapter'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Audit') @Controller('audit') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class AuditController { constructor(private auditService: AuditService) {} 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 0565faf8f..8d3e0e4ca 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,303 +1,69 @@ -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 { AuthService } from './auth.service'; -import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +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') export class AuthController { - constructor(private service: AuthService) {} + constructor(private passengerAuthService: PassengerAuthService) {} @Post('register') - @ApiOperation({ - summary: 'Register new passenger account', - description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.' - }) - @ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' }) - @ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' }) + @IsPublic() + @ApiOperation({ summary: 'Register new passenger account' }) + @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) - register(@Body() dto: RegisterDto) { return this.service.register(dto); } + register(@Request() req: any, @Body() dto: RegisterDto) { + return this.passengerAuthService.register(dto, req); + } @Post('login') + @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Login with email and password', - description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.' - }) - @ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' }) - @ApiResponse({ status: 401, description: 'Invalid credentials or account locked' }) - @ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' }) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiBody({ type: LoginDto }) - login(@Body() dto: LoginDto) { return this.service.login(dto); } - - @Post('otp/request') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Request OTP verification code', - description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.' - }) - @ApiResponse({ status: 200, description: 'OTP sent successfully to email' }) - @ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' }) - @ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' }) - @ApiBody({ type: RequestOtpDto }) - requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); } - - @Post('otp/verify') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Verify OTP code', - description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.' - }) - @ApiResponse({ status: 200, description: 'OTP verified successfully' }) - @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) - @ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' }) - @ApiBody({ type: VerifyOtpDto }) - verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); } - - @Post('password/reset-request') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Request password reset link', - description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.' - }) - @ApiResponse({ status: 200, description: 'Password reset email sent successfully' }) - @ApiResponse({ status: 404, description: 'Email not found' }) - @ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' }) - @ApiBody({ type: RequestPasswordResetDto }) - requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); } - - @Post('password/reset') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Reset password with token', - description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.' - }) - @ApiResponse({ status: 200, description: 'Password reset successfully' }) - @ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' }) - @ApiResponse({ status: 404, description: 'User not found' }) - @ApiBody({ type: ResetPasswordDto }) - resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); } + login(@Request() req: any, @Body() dto: LoginDto) { + return this.passengerAuthService.login(dto, req); + } @Post('logout') @HttpCode(HttpStatus.OK) @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Logout current user', - description: `Logout the authenticated user and invalidate their session. + @ApiOperation({ summary: 'Logout current user' }) + @ApiResponse({ status: 200, description: 'Logout successful' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + logout(@Request() req: any) { + if (!req.user?.id) throw new UnauthorizedException('User not authenticated'); + return this.passengerAuthService.logout(req.user, req); + } -### What happens: -- Invalidates the current session token -- Records logout in audit log -- Frontend should clear stored token and redirect to home - -### Authentication: -- **Required**: JWT Bearer Token -- Token will be invalidated after successful logout` - }) - @ApiResponse({ - status: 200, - description: 'Logout successful', - schema: { - example: { - success: true, - message: 'Logged out successfully' - } - } - }) - @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) - logout(@Request() req: any) { - if (!req.user || !req.user.userId) { - throw new UnauthorizedException('User not authenticated'); - } - return this.service.logout(req.user.userId); + @Get('me') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: '[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard' }) + @ApiResponse({ status: 200, description: 'Returns the full req.user object set by JwtGuard' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + getMe(@Request() req: any) { + return { user: req.user }; } @Get('profile') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Get current user profile', - description: `**Returns complete user profile with all connected data** - ---- - -### Response Includes - -#### User Information -- Basic details (id, email, phone, fullName, role) -- Nationality and document information -- Fayda verification status -- Account timestamps (created, last login) - -#### Passenger Data (if role=PASSENGER) -- Passenger ID and preferences -- **Loyalty Account**: Tier, points balance, lifetime points -- **Wallet Account**: Balance (minor units), currency - -#### Devices -- List of registered devices with platform, name, push token, and last seen time - -#### User Preferences -- Language, notification settings, etc. - ---- - -### Use Cases - -1. **App Initialization**: Fetch on app load to get user context - -2. **Profile Pre-fill**: Use data to auto-fill booking forms - -3. **Verification Check**: Check \`faydaVerified\` before registration - -4. **Loyalty Display**: Show tier and points in UI - -5. **Wallet Balance**: Display available balance - -6. **Device Management**: Get list of user's registered devices - ---- - -### Authentication -- **Required**: JWT Bearer Token -- Token must be valid and not expired -- Returns profile for authenticated user only`, - }) - @ApiResponse({ - status: 200, - description: 'User profile retrieved successfully', - schema: { - example: { - id: 'user-uuid-123', - email: 'kelemu@email.com', - phone: '+251911234567', - fullName: 'Kelemu Abebe', - role: 'PASSENGER', - nationality: 'Ethiopian', - nationalityCode: 'ET', - nationalId: null, - passportNumber: null, - faydaVerified: true, - faydaVerifiedAt: '2024-01-15T10:30:00.000Z', - lastLoginAt: '2024-01-20T14:22:00.000Z', - createdAt: '2023-12-01T08:00:00.000Z', - passenger: { - id: 'passenger-uuid-456', - preferredLanguage: 'am', - loyalty: { - tier: 'SILVER', - pointsBalance: 1500, - lifetimePoints: 3000 - }, - wallet: { - balanceMinor: 50000, - currency: 'ETB' - } - }, - preferences: { - emailNotifications: true, - smsNotifications: true, - language: 'am' - }, - devices: [ - { - id: 'device-uuid-1', - platform: 'WEB', - name: 'Chrome on Windows', - pushToken: 'token-abc123', - trusted: true, - lastSeenAt: '2024-01-20T14:22:00.000Z' - }, - { - id: 'device-uuid-2', - platform: 'IOS', - name: 'iPhone 14', - pushToken: 'token-xyz789', - trusted: false, - lastSeenAt: '2024-01-19T10:15:00.000Z' - } - ] - } - } - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing JWT token', - schema: { - example: { - statusCode: 401, - message: 'Unauthorized' - } - } - }) - getProfile(@Request() req: any) { - console.log('Profile request - User from JWT:', req.user); - if (!req.user || !req.user.userId) { - throw new UnauthorizedException('User not authenticated'); - } - return this.service.getProfile(req.user.userId); + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'User profile retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + getProfile(@Request() req: any) { + const userId = req.user?.id; + if (!userId) throw new UnauthorizedException('User not authenticated'); + 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/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index d44159c67..e12c8cd06 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,152 +1,51 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class NameDto { + @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) + @IsString() + am: string; + + @ApiProperty({ example: 'Kelemu Ketsela' }) + @IsString() + en: string; +} export class RegisterDto { - @ApiProperty({ - description: 'Full name of the passenger', - example: 'Kelemu Ketsela', - minLength: 2, - maxLength: 100 - }) - @IsString() - fullName: string; - - @ApiProperty({ - description: 'Email address (must be unique)', - example: 'kelemu@email.com', - format: 'email' - }) - @IsEmail() + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() email: string; - @ApiProperty({ - description: 'Phone number with country code', - example: '+251912345678', - pattern: '^\\+[1-9]\\d{1,14}$' - }) - @IsString() - phone: string; + @ApiProperty({ example: 'kelemu.ketsela' }) + @IsString() + username: string; - @ApiProperty({ - description: 'Password (minimum 8 characters)', - example: 'SecurePass123', - minLength: 8, - format: 'password' - }) - @IsString() - @MinLength(8) + @ApiProperty({ example: '+251912345678' }) + @IsString() + phoneNumber: string; + + @ApiProperty({ type: NameDto }) + @ValidateNested() + @Type(() => NameDto) + name: NameDto; + + @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) + @IsString() + @MinLength(8) password: string; - @ApiPropertyOptional({ - description: 'Nationality of the passenger', - example: 'Ethiopian' - }) - @IsOptional() - @IsString() - nationality?: string; - - @ApiPropertyOptional({ - description: 'National ID number', - example: 'ET123456789' - }) - @IsOptional() - @IsString() - nationalId?: string; - - @ApiPropertyOptional({ - description: 'Passport number for international travelers', - example: 'P1234567' - }) - @IsOptional() - @IsString() - passportNumber?: string; + @ApiProperty({ example: 'SecurePass123', format: 'password' }) + @IsString() + confirmPassword: string; } export class LoginDto { - @ApiProperty({ - description: 'Registered email address', - example: 'kelemu@email.com', - format: 'email' - }) - @IsEmail() + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() email: string; - @ApiProperty({ - description: 'Account password', - example: 'password123', - format: 'password' - }) - @IsString() + @ApiProperty({ example: 'password123', format: 'password' }) + @IsString() password: string; } - -export class RequestOtpDto { - @ApiProperty({ - description: 'Email address to send OTP', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)', - example: 'REGISTRATION', - enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION'] - }) - @IsString() - purpose: string; -} - -export class VerifyOtpDto { - @ApiProperty({ - description: 'Email address', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: '6-digit OTP code', - example: '123456', - minLength: 6, - maxLength: 6 - }) - @IsString() - code: string; - - @ApiProperty({ - description: 'Purpose of OTP verification', - example: 'REGISTRATION', - enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION'] - }) - @IsString() - purpose: string; -} - -export class RequestPasswordResetDto { - @ApiProperty({ - description: 'Email address of the account', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; -} - -export class ResetPasswordDto { - @ApiProperty({ - description: 'Password reset token received via email', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - }) - @IsString() - token: string; - - @ApiProperty({ - description: 'New password (minimum 8 characters)', - example: 'NewSecurePass123', - minLength: 8, - format: 'password' - }) - @IsString() - @MinLength(8) - newPassword: string; -} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.module.ts b/apps/edr-passenger-api/src/modules/auth/auth.module.ts index 547937d74..54357df06 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.module.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.module.ts @@ -1,24 +1,10 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; -import { JwtStrategy } from '../../common/jwt.strategy'; +import { PassengerAuthService } from './passenger-auth.service'; @Module({ - imports: [ - PassportModule, - JwtModule.registerAsync({ - inject: [ConfigService], - useFactory: (c: ConfigService) => ({ - secret: c.get('JWT_SECRET'), - signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') }, - }), - }), - ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [JwtModule], + providers: [PassengerAuthService], + exports: [PassengerAuthService], }) export class AuthModule {} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts deleted file mode 100644 index e937a106b..000000000 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { PrismaService } from '../../common/prisma.service'; -import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; -import * as bcrypt from 'bcrypt'; -import * as crypto from 'crypto'; - -@Injectable() -export class AuthService { - constructor(private prisma: PrismaService, private jwt: JwtService) {} - - async register(dto: RegisterDto) { - const exists = await this.prisma.user.findFirst({ - where: { OR: [{ email: dto.email }, { phone: dto.phone }] }, - }); - if (exists) throw new ConflictException('Email or phone already registered'); - const passwordHash = await bcrypt.hash(dto.password, 10); - const user = await this.prisma.user.create({ - data: { - fullName: dto.fullName, - email: dto.email, - phone: dto.phone, - passwordHash, - nationality: dto.nationality, - nationalId: dto.nationalId, - passportNumber: dto.passportNumber - }, - }); - const passenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.userPreferences.create({ data: { userId: user.id } }); - await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email }); - return await this.signToken(user.id, user.email, user.role, passenger.id); - } - - async login(dto: LoginDto) { - const user = await this.prisma.user.findUnique({ - where: { email: dto.email }, - include: { passenger: true, agent: true }, - }); - if (!user) throw new UnauthorizedException('Invalid credentials'); - - if (user.lockedUntil && user.lockedUntil > new Date()) { - throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`); - } - - if (!(await bcrypt.compare(dto.password, user.passwordHash))) { - await this.prisma.user.update({ - where: { id: user.id }, - data: { - failedLoginAttempts: { increment: 1 }, - lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null - } - }); - throw new UnauthorizedException('Invalid credentials'); - } - - await this.prisma.user.update({ - where: { id: user.id }, - data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() } - }); - - await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null); - - // Ensure passenger exists and get its ID - let passengerId = user.passenger?.id; - if (!passengerId) { - // If passenger doesn't exist, create it - const passenger = await this.prisma.passenger.create({ - data: { userId: user.id } - }); - passengerId = passenger.id; - // Also create loyalty and wallet accounts - await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } }); - } - - return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id); - } - - async requestOtp(dto: RequestOtpDto) { - const code = Math.floor(100000 + Math.random() * 900000).toString(); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - await this.prisma.otpCode.create({ - data: { email: dto.email, code, purpose: dto.purpose, expiresAt } - }); - console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`); - return { sent: true, expiresIn: 600 }; - } - - async verifyOtp(dto: VerifyOtpDto) { - const otp = await this.prisma.otpCode.findFirst({ - where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } }, - orderBy: { createdAt: 'desc' } - }); - if (!otp) throw new BadRequestException('Invalid or expired OTP'); - await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } }); - return { verified: true }; - } - - async requestPasswordReset(dto: RequestPasswordResetDto) { - const user = await this.prisma.user.findUnique({ where: { email: dto.email } }); - if (!user) return { sent: true }; - const token = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); - await this.prisma.passwordResetToken.create({ - data: { userId: user.id, token, expiresAt } - }); - console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`); - return { sent: true }; - } - - async resetPassword(dto: ResetPasswordDto) { - const resetToken = await this.prisma.passwordResetToken.findUnique({ - where: { token: dto.token } - }); - if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) { - throw new BadRequestException('Invalid or expired reset token'); - } - const passwordHash = await bcrypt.hash(dto.newPassword, 10); - await this.prisma.user.update({ - where: { id: resetToken.userId }, - data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null } - }); - await this.prisma.passwordResetToken.update({ - where: { id: resetToken.id }, - data: { used: true } - }); - await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null); - return { reset: true }; - } - - async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) { - const { search, role, status, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; - - const where: any = { - role: { not: 'PASSENGER' }, // Exclude passenger accounts - }; - - if (search) { - where.OR = [ - { email: { contains: search, mode: 'insensitive' } }, - { fullName: { contains: search, mode: 'insensitive' } }, - ]; - } - - if (role) { - where.role = role; - } - - // For status filtering, we check if user is active (no lock/block) or inactive - if (status === 'ACTIVE') { - where.AND = [ - { blockedUntil: { lte: new Date() } }, - { lockedUntil: { lte: new Date() } } - ]; - } else if (status === 'INACTIVE') { - where.OR = [ - { blockedUntil: { gt: new Date() } }, - { lockedUntil: { gt: new Date() } } - ]; - } - - const [items, total] = await Promise.all([ - this.prisma.user.findMany({ - where, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - blockedUntil: true, - lockedUntil: true, - }, - skip, - take: pageSize, - orderBy: { createdAt: 'desc' }, - }), - this.prisma.user.count({ where }), - ]); - - return { - items: items.map(user => ({ - id: user.id, - email: user.email, - fullName: user.fullName, - role: user.role, - lastLogin: user.lastLoginAt, - status: (!user.blockedUntil || user.blockedUntil <= new Date()) && - (!user.lockedUntil || user.lockedUntil <= new Date()) - ? 'ACTIVE' - : 'INACTIVE', - })), - total, - page, - pageSize, - }; - } - - async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) { - const exists = await this.prisma.user.findFirst({ - where: { OR: [{ email: dto.email }] }, - }); - if (exists) throw new ConflictException('Email already registered'); - - const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10); - - const user = await this.prisma.user.create({ - data: { - email: dto.email, - fullName: dto.fullName, - role: dto.role as any, - phone: dto.email, // Use email as phone temporarily for unique constraint - passwordHash, - blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined, - }, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - }, - }); - - await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role }); - - return user; - } - - async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - const updateData: any = {}; - if (dto.fullName) updateData.fullName = dto.fullName; - if (dto.role) updateData.role = dto.role; - if (dto.status === 'ACTIVE') { - updateData.blockedUntil = null; - updateData.lockedUntil = null; - } else if (dto.status === 'INACTIVE') { - updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); - } - - const updated = await this.prisma.user.update({ - where: { id }, - data: updateData, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - }, - }); - - await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData }); - - return updated; - } - - async deleteUser(id: string) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - // Don't actually delete, just deactivate - await this.prisma.user.update({ - where: { id }, - data: { blockedUntil: new Date(), lockedUntil: new Date() }, - }); - - await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null); - - return { deleted: true }; - } - - async resetUserPassword(id: string, tempPassword: string) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - const passwordHash = await bcrypt.hash(tempPassword, 10); - await this.prisma.user.update({ - where: { id }, - data: { - passwordHash, - failedLoginAttempts: 0, - lockedUntil: null, - }, - }); - - await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' }); - - return { reset: true, tempPassword }; - } - - private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) { - // Get the full user data to include fullName - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { id: true, email: true, fullName: true, role: true } - }); - - const payload = { sub: userId, email, role, passengerId, agentId }; - console.log('[AUTH] Creating JWT with payload:', payload); - - const token = this.jwt.sign(payload); - console.log('[AUTH] JWT created, token length:', token.length); - - const response = { - token, - user: { - id: userId, - email, - fullName: user?.fullName || email, - role, - passengerId, - agentId - } - }; - console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId); - return response; - } - - private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) { - await this.prisma.auditLog.create({ - data: { userId, action, entityType, entityId, oldData, newData } - }); - } - - async getProfile(userId: string) { - if (!userId) { - throw new UnauthorizedException('User ID not found in token'); - } - - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { - passenger: { - include: { - loyalty: true, - wallet: true, - }, - }, - preferences: true, - devices: true, - }, - }); - - if (!user) throw new UnauthorizedException('User not found'); - - return { - id: user.id, - email: user.email, - phone: user.phone, - fullName: user.fullName, - role: user.role, - nationality: user.nationality, - nationalityCode: user.nationalityCode, - nationalId: user.nationalId, - passportNumber: user.passportNumber, - faydaVerified: user.faydaVerified, - faydaVerifiedAt: user.faydaVerifiedAt, - lastLoginAt: user.lastLoginAt, - createdAt: user.createdAt, - passenger: user.passenger ? { - id: user.passenger.id, - preferredLanguage: user.passenger.preferredLanguage, - loyalty: user.passenger.loyalty ? { - tier: user.passenger.loyalty.tier, - pointsBalance: user.passenger.loyalty.pointsBalance, - lifetimePoints: user.passenger.loyalty.lifetimePoints, - } : null, - wallet: user.passenger.wallet ? { - balanceMinor: user.passenger.wallet.balanceMinor, - currency: user.passenger.wallet.currency, - } : null, - } : null, - preferences: user.preferences, - devices: user.devices.map(device => ({ - id: device.id, - platform: device.platform, - name: device.name, - pushToken: device.pushToken, - trusted: device.trusted, - lastSeenAt: device.lastSeenAt, - })), - }; - } - - async logout(userId: string) { - // Invalidate all active sessions for this user - await this.prisma.session.deleteMany({ - where: { userId } - }); - - // Log the logout action - await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null); - - return { - success: true, - message: 'Logged out successfully' - }; - } -} 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 new file mode 100644 index 000000000..dab714ef4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -0,0 +1,231 @@ +import { + Injectable, + ConflictException, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { ModuleRef, ContextIdFactory } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service'; +import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { PrismaService } from '../../common/prisma.service'; +import { RegisterDto, LoginDto } from './auth.dto'; + +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; + +@Injectable() +export class PassengerAuthService { + constructor( + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly moduleRef: ModuleRef, + private readonly eventEmitter: EventEmitter2, + ) {} + + private async resolveIamAuthService(req: any): Promise { + const contextId = ContextIdFactory.getByRequest(req); + this.moduleRef.registerRequestByContextId(req, contextId); + return this.moduleRef.resolve(IamAuthService, contextId, { strict: false }); + } + + async register(dto: RegisterDto, req: any) { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (existing.length) throw new ConflictException('Email or phone already registered'); + + const iamAuthService = await this.resolveIamAuthService(req); + + const { token, refreshToken } = await iamAuthService.signupWithPassword({ + email: dto.email, + username: dto.username, + phoneNumber: dto.phoneNumber, + userType: EUserType.INDIVIDUAL, + name: dto.name, + password: dto.password, + confirmPassword: dto.confirmPassword, + }); + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + if (!iamRows.length) { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + const iamUserId = iamRows[0].id; + + let passengerId: string; + try { + const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); + passengerId = result.passengerId; + } catch { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + + return { + token, + refreshToken, + user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, + }; + } + + async login(dto: LoginDto, req: any) { + const iamAuthService = await this.resolveIamAuthService(req); + + let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean }; + try { + iamResult = await iamAuthService.login({ email: dto.email, password: dto.password }); + } catch { + this.eventEmitter.emit('auth.login.failed', { email: dto.email }); + throw new UnauthorizedException('Invalid credentials'); + } + + if ('mfaRequired' in iamResult && iamResult.mfaRequired) { + return iamResult; + } + + const { token, refreshToken } = iamResult as { token: string; refreshToken: string }; + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + const iamUser = iamRows[0]; + if (!iamUser) { + throw new InternalServerErrorException('IAM user not found after successful authentication'); + } + + // Find existing Passenger record or lazy-provision one on first login + let passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: iamUser.id }, + select: { id: true }, + }); + + if (!passenger) { + const result = await this.provisionPassengerSatellite({ + iamUserId: iamUser.id, + auditAction: 'USER_AUTO_PROVISIONED', + }); + passenger = { id: result.passengerId }; + } + + return { + token, + refreshToken, + user: { id: iamUser.id, iamUserId: iamUser.id, email: dto.email, passengerId: passenger.id }, + }; + } + + private async provisionPassengerSatellite(data: { + iamUserId: string; + auditAction: string; + }): Promise<{ passengerId: string }> { + return this.prisma.$transaction(async (tx) => { + const passenger = await tx.passenger.create({ + data: { iamUserId: data.iamUserId }, + }); + await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } }); + await tx.walletAccount.create({ data: { passengerId: passenger.id } }); + await tx.userPreferences.create({ data: { iamUserId: data.iamUserId } }); + await tx.auditLog.create({ + data: { + iamUserId: data.iamUserId, + action: data.auditAction, + entityType: 'User', + entityId: data.iamUserId, + newData: { iamUserId: data.iamUserId }, + }, + }); + return { passengerId: passenger.id }; + }); + } + + async logout(user: any, req: any) { + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.logout(user); + return { success: true, message: 'Logged out successfully' }; + } + + async getProfile(iamUserId: string) { + const [passenger, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { loyalty: true, wallet: true }, + }), + this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ), + ]); + + if (!passenger) throw new Error('Passenger not found'); + const iam = iamRows[0]; + + return { + iamUserId, + email: iam?.email ?? null, + phone: iam?.phone_number ?? null, + fullName: iam?.name?.en ?? iam?.name?.am ?? null, + faydaVerified: iam?.metadata?.faydaVerified ?? false, + createdAt: passenger.createdAt, + passenger: { + id: passenger.id, + preferredLanguage: passenger.preferredLanguage, + loyalty: passenger.loyalty + ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: passenger.loyalty.lifetimePoints } + : null, + wallet: passenger.wallet + ? { balanceMinor: passenger.wallet.balanceMinor, currency: passenger.wallet.currency } + : null, + }, + }; + } + + private async compensateIamSignup(email: string): Promise { + try { + 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/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 285e38bf0..bb9b8ffae 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,11 +1,11 @@ import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard } from '../../common/iam-adapter'; @ApiTags('Booking') @Controller('bookings') @@ -45,7 +45,8 @@ export class BookingsController { } @Get('by-device') - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'Get bookings by device ID', description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' }) @@ -99,7 +100,8 @@ export class BookingsController { } @Post('guest') - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', description: `Creates a booking without requiring login. Supports all four booking types. @@ -247,8 +249,8 @@ export class BookingsController { }) @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) - createGuest(@Body() dto: CreateGuestBookingDto) { - return this.guestService.createGuestBooking(dto); + createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) { + return this.guestService.createGuestBooking(dto, req); } @Get('saved-passengers') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index bce07b915..63fd8be85 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -7,12 +7,13 @@ import { GuestBookingService } from './guest-booking.service'; import { SeatsModule } from '../seats/seats.module'; import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; +import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; -@Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule], - controllers: [BookingsController], - providers: [BookingsService, GuestBookingService], - exports: [BookingsService, GuestBookingService] +@Module({ + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule], + controllers: [BookingsController], + providers: [BookingsService, GuestBookingService], + exports: [BookingsService, GuestBookingService] }) export class BookingsModule {} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 22c11b8f1..21532fa44 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -33,12 +35,13 @@ interface BookingFilters { @Injectable() export class BookingsService { constructor( - private prisma: PrismaService, - private seatsService: SeatsService, - private eventEmitter: EventEmitter2, - private verifaydaService: VerifaydaService, - private currencyService: CurrencyService, - private fareEngine: FareEngineService, + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly seatsService: SeatsService, + private readonly eventEmitter: EventEmitter2, + private readonly verifaydaService: VerifaydaService, + private readonly currencyService: CurrencyService, + private readonly fareEngine: FareEngineService, ) {} async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { @@ -111,22 +114,22 @@ export class BookingsService { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - // Find user with this device ID - const device = await this.prisma.device.findUnique({ - where: { id: deviceId }, - include: { user: { include: { passenger: true } } }, - }).catch(() => null); - + // Find passenger linked to this device via iamUserId + const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null); + const passenger = device?.iamUserId + ? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null) + : null; + const searchConditions = search ? [ { bookingRef: { contains: search, mode: 'insensitive' } }, { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ] : []; - + const where: any = { OR: [ { userAgent: deviceId }, - ...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []), + ...(passenger ? [{ passengerId: passenger.id }] : []), ], }; @@ -193,11 +196,27 @@ export class BookingsService { const where: any = {}; if (search) { + const iamRows = await this.dataSource.query<{ id: string }[]>( + `SELECT u.id FROM iam.users u + WHERE (u.name->>'en') ILIKE $1 OR (u.name->>'am') ILIKE $1 + OR u.email ILIKE $1 OR u.phone_number ILIKE $1`, + [`%${search}%`], + ); + const matchedPassengers = iamRows.length > 0 + ? await this.prisma.passenger.findMany({ + where: { iamUserId: { in: iamRows.map(r => r.id) } }, + select: { id: true }, + }) + : []; + where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { contactEmail: { contains: search, mode: 'insensitive' } }, { contactPhone: { contains: search, mode: 'insensitive' } }, - { passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } }, + ...(matchedPassengers.length > 0 + ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] + : []), + { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } @@ -211,7 +230,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - passenger: { include: { user: true } }, + passenger: { select: { id: true, iamUserId: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, @@ -219,33 +238,48 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + + const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: booking.passenger?.user, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam + ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } + : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -262,10 +296,23 @@ export class BookingsService { return this.createOneWayBooking(dto); } + private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) { + for (const seatId of requestedSeatIds) { + if (!holdSeatIds.includes(seatId)) { + throw new BadRequestException( + `Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`, + ); + } + } + } + private async createOneWayBooking(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); - + + const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId); + this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds); + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } @@ -335,6 +382,11 @@ export class BookingsService { if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired'); if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired'); + const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean); + const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean); + if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds); + if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds); + const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, @@ -478,6 +530,11 @@ export class BookingsService { if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired'); if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired'); + const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId); + const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId); + this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds); + this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds); + const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, @@ -624,6 +681,11 @@ export class BookingsService { if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired'); if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired'); + this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId)); + this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId)); + this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId)); + this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId)); + // Load all 4 schedules const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -815,7 +877,21 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ + ...passenger, + passengerName, + dateOfBirth, + category, + verifaydaVerified, + verifaydaData, + nationality, + // Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses + // outboundSeatId/returnSeatId. Accept either form so both DTOs work. + outboundSeatId: passenger.outboundSeatId ?? passenger.seatId, + outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId, + returnSeatId: passenger.returnSeatId, + returnLeg2SeatId: passenger.returnLeg2SeatId, + }); } return processedPassengers; } @@ -1010,7 +1086,7 @@ export class BookingsService { await this.prisma.bookingModification.create({ data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason }, }); - await this.seatsService.releaseSeats(oldSeats); + await this.seatsService.releaseSeats(booking.id); await this.seatsService.confirmSeats(dto.newSeatIds); return { modified: true, bookingRef: dto.bookingRef }; } @@ -1021,7 +1097,7 @@ export class BookingsService { if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; @@ -1050,7 +1126,7 @@ export class BookingsService { const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); if (!booking) throw new NotFoundException('Booking not found'); - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); await this.prisma.booking.delete({ where: { id } }); @@ -1086,7 +1162,7 @@ export class BookingsService { const cutoff = new Date(Date.now() - 20 * 60 * 1000); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } }); for (const b of expired) { - await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(b.id); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); } } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 922d7409b..f6971a1bc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -3,17 +3,32 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; +import { PassengerAuthService } from '../auth/passenger-auth.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; -import * as bcrypt from 'bcrypt'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } +// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx) +const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964']; + +function generateEthiopianPhone(): string { + const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)]; + const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0'); + return `+251${prefix}${suffix}`; +} + +function generateGuestEmail(uniqueId: string): string { + const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et']; + const domain = domains[Math.floor(Math.random() * domains.length)]; + return `guest.edr.${uniqueId}@${domain}`; +} + function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); @@ -29,18 +44,19 @@ export class GuestBookingService { private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, ) {} - async createGuestBooking(dto: CreateGuestBookingDto) { - if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto); - if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto); - if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto); - return this.createGuestOneWayBooking(dto); + async createGuestBooking(dto: CreateGuestBookingDto, req?: any) { + if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req); + if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req); + if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req); + return this.createGuestOneWayBooking(dto, req); } - private async createGuestOneWayBooking(dto: CreateGuestBookingDto) { + private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -80,15 +96,12 @@ export class GuestBookingService { let verifaydaData: Record | undefined; let nationality = passenger.nationality; - // Determine if passenger is Ethiopian - const isEthiopian = passenger.nationality === 'Ethiopian' || + const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; - - // Ethiopian with National ID + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { - // Attempt Fayda verification const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) { throw new BadRequestException( @@ -100,22 +113,14 @@ export class GuestBookingService { verifaydaData = verification.passengerData?.profileData; } nationality = 'Ethiopian'; - } - // International passenger with Passport (non-Ethiopian) - else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { - // Passport details are required for international passengers + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) { throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); } nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); - } - // Ethiopian with Passport (manual entry without Fayda) - else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { - // Ethiopians can use passport instead of national ID + } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { nationality = 'Ethiopian'; - } - // International with National ID (e.g., Djiboutian national ID) - else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { nationality = nationality || 'Other'; } @@ -164,16 +169,27 @@ export class GuestBookingService { displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); } - // Create or get guest passenger + // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { for (const passenger of passengersData) { - // Note: SavedPassengerProfile will be available after migration - // Temporarily disabled until prisma generate completes - // await this.prisma.savedPassengerProfile.create({ ... }); + await this.prisma.savedPassengerProfile.create({ + data: { + userId: iamUserId ?? undefined, + deviceId: dto.deviceId, + passengerName: passenger.passengerName, + dateOfBirth: passenger.dateOfBirth, + idDocumentType: passenger.idDocumentType, + passportNumber: passenger.passportNumber, + passportCountry: passenger.passportCountry, + nationality: passenger.nationality, + phone: passenger.phone, + email: passenger.email, + }, + }); } } @@ -181,7 +197,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', totalMinor, @@ -222,7 +238,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { baseFareMinor, adultCount, @@ -242,7 +258,7 @@ export class GuestBookingService { }; } - private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) { + private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); } @@ -359,7 +375,7 @@ export class GuestBookingService { : totalMinor; // Create or resolve guest passenger (same as one-way) - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId); @@ -368,7 +384,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', @@ -436,7 +452,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { outboundBaseFareMinor: outboundBaseFare, returnBaseFareMinor: returnBaseFare, @@ -455,7 +471,7 @@ export class GuestBookingService { }; } - private async createGuestTransitBooking(dto: CreateGuestBookingDto) { + private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); } @@ -556,13 +572,13 @@ export class GuestBookingService { ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', @@ -628,7 +644,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { leg1BaseFareMinor: leg1BaseFare, leg2BaseFareMinor: leg2BaseFare, @@ -642,7 +658,7 @@ export class GuestBookingService { }; } - private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) { + private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { @@ -749,7 +765,7 @@ export class GuestBookingService { ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ seat: { connect: { id: seatId } }, @@ -770,7 +786,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', @@ -817,7 +833,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { outboundLeg1FareMinor: obL1Fare, outboundLeg2FareMinor: obL2Fare, @@ -836,62 +852,28 @@ export class GuestBookingService { private async resolveGuestPassenger( dto: Pick, firstPassenger: any, - ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> { + req?: any, + ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) throw new BadRequestException('Email already registered. Please login instead.'); - - let accountPhone = firstPassenger.phone || null; - if (accountPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); - if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); - } - if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - const user = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: firstPassenger.email, - phone: accountPhone, - passwordHash: await bcrypt.hash(dto.password, 10), - nationality: firstPassenger.nationality, - nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, - passportNumber: firstPassenger.passportNumber, + const guestName = firstPassenger.passengerName ?? 'Guest'; + const result = await this.passengerAuthService.register( + { + email: firstPassenger.email, + username: firstPassenger.email, + phoneNumber: firstPassenger.phone || `+251900000000`, + name: { en: guestName, am: guestName }, + password: dto.password, + confirmPassword: dto.password, }, - }); - const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); - await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); - return { guestPassenger, userId: user.id, createdAccount: true }; + req, + ); + return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true }; } - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - let guestEmail = firstPassenger.email; - if (guestEmail) { - const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } }); - if (existing) guestEmail = null; - } - if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`; - - let guestPhone = firstPassenger.phone; - if (guestPhone) { - const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); - if (existing) guestPhone = null; - } - if (!guestPhone) guestPhone = `+guest-${uniqueId}`; - - const tempUser = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: guestEmail, - phone: guestPhone, - passwordHash: await bcrypt.hash(Math.random().toString(36), 10), - role: 'PASSENGER', - }, - }); - const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); - return { guestPassenger, userId: null, createdAccount: false }; + const guestPassenger = await this.prisma.passenger.create({ data: {} }); + await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); + await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); + return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false }; } async getSavedPassengers(userId?: string, deviceId?: string): Promise { @@ -899,10 +881,6 @@ export class GuestBookingService { throw new BadRequestException('Either userId or deviceId is required'); } - // Temporarily return empty array until Prisma client is regenerated - return []; - - /* Uncomment after running migration and prisma generate const profiles = await this.prisma.savedPassengerProfile.findMany({ where: { OR: [ @@ -917,14 +895,13 @@ export class GuestBookingService { passengerName: p.passengerName, dateOfBirth: p.dateOfBirth.toISOString().split('T')[0], idDocumentType: p.idDocumentType, - idDocumentNumber: undefined, // Never return sensitive data + idDocumentNumber: undefined, passportNumber: p.passportNumber || undefined, passportCountry: p.passportCountry || undefined, nationality: p.nationality || undefined, phone: p.phone || undefined, email: p.email || undefined, })); - */ } private async getBaseFare( diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 3081c7a75..87dee3ec8 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Currencies') @Controller('currencies') @@ -15,8 +16,7 @@ export class CurrenciesController { } @Post() - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(201) createCurrency(@Body() dto: CreateCurrencyDto) { @@ -24,24 +24,21 @@ export class CurrenciesController { } @Patch(':id') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) { return this.currenciesService.updateCurrency(id, dto); } @Delete(':id') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerAdmin() @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); } @Post('sync-rates') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(200) syncRates() { diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 1f9717c6a..2d52879a8 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -1,14 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @Injectable() export class DashboardService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ - this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }), + this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { loyalty: true } }), this.prisma.booking.findFirst({ where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } }, include: { @@ -27,7 +32,16 @@ export class DashboardService { const hour = now.getHours(); const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING'; - const firstName = passenger?.user.fullName.split(' ')[0] ?? ''; + + let firstName = ''; + if (passenger?.iamUserId) { + const iamRows = await this.dataSource.query<{ name: { en?: string; am?: string } | null }[]>( + `SELECT name FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ); + const name = iamRows[0]?.name; + firstName = (name?.en ?? name?.am ?? '').split(' ')[0]; + } const seat = upcomingBooking?.seats[0]; return { diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index 4056b4259..c53d3a5fb 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Fraud Detection') @Controller('fraud') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class FraudController { private readonly logger = new Logger(FraudController.name); @@ -17,7 +17,6 @@ export class FraudController { * Get fraud alerts */ @Get('alerts') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Get fraud alerts' }) async getAlerts( @Query('userId') userId?: string, @@ -32,7 +31,6 @@ export class FraudController { * Get fraud rules */ @Get('rules') - @IamRoles('ADMIN') @ApiOperation({ summary: 'Get fraud detection rules' }) async getRules() { const rules = await this.fraudService.getRules(); @@ -43,7 +41,7 @@ export class FraudController { * Create or update fraud rule */ @Post('rules') - @IamRoles('ADMIN') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Create or update fraud rule' }) async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { const rule = await this.fraudService.upsertRule(body.type, body.config); @@ -54,10 +52,10 @@ export class FraudController { * Block user temporarily */ @Post('actions/block') - @IamRoles('ADMIN', 'SUPERVISOR') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Block user temporarily' }) - async blockUser(@Body() body: { userId: string; durationMinutes: number }) { - await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes); + async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { + await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); return { message: `User blocked for ${body.durationMinutes} minutes` }; } @@ -65,10 +63,10 @@ export class FraudController { * Unblock user */ @Post('actions/unblock') - @IamRoles('ADMIN', 'SUPERVISOR') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Unblock user' }) - async unblockUser(@Body() body: { userId: string }) { - await this.fraudService.unblockUser(body.userId); + async unblockUser(@Body() body: { iamUserId: string }) { + await this.fraudService.unblockUser(body.iamUserId); return { message: 'User unblocked' }; } } diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index 7c3b66e6b..a75db4449 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; export interface FraudRuleConfig { @@ -14,47 +16,37 @@ export interface FraudRuleConfig { export class FraudService { private readonly logger = new Logger(FraudService.name); - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} /** * Evaluate fraud rules and create alerts if triggered */ async evaluateRules( - userId: string, + passengerId: string, eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed', context: Record, ): Promise<{ triggered: boolean; rules: string[] }> { const triggeredRules: string[] = []; - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) return { triggered: false, rules: [] }; - - // Check velocity rule (multiple bookings in short time) if (eventType === 'booking.created') { - const velocityTriggered = await this.checkVelocityRule(userId); - if (velocityTriggered) { - triggeredRules.push('VELOCITY'); - } + const velocityTriggered = await this.checkVelocityRule(passengerId); + if (velocityTriggered) triggeredRules.push('VELOCITY'); - // Check high-value booking const amount = (context.amountMinor as number) || 0; const highValueTriggered = await this.checkHighValueRule(amount); - if (highValueTriggered) { - triggeredRules.push('HIGH_VALUE'); - } + if (highValueTriggered) triggeredRules.push('HIGH_VALUE'); } - // Check repeated failed payments if (eventType === 'payment.failed') { - const failedPaymentTriggered = await this.checkFailedPaymentRule(userId); - if (failedPaymentTriggered) { - triggeredRules.push('FAILED_PAYMENTS'); - } + const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId); + if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS'); } - // Create alert if rules triggered if (triggeredRules.length > 0) { - await this.createFraudAlert(userId, eventType, triggeredRules, context); + await this.createFraudAlert(passengerId, eventType, triggeredRules, context); return { triggered: true, rules: triggeredRules }; } @@ -64,7 +56,7 @@ export class FraudService { /** * Check velocity rule: X bookings in Y minutes */ - private async checkVelocityRule(userId: string): Promise { + private async checkVelocityRule(passengerId: string): Promise { const rule = await this.prisma.fraudRule.findFirst({ where: { type: 'VELOCITY', enabled: true }, }); @@ -72,18 +64,14 @@ export class FraudService { if (!rule) return false; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30; - const threshold = rule.threshold; - const bookingCount = await this.prisma.booking.count({ where: { - passengerId: userId, - createdAt: { - gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000), - }, + passengerId, + createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) }, }, }); - return bookingCount > threshold; + return bookingCount > rule.threshold; } /** @@ -104,7 +92,7 @@ export class FraudService { /** * Check failed payment rule: X failed attempts in Y minutes */ - private async checkFailedPaymentRule(userId: string): Promise { + private async checkFailedPaymentRule(passengerId: string): Promise { const rule = await this.prisma.fraudRule.findFirst({ where: { type: 'FAILED_PAYMENTS', enabled: true }, }); @@ -112,33 +100,33 @@ export class FraudService { if (!rule) return false; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60; - const threshold = rule.threshold; - const failedCount = await this.prisma.paymentIntent.count({ where: { - booking: { passengerId: userId }, + booking: { passengerId }, status: 'FAILED', - updatedAt: { - gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000), - }, + updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) }, }, }); - return failedCount > threshold; + return failedCount > rule.threshold; } /** * Create a fraud alert */ private async createFraudAlert( - userId: string, + passengerId: string, eventType: string, triggeredRules: string[], context: Record, ): Promise { + const passenger = await this.prisma.passenger.findUnique({ + where: { id: passengerId }, + select: { iamUserId: true }, + }); const alert = await this.prisma.fraudAlert.create({ data: { - userId, + iamUserId: passenger?.iamUserId ?? passengerId, eventType, triggeredRules, context: context as any, @@ -146,35 +134,34 @@ export class FraudService { }, }); - this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`); + this.logger.warn(`Fraud alert created: ${alert.id} for passenger ${passengerId} - rules: ${triggeredRules.join(', ')}`); - // Trigger blocking if needed if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) { - await this.blockUserTemporarily(userId, 30); // Block for 30 minutes + if (passenger?.iamUserId) await this.blockUserTemporarily(passenger.iamUserId, 30); } } /** * Block user temporarily */ - async blockUserTemporarily(userId: string, durationMinutes: number): Promise { + async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise { const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000); - await this.prisma.user.update({ - where: { id: userId }, + await this.prisma.passenger.updateMany({ + where: { iamUserId }, data: { blockedUntil }, }); - this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`); + this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`); } /** * Unblock user */ - async unblockUser(userId: string): Promise { - await this.prisma.user.update({ - where: { id: userId }, + async unblockUser(iamUserId: string): Promise { + await this.prisma.passenger.updateMany({ + where: { iamUserId }, data: { blockedUntil: null }, }); - this.logger.log(`User ${userId} unblocked`); + this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } /** @@ -182,7 +169,7 @@ export class FraudService { */ async getAlerts(userId?: string, limit = 100, offset = 0) { return this.prisma.fraudAlert.findMany({ - where: userId ? { userId } : {}, + where: userId ? { iamUserId: userId } : {}, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, @@ -234,9 +221,10 @@ export class FraudService { * Event listener for payment failed */ @OnEvent('payment.failed') - async onPaymentFailed(payload: { intentId: string; userId: string }) { - await this.evaluateRules(payload.userId, 'payment.failed', { - intentId: payload.intentId, + async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) { + if (!payload.booking?.passengerId) return; + await this.evaluateRules(payload.booking.passengerId, 'payment.failed', { + bookingId: payload.booking.id, }); } @@ -244,9 +232,18 @@ export class FraudService { * Event listener for auth login failed */ @OnEvent('auth.login.failed') - async onLoginFailed(payload: { userId: string; email: string }) { - await this.evaluateRules(payload.userId, 'auth.login.failed', { - email: payload.email, + async onLoginFailed(payload: { email: string }) { + if (!payload.email) return; + const iamRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [payload.email], + ); + if (!iamRows.length) return; + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: iamRows[0].id }, + select: { id: true }, }); + if (!passenger) return; + await this.evaluateRules(passenger.id, 'auth.login.failed', { email: payload.email }); } } 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 b622e3b7d..f27bd8ecd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -2,7 +2,8 @@ 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 { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { TestNotificationDto } from './notifications.dto'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @@ -39,8 +40,7 @@ export class NotificationsController { } @Post('send/email') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct email via the email microservice' }) @ApiBody({ type: SendEmail }) sendEmail(@Body() dto: SendEmail) { @@ -48,8 +48,7 @@ export class NotificationsController { } @Post('send/sms') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) @ApiBody({ type: SingleMessageDto }) sendSms(@Body() dto: SingleMessageDto) { @@ -57,8 +56,7 @@ export class NotificationsController { } @Post('send/sms/bulk') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' }) @ApiBody({ type: BulkMessagesDto }) sendBulkSms(@Body() dto: BulkMessagesDto) { @@ -66,8 +64,6 @@ export class NotificationsController { } @Post('test') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') @ApiOperation({ summary: 'Test notification delivery (Admin only)' }) async testNotification(@Body() dto: TestNotificationDto) { return this.service.send( diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 0c6add4b2..db09ab402 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; @@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); @@ -14,6 +18,7 @@ export class NotificationsService { constructor( private prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private emailClient: EmailClientService, private smsClient: SmsClientService, private pushAdapter: PushAdapter, @@ -112,22 +117,20 @@ export class NotificationsService { body: string, context: Record, ): Promise { - // Try to find passenger by ID or email let passengerId = recipient; - if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { - const user = await this.prisma.user.findFirst({ - where: { - OR: [{ email: recipient }, { phone: recipient }], - }, - include: { passenger: true }, - }); - if (user?.passenger) { - passengerId = user.passenger.id; - } else { + if (!UUID_RE.test(recipient)) { + const iamUserId = await this.resolveIamUserId(recipient); + if (!iamUserId) { this.logger.warn(`Could not find passenger for recipient: ${recipient}`); return; } + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } }); + if (!passenger) { + this.logger.warn(`Could not find passenger for recipient: ${recipient}`); + return; + } + passengerId = passenger.id; } await this.prisma.notification.create({ @@ -163,26 +166,19 @@ export class NotificationsService { } private async getUserPreferredChannels(recipient: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - OR: [ - { id: recipient }, - { email: recipient }, - { phone: recipient }, - { passenger: { id: recipient } }, - ], - }, - include: { preferences: true }, - }); + const iamUserId = await this.resolveIamUserId(recipient); + const preferences = iamUserId + ? await this.prisma.userPreferences.findUnique({ where: { iamUserId } }) + : null; - if (!user?.preferences) { + if (!preferences) { return ['IN_APP', 'EMAIL']; } const channels: NotificationChannelType[] = ['IN_APP']; - if (user.preferences.emailEnabled) channels.push('EMAIL'); - if (user.preferences.smsEnabled) channels.push('SMS'); - if (user.preferences.pushEnabled) channels.push('PUSH'); + if (preferences.emailEnabled) channels.push('EMAIL'); + if (preferences.smsEnabled) channels.push('SMS'); + if (preferences.pushEnabled) channels.push('PUSH'); return channels; } @@ -191,32 +187,44 @@ export class NotificationsService { recipient: string, channel: NotificationChannelType, ): Promise { - const user = await this.prisma.user.findFirst({ - where: { - OR: [ - { id: recipient }, - { email: recipient }, - { phone: recipient }, - { passenger: { id: recipient } }, - ], - }, - }); - - if (!user) return null; + const iamUserId = await this.resolveIamUserId(recipient); + if (!iamUserId) return null; + const contact = await this.resolveContactInfo(iamUserId); switch (channel) { - case 'EMAIL': - return user.email; - case 'SMS': - return user.phone; - case 'PUSH': - // Would need to fetch device push token - return user.id; - default: - return null; + case 'EMAIL': return contact.email; + case 'SMS': return contact.phone; + case 'PUSH': return iamUserId; + default: return null; } } + private async resolveIamUserId(recipient: string): Promise { + if (UUID_RE.test(recipient)) { + const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } }); + return passenger?.iamUserId ?? recipient; + } + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`, + [recipient], + ); + return rows[0]?.id ?? null; + } + + private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> { + const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>( + `SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null }; + } + + private sanitize(value: string): string { + return value + .replace(/[\r\n]/g, ' ') + .replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c)); + } + getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 24aadbd4c..a03750965 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -3,7 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@ne import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard } from '../../common/iam-adapter'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { PrismaService } from '../../common/prisma.service'; @@ -53,25 +52,17 @@ export class PassengersController { }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) async getMe(@Request() req: any) { - if (!req.user || !req.user.userId) { + if (!req.user || !req.user.id) { throw new UnauthorizedException('User not authenticated'); } try { - const user = await this.prisma.user.findUnique({ - where: { id: req.user.userId }, - include: { - passenger: true, - }, + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: req.user.id }, }); - - if (!user || !user.passenger) { - return null; - } - - return this.service.getProfile(user.passenger.id); + if (!passenger) return null; + return this.service.getProfile(passenger.id); } catch (error) { - // If profile lookup fails for any reason, return null to allow app to continue return null; } } @@ -251,7 +242,7 @@ The API automatically detects: description: 'Invalid JWT token (only if token provided but invalid)' }) registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) { - const userId = req.user?.userId; + const userId = req.user?.id; return this.service.registerPassenger({ ...dto, userId }); } 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 7171b56ad..a9f6c8f77 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { VerifaydaService } from '../verifayda/verifayda.service'; @@ -10,37 +12,68 @@ interface PassengerFilters { pageSize?: number; } +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; + @Injectable() export class PassengersService { constructor( - private prisma: PrismaService, - private verifaydaService: VerifaydaService, + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) {} async findAll(filters: PassengerFilters = {}) { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - - const where: any = { user: { role: 'PASSENGER' } }; - - if (search) { - where.user = { - ...where.user, - OR: [ - { fullName: { contains: search, mode: 'insensitive' } }, - { email: { contains: search, mode: 'insensitive' } }, - { phone: { contains: search, mode: 'insensitive' } }, - ], - }; + + let iamUserIdFilter: string[] | null = null; + + if (search || verified !== undefined) { + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (search) { + conditions.push(`( + u.email ILIKE $${idx} OR + u.phone_number ILIKE $${idx} OR + (u.name->>'en') ILIKE $${idx} OR + (u.name->>'am') ILIKE $${idx} + )`); + params.push(`%${search}%`); + idx++; + } + + if (verified !== undefined) { + if (verified) { + conditions.push(`u.metadata->>'faydaVerified' = 'true'`); + } else { + conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`); + } + } + + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`, + params, + ); + iamUserIdFilter = rows.map(r => r.id); + + if (iamUserIdFilter.length === 0) { + return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; + } } - - if (verified !== undefined) { - where.user = { - ...where.user, - nationalId: verified ? { not: null } : null, - }; + + const where: any = {}; + if (iamUserIdFilter) { + where.iamUserId = { in: iamUserIdFilter }; } - + const [items, total] = await Promise.all([ this.prisma.passenger.findMany({ where, @@ -48,42 +81,36 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - user: true, loyalty: true, - wallet: true, - _count: { - select: { - bookings: true, - }, - }, + _count: { select: { bookings: true } }, }, }), this.prisma.passenger.count({ where }), ]); - + + const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { items: items.map(passenger => { - const user = passenger.user as any; + const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; return { id: passenger.id, - userId: passenger.userId, - fullName: user.fullName, - email: user.email, - phone: user.phone?.startsWith('+guest-') ? null : user.phone, - nationalId: user.nationalId, - nationality: user.nationality, - dateOfBirth: user.dateOfBirth ?? null, - gender: user.gender ?? null, - passportNumber: user.passportNumber, - passportCountry: user.passportCountry ?? null, - verified: !!user.nationalId, + fullName: iam?.name?.en ?? iam?.name?.am ?? null, + email: iam?.email ?? null, + phone: iam?.phone_number ?? null, + verified: faydaVerified, loyaltyTier: passenger.loyalty?.tier || 'BRONZE', loyaltyPoints: passenger.loyalty?.pointsBalance || 0, totalBookings: passenger._count.bookings, createdAt: passenger.createdAt, - updatedAt: user.updatedAt, - loyalty: passenger.loyalty, - wallet: passenger.wallet, }; }), meta: { @@ -99,33 +126,42 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { - user: true, - bookings: { - orderBy: { createdAt: 'desc' }, - take: 10, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } } - } + bookings: { + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + }, }, - loyalty: true, - wallet: true, - travelerProfiles: true, + loyalty: true, + wallet: true, + travelerProfiles: true, savedRoutes: true, }, }); if (!passenger) throw new NotFoundException('Passenger not found'); + + let iamUser: IamUserRow | null = null; + if (passenger.iamUserId) { + const rows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ); + iamUser = rows[0] ?? null; + } + return { id: passenger.id, - fullName: passenger.user.fullName, - email: passenger.user.email, - phone: passenger.user.phone, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? null, createdAt: passenger.createdAt, bookings: passenger.bookings.map((b) => ({ - id: b.id, - bookingRef: b.bookingRef, - status: b.status, - totalFare: b.totalMinor / 100, + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalFare: b.totalMinor / 100, createdAt: b.createdAt, trip: { number: b.schedule.train.number, @@ -143,13 +179,9 @@ export class PassengersService { }, departureAt: b.schedule.departureAt, }, - passengers: b.seats.map((bs) => ({ - fullName: bs.passengerName, - seat: { - number: bs.seat.seatNumber, - coach: bs.seat.coach.number, - class: 'N/A' - } + passengers: b.seats.map((bs) => ({ + fullName: bs.passengerName, + seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' }, })), })), }; @@ -157,11 +189,11 @@ export class PassengersService { async getStats(passengerId: string) { const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ - this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }), - this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }), + this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }), + this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }), this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }), ]); - const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100; + const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100; return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } @@ -229,23 +261,53 @@ export class PassengersService { async updatePassenger(id: string, dto: any) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - return this.prisma.passenger.update({ - where: { id }, - data: { - user: { - update: { - fullName: dto.fullName || undefined, - email: dto.email || undefined, - phone: dto.phone || undefined, - nationality: dto.nationality || undefined, - }, - }, - }, - include: { - user: true, - loyalty: true, - }, - }); + + if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) { + const updates: string[] = []; + const params: any[] = []; + let idx = 1; + + if (dto.fullName) { + updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`); + params.push(dto.fullName); + idx++; + } + if (dto.email) { + updates.push(`email = $${idx}`); + params.push(dto.email); + idx++; + } + if (dto.phone) { + updates.push(`phone_number = $${idx}`); + params.push(dto.phone); + idx++; + } + + params.push(passenger.iamUserId); + await this.dataSource.query( + `UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`, + params, + ); + } + + const [updated, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }), + passenger.iamUserId + ? this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ) + : Promise.resolve([] as IamUserRow[]), + ]); + + const iamUser = iamRows[0] ?? null; + return { + id: updated!.id, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? null, + loyalty: updated!.loyalty, + }; } async registerPassenger(dto: RegisterPassengerDto) { @@ -274,31 +336,16 @@ export class PassengersService { }; if (isLoggedIn) { - const user = await this.prisma.user.findUnique({ - where: { id: dto.userId }, - include: { passenger: true }, + const linkedPassenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: dto.userId }, }); - if (!user) { - throw new BadRequestException('User not found'); - } - - if (!user.faydaVerified && verifiedData) { - await this.prisma.user.update({ - where: { id: dto.userId }, - data: { - fullName: finalData.passengerName, - nationality: finalData.nationality, - nationalId: dto.nationalId, - passportNumber: dto.passportNumber, - faydaVerified: !!verifiedData, - faydaVerifiedAt: verifiedData ? new Date() : null, - }, - }); + if (!linkedPassenger) { + throw new BadRequestException('Passenger not found'); } return { - id: user.passenger?.id || user.id, + id: linkedPassenger.id, passengerName: finalData.passengerName, dateOfBirth: finalData.dateOfBirth, nationality: finalData.nationality, @@ -336,7 +383,9 @@ export class PassengersService { async deletePassenger(id: string) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - return this.prisma.passenger.delete({ where: { id } }); + + await this.prisma.passenger.delete({ where: { id } }); + return { deleted: true, passengerId: id }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index a64c4ae9c..0bf09e41d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -17,6 +17,7 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; +import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; import { @@ -28,10 +29,8 @@ import { PaymentMethodTypeEnum, PaymentPlatformDto, } from "./payments.dto"; -import { JwtGuard } from "../../common/jwt.guard"; -import { RolesGuard } from "../../common/roles.guard"; -import { Roles } from "../../common/roles.decorator"; -import { UserRole } from "@prisma/client"; +import { PassengerStaff } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Payment") @Controller("payments") @@ -39,9 +38,8 @@ export class PaymentsController { constructor(private service: PaymentsService) {} @Get("all") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @@ -65,6 +63,7 @@ export class PaymentsController { } @Post("initiate") + @IsPublic() @ApiOperation({ summary: "Initiate payment with nationality-based payment methods", description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, @@ -74,12 +73,14 @@ export class PaymentsController { } @Get("intents/:bookingId") + @IsPublic() @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Get("waafi/return") + @IsPublic() @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -102,18 +103,16 @@ export class PaymentsController { } @Post("refund") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } @Post("methods") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.STAFF) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Add a payment system to the platform catalog (admin only)", }) @@ -122,6 +121,7 @@ export class PaymentsController { } @Get("methods") + @IsPublic() @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -134,6 +134,7 @@ export class PaymentsController { } @Get("checkout") + @IsPublic() @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 0fd594b39..5393696bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -23,19 +23,7 @@ describe("Payments E2E", () => { prisma = app.get(PrismaService); - const testUser = await prisma.user.create({ - data: { - email: "payment-test@example.com", - phone: "+251911111112", - fullName: "Payment Test User", - passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz", - role: "PASSENGER", - }, - }); - - const passenger = await prisma.passenger.create({ - data: { userId: testUser.id }, - }); + const passenger = await prisma.passenger.create({ data: { iamUserId: 'test-iam-payments-user' } }); await prisma.walletAccount.create({ data: { @@ -151,7 +139,6 @@ describe("Payments E2E", () => { prisma.walletLedgerEntry.deleteMany(), prisma.walletAccount.deleteMany(), prisma.passenger.deleteMany(), - prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }), ]); await app.close(); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 13569ffc4..d9fa4efd7 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -447,7 +447,7 @@ export class PaymentsService { include: { seats: true }, }); if (booking) { - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: "CANCELLED" }, @@ -746,51 +746,125 @@ export class PaymentsService { private async createJourneySegments( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ) { - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: booking.scheduleId }, - include: { - stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, - }, - }); - if (!schedule) return; + const b = booking as any; - const stopTimes = schedule.stopTimes; - if (stopTimes.length < 2) return; + // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] } + // BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2 + type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] }; + const legDefs: LegDef[] = []; - const originSequence = stopTimes.findIndex( - (st) => st.stationId === schedule.originStationId, - ); - const destSequence = stopTimes.findIndex( - (st) => st.stationId === schedule.destinationStationId, - ); + const seatsForLeg = (legNum: number) => + booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId); - if ( - originSequence < 0 || - destSequence < 0 || - originSequence >= destSequence - ) - return; + if (booking.bookingType === 'ONE_WAY') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.destinationStationId, + seatIds: booking.seats.map((s: any) => s.seatId), + }); + } else if (booking.bookingType === 'ROUND_TRIP') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.destinationStationId, + seatIds: seatsForLeg(1), + }); + if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + legDefs.push({ + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, + destinationStationId: b.returnDestinationStationId, + seatIds: seatsForLeg(2), + }); + } + } else if (booking.bookingType === 'TRANSIT') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.leg2OriginStationId, // transit station + seatIds: seatsForLeg(1), + }); + if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + legDefs.push({ + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, + destinationStationId: b.leg2DestinationStationId, + seatIds: seatsForLeg(2), + }); + } + } else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.leg2OriginStationId, + seatIds: seatsForLeg(1), + }); + if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + legDefs.push({ + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, + destinationStationId: b.leg2DestinationStationId, + seatIds: seatsForLeg(2), + }); + } + if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + legDefs.push({ + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, + destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId, + seatIds: seatsForLeg(3), + }); + } + if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) { + legDefs.push({ + scheduleId: b.returnLeg2ScheduleId, + originStationId: b.returnLeg2OriginStationId, + destinationStationId: b.returnLeg2DestStationId, + seatIds: seatsForLeg(4), + }); + } + } + + if (legDefs.length === 0) return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - status: "CONFIRMED", - totalMinor: booking.totalMinor, - currency: booking.currency, + bookingId: booking.id, + status: 'CONFIRMED', + totalMinor: booking.totalMinor, + currency: booking.currency, }, }); - const journeySegments = []; - for (const bookingSeat of booking.seats) { - for (let i = originSequence; i < destSequence; i++) { - journeySegments.push({ - journeyId: journey.id, - scheduleId: booking.scheduleId, - segmentOrder: i, - seatId: bookingSeat.seatId, - departureStationId: stopTimes[i].stationId, - arrivalStationId: stopTimes[i + 1].stationId, - }); + const journeySegments: any[] = []; + let segmentOrder = 0; + + for (const leg of legDefs) { + if (leg.seatIds.length === 0) continue; + + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: leg.scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true, sequence: true }, + }); + + const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId); + const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId); + if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue; + + for (const seatId of leg.seatIds) { + for (let i = originIdx; i < destIdx; i++) { + journeySegments.push({ + journeyId: journey.id, + scheduleId: leg.scheduleId, + segmentOrder: segmentOrder++, + seatId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } } } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 772a1428c..0c0d02ea4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -1,33 +1,30 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; import { GenerateReportDto } from './reports.dto'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Reports') @Controller('reports') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class ReportsController { constructor(private service: ReportsService) {} @Post('generate') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Generate operational report' }) generateReport(@Body() dto: GenerateReportDto) { return this.service.generateReport(dto); } @Get(':reportId') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Get report by ID' }) getReport(@Param('reportId') reportId: string) { return this.service.getReport(reportId); } @Get() - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'List reports' }) listReports(@Query('type') type?: string) { return this.service.listReports(type); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index d1f25dde7..5c1b5e582 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,10 +1,15 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { GenerateReportDto, ReportType } from './reports.dto'; @Injectable() export class ReportsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); @@ -113,13 +118,25 @@ export class ReportsService { ...(agentId ? { agentId } : {}) }, include: { - agent: { include: { user: true } }, + agent: { select: { id: true, iamUserId: true, agentCode: true } }, booking: true } }); + const iamUserIds = [...new Set( + agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[] + )]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>( + `SELECT id, name FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + const byAgent = agentBookings.reduce((acc, ab) => { - const agentName = ab.agent.user.fullName; + const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined; + const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode; if (!acc[agentName]) { acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 378bc8fa7..ac55bc14b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -23,6 +24,7 @@ export class SchedulesController { createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @Get() + @IsPublic() @ApiOperation({ summary: 'List schedules with optional filters' }) @ApiQuery({ name: 'date', required: false }) @ApiQuery({ name: 'routeId', required: false }) @@ -67,6 +69,7 @@ export class SchedulesController { createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } @Get('routes/:routeId/segment-fares') + @IsPublic() @ApiOperation({ summary: 'List all segment fare rules for a route' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @@ -86,6 +89,7 @@ export class SchedulesController { // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== @Get(':id') + @IsPublic() @ApiOperation({ summary: 'Get schedule detail' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @@ -113,6 +117,7 @@ export class SchedulesController { deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @Get(':id/stops') + @IsPublic() @ApiOperation({ summary: 'List all stops for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) getStops(@Param('id') id: string) { return this.service.getStops(id); } diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index b32f2f291..6bb9d1960 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; @ApiTags('Search') @Controller('search') +@IsPublic() export class SearchController { constructor(private service: SearchService) {} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9087ee7ac..4797c6c19 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -39,6 +39,23 @@ export class SearchService { const outbound = [...direct, ...transit]; + if (outbound.length === 0) { + const alternativesOutbound = await this.searchAlternatives( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + return { + journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', + outbound: [], + alternativeOutbound: alternativesOutbound, + requestedDate: dto.date, + }; + } + if (dto.journeyType === 'ROUND_TRIP') { const [returnDirect, returnTransit] = await Promise.all([ this.searchSchedules( @@ -68,12 +85,85 @@ export class SearchService { new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); + if (inbound.length === 0) { + const alternativeInbound = await this.searchAlternatives( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; + } + return { journeyType: 'ROUND_TRIP', outbound, inbound }; } return { journeyType: 'ONE_WAY', outbound }; } + private async searchAlternatives( + originStationId: string, + destinationStationId: string, + dateStr: string, + adultCount: number, + childCount?: number, + nationality?: string, + ) { + const [y, m, d] = dateStr.split('-').map(Number); + const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); + + const now = new Date(); + const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); + const daysAfter = 14 - daysBefore; + + const windowStart = new Date(requestedDate); + windowStart.setDate(windowStart.getDate() - daysBefore); + if (windowStart < now) windowStart.setTime(now.getTime()); + + const windowEnd = new Date(requestedDate); + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + + const totalPassengers = adultCount + (childCount ?? 0); + + const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + OR: [ + { departureAt: { gte: windowStart, lt: requestedDate } }, + { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, + ], + stopTimes: { some: { stationId: originStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + orderBy: { departureAt: 'asc' }, + }); + + const results: any[] = []; + for (const schedule of schedules) { + const result = await this.buildScheduleResult( + schedule, + originStationId, + destinationStationId, + totalPassengers, + nationality, + ); + if (result) results.push(result); + } + return results; + } + private async searchSchedules( originStationId: string, destinationStationId: string, @@ -85,12 +175,13 @@ export class SearchService { const [y, m, d] = dateStr.split('-').map(Number); const date = new Date(y, m - 1, d, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: date, lt: nextDay }, + departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, include: { diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 0ac25272d..86eb287a4 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SeatClassesService } from './seat-classes.service'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,11 +11,13 @@ export class SeatClassesController { constructor(private service: SeatClassesService) {} @Get() + @IsPublic() @ApiOperation({ summary: 'List all seat classes' }) @ApiResponse({ status: 200, description: 'Returns all seat classes with their coaches' }) listSeatClasses() { return this.service.listSeatClasses(); } @Get(':id') + @IsPublic() @ApiOperation({ summary: 'Get a seat class by ID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiResponse({ status: 200, description: 'Returns seat class with its coaches' }) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 8914ca465..1347bd4cc 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SeatsService } from './seats.service'; import { HoldSeatsDto } from './seats.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -12,7 +13,8 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get('seatmap/:scheduleId') - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'Get seat map with real-time availability by class', description: `Returns seat map for a schedule with availability by seat class: - Economy Regular 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], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 21060b595..0dbc1c653 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -11,7 +11,7 @@ export class SeatsService { private segmentsService: SegmentsService, ) {} - async getSeatMap(scheduleId: string, coachId?: string) { + async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) { const assignments = await this.prisma.coachAssignment.findMany({ where: { scheduleId, ...(coachId ? { coachId } : {}) }, include: { @@ -25,16 +25,14 @@ export class SeatsService { orderBy: { positionNumber: 'asc' }, }); - console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`); - const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds); + const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId); - const response = { + return { coaches: assignments.map((a) => { const allSeats = a.coach.seats; const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); - + return { id: a.coach.id, assignmentId: a.id, @@ -68,43 +66,95 @@ export class SeatsService { }; }), }; - - console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`); - return response; } async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], + originStationId?: string, + destinationStationId?: string, ): Promise> { const statusMap = new Map(); - if (seatIds.length === 0) return statusMap; + // Resolve the requested leg's sequence range once + let reqFrom: number | undefined; + let reqTo: number | undefined; + let allStopTimes: { stationId: string; sequence: number }[] | null = null; + + const getStopTimes = async () => { + if (!allStopTimes) { + allStopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + } + return allStopTimes; + }; + + if (originStationId && destinationStationId) { + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + reqFrom = seqOf(originStationId); + reqTo = seqOf(destinationStationId); + } + + // ── Active holds ────────────────────────────────────────────────────────── const activeHolds = await this.prisma.seatHold.findMany({ - where: { - scheduleId, - expiresAt: { gt: new Date() }, - seatIds: { hasSome: seatIds }, - }, - select: { seatIds: true }, + where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } }, + select: { seatIds: true, createdBy: true }, }); + for (const hold of activeHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy?.trimStart().startsWith('{')) { + const meta = JSON.parse(hold.createdBy); + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + for (const seatId of hold.seatIds) { - if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD'); + if (!seatIds.includes(seatId)) continue; + if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) { + if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD'); + } else { + statusMap.set(seatId, 'HELD'); + } } } + // ── Confirmed bookings via JourneySegment ───────────────────────────────── const bookedSegments = await this.prisma.journeySegment.findMany({ where: { scheduleId, seatId: { in: seatIds }, journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, }, - select: { seatId: true }, + select: { seatId: true, departureStationId: true, arrivalStationId: true }, }); - for (const seg of bookedSegments) { - if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + + if (reqFrom !== undefined && reqTo !== undefined) { + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + for (const seg of bookedSegments) { + if (!seg.seatId) continue; + const segFrom = seqOf(seg.departureStationId); + const segTo = seqOf(seg.arrivalStationId); + if (segFrom !== undefined && segTo !== undefined) { + if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED'); + } else { + statusMap.set(seg.seatId, 'BOOKED'); + } + } + } else { + for (const seg of bookedSegments) { + if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + } } return statusMap; @@ -154,6 +204,7 @@ export class SeatsService { if (reqFrom >= reqTo) throw new BadRequestException('Origin must come before destination'); + // ── Check existing holds for overlap ──────────────────────────────────── const activeHolds = await tx.seatHold.findMany({ where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, select: { seatIds: true, createdBy: true }, @@ -197,6 +248,29 @@ export class SeatsService { } } + // ── Check confirmed JourneySegments for overlap ────────────────────────── + const bookedSegments = await tx.journeySegment.findMany({ + where: { + scheduleId: dto.scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, departureStationId: true, arrivalStationId: true }, + }); + + for (const seg of bookedSegments) { + if (!seg.seatId) continue; + const segFrom = seqOf(seg.departureStationId); + const segTo = seqOf(seg.arrivalStationId); + if (segFrom !== undefined && segTo !== undefined) { + if (segFrom < reqTo && reqFrom < segTo) { + throw new ConflictException( + `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, + ); + } + } + } + const holdMeta = { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, @@ -347,16 +421,12 @@ export class SeatsService { return { released: true, holdId }; } - async confirmSeats(seatIds: string[]) { - // No-op - } + // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy. + async confirmSeats(_seatIds: string[]) {} - async releaseSeats(seatIds: string[]) { - if (seatIds.length > 0) { - await this.prisma.journeySegment.deleteMany({ - where: { seatId: { in: seatIds } }, - }); - } + // Delete the Journey (and its JourneySegments) scoped to this booking. + async releaseSeats(bookingId: string) { + await this.prisma.journey.deleteMany({ where: { bookingId } }); } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { @@ -424,7 +494,7 @@ export class SeatsService { invalid++; continue; } - const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; + const [coachId, , row, col, seatNumber] = parts; if (!coachId || !row || !col || !seatNumber) { errors.push(`Line ${i + 2}: Missing required fields`); invalid++; @@ -448,7 +518,7 @@ export class SeatsService { for (let i = 0; i < lines.length; i++) { try { const parts = lines[i].split(','); - const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; + const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts; await this.prisma.seat.upsert({ where: { coachId_row_col: { coachId, row: parseInt(row), col } }, @@ -481,18 +551,8 @@ export class SeatsService { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'BLOCKED' }, - }); - - await this.prisma.seatBlock.create({ - data: { - seatId, - reason, - blockedBy: 'system', - }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); + await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); return { blocked: true, seatId, reason }; } @@ -501,14 +561,8 @@ export class SeatsService { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'AVAILABLE' }, - }); - - await this.prisma.seatBlock.deleteMany({ - where: { seatId }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId } }); return { unblocked: true, seatId }; } @@ -518,11 +572,9 @@ export class SeatsService { if (!seat) throw new NotFoundException('Seat not found'); if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); - // Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space - const negatedNumber = `-${seat.seatNumber}`; await this.prisma.seat.update({ where: { id: seatId }, - data: { seatNumber: negatedNumber }, + data: { seatNumber: `-${seat.seatNumber}` }, }); return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; @@ -535,26 +587,15 @@ export class SeatsService { throw new BadRequestException('Seat is not removed'); } - // Restore original seatNumber by removing the negative sign const originalNumber = seat.seatNumber.slice(1); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { seatNumber: originalNumber }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); return { restored: true, seatId, seatNumber: originalNumber }; } @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - const now = new Date(); - const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } }); - if (expired.length === 0) return; - - const expiredIds = expired.map(h => h.id); - for (const hold of expired) { - await this.releaseSeats(hold.seatIds); - } - await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } }); + // Holds are temporary and don't create Journey rows — just delete expired ones. + await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 9c864c307..a89ecd87d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,7 +11,8 @@ export class StationsController { constructor(private service: StationsService) {} @Get() - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'List all stations with country information', description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)' }) @@ -48,7 +50,8 @@ export class StationsController { } @Get(':id') - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'Get station details by ID', description: 'Returns station information including name, code, country, coordinates, and facilities' }) diff --git a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts index fc350dbcf..57e78caf6 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts @@ -7,8 +7,8 @@ export class CreateStationDto { @ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string; @ApiPropertyOptional() @IsOptional() @IsString() timezone?: string; @ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string; - @ApiProperty({ example: 9.0054 }) @IsNumber() lat: number; - @ApiProperty({ example: 38.7636 }) @IsNumber() lng: number; + @ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number; + @ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number; @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean; } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 5dd1aa77d..bc1faa1bc 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -50,7 +50,10 @@ export class StationsService { } async create(dto: CreateStationDto) { - const station = await this.prisma.station.create({ data: dto }); + const { lat, lng, ...rest } = dto; + const station = await this.prisma.station.create({ + data: { ...rest, ...(lat !== undefined && { lat }), ...(lng !== undefined && { lng }) } as any, + }); await this.auditService.log({ userId: this.request?.user?.id, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 63b675157..7bbe580fc 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import * as QRCode from 'qrcode'; @@ -12,7 +14,10 @@ interface OfflineValidation { @Injectable() export class TicketsService { - constructor(private prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { const where: any = {}; @@ -38,47 +43,68 @@ export class TicketsService { end.setDate(end.getDate() + 1); where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } - const tickets = await this.prisma.ticket.findMany({ - where, - include: { - booking: { - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, - passenger: { include: { user: true } }, + const [tickets, total] = await Promise.all([ + this.prisma.ticket.findMany({ + where, + include: { + booking: { + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { select: { id: true, iamUserId: true } }, + }, }, }, - }, - skip: filters.skip, - take: filters.take, - orderBy: { issuedAt: 'desc' }, - }); - const total = await this.prisma.ticket.count({ where }); + skip: filters.skip, + take: filters.take, + orderBy: { issuedAt: 'desc' }, + }), + this.prisma.ticket.count({ where }), + ]); + + const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { - items: tickets.map((t) => ({ - id: t.id, - ticketNumber: t.barcodePayload, - bookingRef: t.bookingRef, - booking: { - bookingRef: t.booking.bookingRef, - status: t.booking.status, - bookingType: t.booking.bookingType, - returnLegStatus: (t.booking as any).returnLegStatus ?? null, - outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, - returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, - totalMinor: t.booking.totalMinor, - currency: t.booking.currency, - displayCurrency: t.booking.displayCurrency, - displayTotalMinor: t.booking.displayTotalMinor, - passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, - contactEmail: t.booking.contactEmail, - }, - schedule: t.booking.schedule, - seat: t.booking.seats[0]?.seat, - status: t.status, - validatedAt: t.validatedAt, - createdAt: t.issuedAt, - })), + items: tickets.map((t) => { + const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined; + const passengerInfo = iam + ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } + : { fullName: 'Guest', email: t.booking.contactEmail, phone: null }; + return { + id: t.id, + ticketNumber: t.barcodePayload, + bookingRef: t.bookingRef, + booking: { + bookingRef: t.booking.bookingRef, + status: t.booking.status, + bookingType: t.booking.bookingType, + returnLegStatus: (t.booking as any).returnLegStatus ?? null, + outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, + totalMinor: t.booking.totalMinor, + currency: t.booking.currency, + displayCurrency: t.booking.displayCurrency, + displayTotalMinor: t.booking.displayTotalMinor, + passenger: passengerInfo, + contactEmail: t.booking.contactEmail, + contactPhone: t.booking.contactPhone, + returnSchedule: (t.booking as any).returnSchedule ?? null, + }, + schedule: t.booking.schedule, + seat: t.booking.seats[0]?.seat, + status: t.status, + validatedAt: t.validatedAt, + createdAt: t.issuedAt, + }; + }), total, skip: filters.skip, take: filters.take, @@ -115,7 +141,7 @@ export class TicketsService { legs: legSummary, }); const qrPayload = await QRCode.toDataURL(qrData); - const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; + const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const ticket = await this.prisma.ticket.upsert({ where: { bookingId }, @@ -387,8 +413,8 @@ export class TicketsService { where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { ticket: true, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, - passenger: { include: { user: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + passenger: { select: { id: true, iamUserId: true } }, }, }); diff --git a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts index 5f5fac19b..8673aa60e 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts @@ -1,21 +1,30 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; /** - * Like {@link JwtGuard}, but never rejects the request. + * Like the IAM JwtGuard, but never rejects the request. * - * When a valid `Authorization: Bearer ` is present, `request.user` is - * populated from the JWT strategy (`{ userId, ... }`). When the token is - * missing or invalid, the request still proceeds with `request.user` - * undefined — the handler decides what to do. - * - * Used on `POST /fayda/verification/start`, which must work for both - * logged-in users (who can opt to save the verification to their account) - * and guests (anchored to a booking only). + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. */ @Injectable() -export class OptionalJwtGuard extends AuthGuard('jwt') { - handleRequest(_err: unknown, user: TUser): TUser { - return (user ?? null) as TUser; +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index f1eb25e8e..059c6ec1f 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -15,6 +15,7 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; import { @@ -25,21 +26,13 @@ import { } from './verifayda.dto'; import { VerifaydaService } from './verifayda.service'; -/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */ -interface AuthedUser { - userId: string; - email?: string; - role?: string; - passengerId?: string; -} - /** Minimal slices of the Express req we touch (avoids a hard dependency on * `@types/express`, which isn't resolved in this package). */ interface RequestWithOptionalUser { - user?: AuthedUser; + user?: TCurrentUser; } interface RequestWithUser { - user: AuthedUser; + user: TCurrentUser; } @ApiTags('Fayda Verification') @@ -55,8 +48,9 @@ export class VerifaydaController { summary: 'Start a VeriFayda 2.0 verification session', description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. -- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success. -- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified. +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. - The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, }) @ApiOkResponse({ @@ -73,11 +67,9 @@ export class VerifaydaController { @Req() req: RequestWithOptionalUser, ): Promise<{ authorizationUrl: string }> { const authorizationUrl = await this.service.startVerification({ - purpose: dto.purpose ?? 'PURCHASE', + purpose: dto.purpose ?? 'VERIFY', platform: dto.platform ?? 'WEB', - userId: req.user?.userId, - bookingId: dto.bookingId, - saveToAccount: dto.saveToAccount, + userId: req.user?.id, }); return { authorizationUrl }; } @@ -106,6 +98,6 @@ export class VerifaydaController { async status( @Req() req: RequestWithUser, ): Promise { - return this.service.getVerificationStatus(req.user.userId); + return this.service.getVerificationStatus(req.user.id); } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 005a3e517..f446b6fb3 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -1,31 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsOptional, IsString } from 'class-validator'; export class StartVerificationDto { @ApiPropertyOptional({ - enum: ['LOGIN', 'PURCHASE'], - default: 'PURCHASE', - description: 'Reason for verification.', - }) - @IsOptional() - @IsIn(['LOGIN', 'PURCHASE']) - purpose?: 'LOGIN' | 'PURCHASE'; - - @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', description: - 'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.', + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', }) @IsOptional() - @IsString() - bookingId?: string; - - @ApiPropertyOptional({ - description: - 'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.', - }) - @IsOptional() - @IsBoolean() - saveToAccount?: boolean; + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ enum: ['WEB', 'MOBILE'], @@ -39,8 +24,8 @@ export class StartVerificationDto { } export class CompleteVerificationResultDto { - @ApiProperty({ enum: ['LOGIN', 'PURCHASE'] }) - purpose: 'LOGIN' | 'PURCHASE'; + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose: 'LOGIN' | 'VERIFY'; @ApiProperty() verified: boolean; @@ -58,10 +43,22 @@ export class CompleteVerificationResultDto { agentId?: string; }; - @ApiPropertyOptional({ - description: 'Verified full name from Fayda (PURCHASE flow).', - }) + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; } export class VerifaydaCallbackDto { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index d850b1dbf..e54b94726 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -2,12 +2,9 @@ import { Module } from '@nestjs/common'; import { VerifaydaController } from './verifayda.controller'; import { VerifaydaService } from './verifayda.service'; import { PrismaModule } from '../../common/prisma.module'; -import { AuthModule } from '../auth/auth.module'; @Module({ - // AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry - // config as /auth/login) to mint tokens for the LOGIN flow. - imports: [PrismaModule, AuthModule], + imports: [PrismaModule], controllers: [VerifaydaController], providers: [VerifaydaService], exports: [VerifaydaService], diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts index e4b8cb790..ede9aa9e8 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -1,5 +1,4 @@ import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; import { exportJWK, generateKeyPair, type JWK } from 'jose'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig } from '../../config/fayda.config'; @@ -16,12 +15,6 @@ function buildPrismaMock() { bookingSeat: { updateMany: jest.fn(), }, - user: { - findUnique: jest.fn(), - findFirst: jest.fn(), - create: jest.fn(), - update: jest.fn(), - }, passenger: { create: jest.fn() }, loyaltyAccount: { create: jest.fn() }, walletAccount: { create: jest.fn() }, @@ -30,10 +23,8 @@ function buildPrismaMock() { }; } -function buildJwtMock(): jest.Mocked { - return { - sign: jest.fn(() => 'signed.jwt.token'), - } as unknown as jest.Mocked; +function buildDataSourceMock() { + return { query: jest.fn().mockResolvedValue([]) }; } function buildConfig(overrides?: Partial): FaydaConfig { @@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked { let prisma: ReturnType; - let jwt: jest.Mocked; + let dataSource: ReturnType; let service: VerifaydaService; let realPrivateJwk: JWK; @@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { beforeEach(() => { prisma = buildPrismaMock(); - jwt = buildJwtMock(); + dataSource = buildDataSourceMock(); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); service = new VerifaydaService( buildConfigService(cfg), prisma as unknown as PrismaService, - jwt, + dataSource as any, ); (global as any).fetch = jest.fn(); }); @@ -96,13 +87,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { prisma.faydaVerificationSession.create.mockResolvedValue({}); const url = await service.startVerification({ - purpose: 'PURCHASE', + purpose: 'VERIFY', userId: 'user-1', - saveToAccount: true, }); const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; - expect(created.purpose).toBe('PURCHASE'); + expect(created.purpose).toBe('VERIFY'); expect(created.platform).toBe('WEB'); expect(typeof created.state).toBe('string'); expect(typeof created.codeVerifier).toBe('string'); @@ -136,10 +126,10 @@ describe('VerifaydaService (OIDC, client-callback)', () => { const disabledService = new VerifaydaService( buildConfigService(buildConfig({ enabled: false })), prisma as unknown as PrismaService, - jwt, + buildDataSourceMock() as any, ); await expect( - disabledService.startVerification({ purpose: 'PURCHASE' }), + disabledService.startVerification({ purpose: 'VERIFY' }), ).rejects.toMatchObject({ status: 503 }); }); }); @@ -150,14 +140,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', errorCode: null, errorDescription: null, - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -209,18 +197,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - describe('completeVerification — PURCHASE', () => { + describe('completeVerification — VERIFY', () => { function pendingSession(overrides: Partial = {}) { return { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -238,19 +224,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - it('stamps the booking seats and returns { verified, fullName }', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-1' }), - ); + it('returns the verified identity attributes and writes no domain rows', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); prisma.faydaVerificationSession.update.mockResolvedValue({}); - prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, { headers: new Headers({ 'content-type': 'application/json' }), text: async () => - JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }), + JSON.stringify({ + sub: 'fayda-sub-1', + name: 'Test User', + email: 'test@example.com', + phone_number: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', + }), }, ); @@ -260,66 +250,17 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); expect(result).toMatchObject({ - purpose: 'PURCHASE', + purpose: 'VERIFY', verified: true, fullName: 'Test User', + email: 'test@example.com', + phoneNumber: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', }); expect(result.token).toBeUndefined(); - expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({ - where: { bookingId: 'booking-1' }, - data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }), - }); - }); - - it('saves to the User account when saveToAccount=true and no conflict', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), - ); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.update.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockFetchSequence( - { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, - { - headers: new Headers({ 'content-type': 'application/json' }), - text: async () => - JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }), - }, - ); - - const result = await service.completeVerification({ - code: 'authcode', - state: 'state-abc', - }); - - expect(result.verified).toBe(true); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), - }); - }); - - it('throws identity_conflict (409) when faydaSub belongs to another user', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), - ); - prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); - prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); - - mockFetchSequence( - { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, - { - headers: new Headers({ 'content-type': 'application/json' }), - text: async () => - JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }), - }, - ); - - await expect( - service.completeVerification({ code: 'authcode', state: 'state-abc' }), - ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); + expect(result.user).toBeUndefined(); + expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled(); }); it('throws 502 when the token endpoint returns 4xx', async () => { @@ -353,11 +294,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); it('falls back to localized name (name#en) when name is missing', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-2' }), - ); + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); prisma.faydaVerificationSession.update.mockResolvedValue({}); - prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, @@ -377,9 +315,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => { state: 'state-abc', }); expect(result.fullName).toBe('English Name'); - expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe( - 'English Name', - ); }); }); @@ -391,10 +326,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { codeVerifier: 'verifier-xyz', purpose: 'LOGIN', platform: 'WEB', - saveToAccount: false, status: 'PENDING', - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -420,139 +353,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - /** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */ - function mockUserFindUnique(bySub: any, fullUser: any) { - prisma.user.findUnique.mockImplementation(async (args: any) => { - if (args?.where?.faydaSub !== undefined) return bySub; - if (args?.where?.id !== undefined) return fullUser; - return null; - }); - } - beforeEach(() => { prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); }); - it('creates a new user when no match and returns { token, user }', async () => { - const fullUser = { - id: 'new-user', - email: 'new@example.com', - role: 'PASSENGER', - passenger: { id: 'p-new' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.create.mockResolvedValue({ id: 'new-user' }); - prisma.passenger.create.mockResolvedValue({ id: 'p-new' }); - prisma.loyaltyAccount.create.mockResolvedValue({}); - prisma.walletAccount.create.mockResolvedValue({}); - prisma.userPreferences.create.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - + it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => { mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result).toMatchObject({ - purpose: 'LOGIN', - verified: true, - token: 'signed.jwt.token', - user: { id: 'new-user', passengerId: 'p-new' }, - }); - expect(prisma.user.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - faydaSub: 'login-sub-1', - faydaVerified: true, - email: 'new@example.com', - }), - }), - ); - expect(prisma.passenger.create).toHaveBeenCalled(); - expect(jwt.sign).toHaveBeenCalledWith( - expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }), - ); - }); - - it('logs in an existing user already linked by faydaSub', async () => { - const fullUser = { - id: 'known-user', - email: 'k@example.com', - role: 'PASSENGER', - passenger: { id: 'p-k' }, - agent: null, - }; - mockUserFindUnique({ id: 'known-user' }, fullUser); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-2', name: 'Known' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result.user?.id).toBe('known-user'); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('links Fayda to an existing account matched by email', async () => { - const fullUser = { - id: 'acc-1', - email: 'match@example.com', - role: 'PASSENGER', - passenger: { id: 'p-1' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null }); - prisma.user.update.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result.user?.id).toBe('acc-1'); - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'acc-1' }, - data: expect.objectContaining({ faydaSub: 'login-sub-3' }), - }), - ); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('throws identity_conflict (409) when matched account has a different faydaSub', async () => { - mockUserFindUnique(null, null); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); - mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' }); - await expect( service.completeVerification({ code: 'c', state: 'state-login' }), - ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.create).not.toHaveBeenCalled(); + ).rejects.toMatchObject({ + status: 401, + response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }), + }); + }); + + it('does not touch the database for LOGIN purpose', async () => { + mockLoginFetch({ sub: 'login-sub-2', name: 'Person' }); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + + await expect( + service.completeVerification({ code: 'c', state: 'state-login' }), + ).rejects.toMatchObject({ status: 401 }); + expect(dataSource.query).not.toHaveBeenCalled(); + expect(prisma.passenger.create).not.toHaveBeenCalled(); }); }); describe('getVerificationStatus', () => { - it('returns verified=true when User row has the flag', async () => { - prisma.user.findUnique.mockResolvedValue({ - faydaVerified: true, - faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), - fullName: 'Test User', - }); - const result = await service.getVerificationStatus('user-1'); + it('returns verified=true when IAM user metadata has the flag', async () => { + dataSource.query.mockResolvedValueOnce([{ + metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' }, + name: { en: 'Test User', am: 'ቴስት ዩዘር' }, + }]); + const result = await service.getVerificationStatus('iam-user-1'); expect(result).toEqual({ verified: true, verifiedAt: new Date('2026-01-01T00:00:00Z'), @@ -560,9 +395,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - it('returns verified=false when User row is missing or unverified', async () => { - prisma.user.findUnique.mockResolvedValue(null); - const result = await service.getVerificationStatus('user-x'); + it('returns verified=false when IAM user is missing or unverified', async () => { + dataSource.query.mockResolvedValueOnce([]); + const result = await service.getVerificationStatus('iam-user-x'); expect(result).toEqual({ verified: false }); }); }); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index f7b3e77fb..795a6f158 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -6,10 +6,9 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import axios, { AxiosInstance } from 'axios'; -import * as bcrypt from 'bcrypt'; -import { randomBytes } from 'crypto'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; import { @@ -20,7 +19,6 @@ import { import { generateClientAssertion } from './utils/client-assertion.util'; import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; import { - FaydaIdentityConflictException, FaydaTokenExchangeException, FaydaUserInfoException, } from './verifayda.errors'; @@ -48,9 +46,7 @@ export interface VerifaydaVerificationResult { export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; - userId?: string; - bookingId?: string; - saveToAccount?: boolean; + userId?: string; // iamUserId of the authenticated user, if any } export interface FaydaUserSummary { @@ -63,7 +59,8 @@ export interface FaydaUserSummary { /** * Result of completing a verification. `verified` is always true on success. - * LOGIN additionally returns a JWT + user; PURCHASE returns the verified name. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; @@ -71,6 +68,10 @@ export interface CompleteVerificationResult { token?: string; user?: FaydaUserSummary; fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; } @Injectable() @@ -88,7 +89,7 @@ export class VerifaydaService { constructor( private readonly config: ConfigService, private readonly prisma: PrismaService, - private readonly jwt: JwtService, + @InjectDataSource() private readonly dataSource: DataSource, ) { const fayda = this.config.get('fayda'); if (!fayda) { @@ -143,15 +144,13 @@ export class VerifaydaService { codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', - saveToAccount: input.saveToAccount ?? false, - userId: input.userId ?? null, - bookingId: input.bookingId ?? null, + iamUserId: input.userId ?? null, expiresAt, }, }); this.logger.log( - `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`, + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); return this.buildAuthorizationUrl({ state, codeChallenge }); @@ -215,17 +214,22 @@ export class VerifaydaService { } let result: CompleteVerificationResult; - if (session.purpose === 'PURCHASE') { - await this.handlePurchaseSuccess(session, normalized); - result = { - purpose: 'PURCHASE', - verified: true, - fullName: normalized.fullName, - }; - } else { + if (session.purpose === 'LOGIN') { const { userId } = await this.handleLoginSuccess(normalized); const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity and hand the verified attributes back to the + // caller. No domain writes; the session row tracks status as usual. + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + }; } await this.prisma.faydaVerificationSession.update({ @@ -251,52 +255,25 @@ export class VerifaydaService { } } - /** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */ private async issueLoginToken( - userId: string, + _userId: string, ): Promise<{ token: string; user: FaydaUserSummary }> { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { passenger: true, agent: true }, + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', }); - if (!user) { - // Should not happen — we just resolved/created this user. - throw new UnauthorizedException({ - code: 'FAYDA_LOGIN_FAILED', - message: 'Could not load the verified user', - }); - } - - const summary: FaydaUserSummary = { - id: user.id, - email: user.email, - role: user.role, - passengerId: user.passenger?.id, - agentId: user.agent?.id, - }; - const token = this.jwt.sign({ - sub: summary.id, - email: summary.email, - role: summary.role, - passengerId: summary.passengerId, - agentId: summary.agentId, - }); - - this.logger.log(`Fayda login issued token for user ${user.id}`); - return { token, user: summary }; } - async getVerificationStatus(userId: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true }, - }); - - return { - verified: user?.faydaVerified ?? false, - verifiedAt: user?.faydaVerifiedAt ?? undefined, - fullName: user?.fullName ?? undefined, - }; + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ metadata: Record | null; name: { en: string; am: string } | null }[]>( + `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; } // ========================================================================== @@ -422,149 +399,16 @@ export class VerifaydaService { }; } - private async handlePurchaseSuccess( - session: { - id: string; - userId: string | null; - bookingId: string | null; - saveToAccount: boolean; - }, - normalized: NormalizedFaydaUserInfo, - ): Promise { - if (session.bookingId) { - await this.prisma.bookingSeat.updateMany({ - where: { bookingId: session.bookingId }, - data: { - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - faydaVerifiedName: normalized.fullName ?? null, - }, - }); - } - - if (session.userId && session.saveToAccount) { - const conflict = await this.prisma.user.findFirst({ - where: { - faydaSub: normalized.sub, - NOT: { id: session.userId }, - }, - select: { id: true }, - }); - if (conflict) { - throw new FaydaIdentityConflictException(); - } - - await this.prisma.user.update({ - where: { id: session.userId }, - data: { - faydaVerified: true, - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - }, - }); - } - } - - /** - * Resolves the User for a LOGIN flow and returns its id (the caller mints the - * JWT via {@link issueLoginToken}). Resolution order: - * 1. Existing user already linked to this Fayda `sub`. - * 2. Existing account whose email/phone matches — linked to this `sub`. - * 3. Otherwise a fresh Fayda-backed account is created. - */ + // LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. private async handleLoginSuccess( - normalized: NormalizedFaydaUserInfo, + _normalized: NormalizedFaydaUserInfo, ): Promise<{ userId: string }> { - let userId: string; - - const bySub = await this.prisma.user.findUnique({ - where: { faydaSub: normalized.sub }, - select: { id: true }, + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', }); - - if (bySub) { - userId = bySub.id; - } else { - const matchers: Array<{ email?: string; phone?: string }> = []; - if (normalized.email) matchers.push({ email: normalized.email }); - if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber }); - - const existing = matchers.length - ? await this.prisma.user.findFirst({ - where: { OR: matchers }, - select: { id: true, faydaSub: true }, - }) - : null; - - if (existing) { - if (existing.faydaSub && existing.faydaSub !== normalized.sub) { - // The matched account is already tied to a different Fayda identity. - throw new FaydaIdentityConflictException(); - } - await this.prisma.user.update({ - where: { id: existing.id }, - data: { - faydaSub: normalized.sub, - faydaVerified: true, - faydaVerifiedAt: new Date(), - }, - }); - userId = existing.id; - this.logger.log(`Fayda login linked existing user ${existing.id}`); - } else { - userId = await this.createFaydaUser(normalized); - this.logger.log(`Fayda login created new user ${userId}`); - } - } - - return { userId }; - } - - /** - * Creates a Fayda-backed User plus the same satellite rows registration makes - * (Passenger, LoyaltyAccount, WalletAccount, UserPreferences). - * - * The user has no password — `passwordHash` is set to a bcrypt of random bytes - * so password login is impossible; they authenticate only via Fayda. When - * Fayda doesn't supply an email/phone, a deterministic placeholder derived from - * the (unique) `sub` keeps the NOT NULL + unique columns satisfied. - */ - private async createFaydaUser( - normalized: NormalizedFaydaUserInfo, - ): Promise { - const passwordHash = await bcrypt.hash( - randomBytes(32).toString('hex'), - 10, - ); - const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`; - const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`; - const fullName = normalized.fullName ?? 'Fayda User'; - - const user = await this.prisma.user.create({ - data: { - fullName, - email, - phone, - passwordHash, - faydaVerified: true, - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - }, - select: { id: true }, - }); - const passenger = await this.prisma.passenger.create({ - data: { userId: user.id }, - select: { id: true }, - }); - await this.prisma.loyaltyAccount.create({ - data: { passengerId: passenger.id }, - }); - await this.prisma.walletAccount.create({ - data: { passengerId: passenger.id }, - }); - await this.prisma.userPreferences.create({ data: { userId: user.id } }); - - return user.id; } private async markSessionFailed( @@ -585,7 +429,6 @@ export class VerifaydaService { } private classifyFailureReason(err: unknown): string { - if (err instanceof FaydaIdentityConflictException) return 'identity_conflict'; if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; return 'verification_failed'; @@ -602,9 +445,8 @@ export class VerifaydaService { ): Promise { this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`); - if (this.stubEnabled != false || this.stubEnabled) { - this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)'); - // In development mode, return mock verified data + if (!this.stubEnabled) { + this.logger.warn('Verifayda not configured — returning mock data (development mode)'); return { verified: true, passengerData: { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts index 7c7335c34..450bfaff5 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -1,4 +1,4 @@ -export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE'; +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; export interface FaydaTokenResponse { access_token: string; diff --git a/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts new file mode 100644 index 000000000..51995b2d9 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Application, + Organization, + OrganizationConfiguration, + Permission, + Role, + RolePermission, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { ERoleKey } from '@tria-plc/api-common/utils/enums/seed.enum'; +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, +} from './passenger-permissions.registry'; +import { EDR_PASSENGER_APPLICATION, EDR_PASSENGER_ROLES, type PassengerSeedRole } from './edr-passenger.seed'; + +const EDR_ORG_KEY = 'edr'; +const EDR_ORG_NAME = { am: 'EDR', en: 'EDR' }; +const SEED_FLAG = 'SEED_EDR_PASSENGER_ORG'; + +type SeedOrganization = { id: string; key: string }; + +@Injectable() +export class EdrPassengerOrgSeeder { + private readonly logger = new Logger(EdrPassengerOrgSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger org seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + await this.ensureApplication(manager); + await this.ensurePermissions(manager); + const organization = await this.ensureOrganization(manager); + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_PASSENGER_ROLES); + await this.ensureRolePermissions(manager, EDR_PASSENGER_ROLES); + await this.ensureSuperAdminPermissions(manager); + }); + + this.logger.log(`Ensured EDR passenger organization seed for '${EDR_ORG_KEY}'`); + } + + private async ensureApplication(manager: EntityManager) { + await manager.getRepository(Application).upsert( + { + id: EDR_PASSENGER_APPLICATION.id, + key: EDR_PASSENGER_APPLICATION.key, + name: EDR_PASSENGER_APPLICATION.name, + }, + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured application '${EDR_PASSENGER_APPLICATION.key}'`); + } + + private async ensurePermissions(manager: EntityManager) { + await manager.getRepository(Permission).upsert( + PASSENGER_PERMISSIONS.map((p) => ({ + id: p.id, + key: p.key, + name: p.name, + applicationId: EDR_PASSENGER_APPLICATION.id, + })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured ${PASSENGER_PERMISSIONS.length} passenger permissions`); + } + + private async ensureOrganization(manager: EntityManager): Promise { + const repo = manager.getRepository(Organization); + let org = await repo.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true } }); + + if (!org) { + const result = await repo.insert({ + key: EDR_ORG_KEY, + name: EDR_ORG_NAME, + isGovernmentOrganization: true, + }); + this.logger.log(`Seeded EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: result.identifiers[0]?.id as string, key: EDR_ORG_KEY }; + } + + this.logger.log(`Ensured EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: org.id as string, key: EDR_ORG_KEY }; + } + + private async ensureOrganizationConfiguration(manager: EntityManager, organizationId: string) { + await manager.getRepository(OrganizationConfiguration).upsert( + { organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true }, + { conflictPaths: { organizationId: true } }, + ); + this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured passenger roles: ${seedRoles.map((r) => r.key).join(', ')}`); + } + + private async ensureRolePermissions(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + const allPermissionKeys = [...new Set(seedRoles.flatMap((r) => r.permissionKeys))]; + if (!allPermissionKeys.length) return; + + const roles = await manager.getRepository(Role).find({ + where: { key: In(seedRoles.map((r) => r.key)) }, + select: { id: true, key: true }, + }); + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(allPermissionKeys) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permByKey = new Map(permissions.map((p) => [p.key, p])); + + const links = seedRoles.flatMap((seedRole) => { + const role = roleByKey.get(seedRole.key); + if (!role) throw new Error(`missing_role:${seedRole.key}`); + + return seedRole.permissionKeys.map((key) => { + const perm = permByKey.get(key); + if (!perm) throw new Error(`missing_permission:${key}`); + return { roleId: role.id, permissionId: perm.id }; + }); + }); + + await manager.getRepository(RolePermission).upsert(links, { + conflictPaths: { roleId: true, permissionId: true }, + }); + this.logger.log(`Ensured ${links.length} passenger role-permission links`); + } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn(`Role ${ERoleKey.SUPER_ADMIN} not found; skipping super_admin permission links`); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(PASSENGER_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) return; + + await manager.getRepository(RolePermission).upsert( + permissions.map((p) => ({ roleId: role.id, permissionId: p.id })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + this.logger.log(`Ensured ${permissions.length} passenger permissions on super_admin`); + } +} diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts new file mode 100644 index 000000000..8413cadb2 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -0,0 +1,47 @@ +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './passenger-permissions.registry'; + +export type PassengerSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +export const EDR_PASSENGER_APPLICATION = { + id: 'd2000001-0001-4000-8000-000000000001', + key: 'edr_passenger_app', + name: { + am: 'EDR Passenger App', + en: 'EDR Passenger App', + }, +} as const; + +export const EDR_PASSENGER_PERMISSIONS = [...PASSENGER_PERMISSIONS]; + +export { PASSENGER_PERMISSION_KEYS } from './passenger-permissions.registry'; + +export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [ + { + key: 'edr_passenger_backoffice_admin', + name: { en: 'EDR Passenger Backoffice Admin' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeAdmin], + }, + { + key: 'edr_passenger_backoffice_staff', + name: { en: 'EDR Passenger Backoffice Staff' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff], + }, + { + key: 'edr_passenger_agent', + name: { en: 'EDR Passenger Agent' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.agent], + }, + { + key: 'edr_passenger_finance', + name: { en: 'EDR Passenger Finance' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, +]; diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts new file mode 100644 index 000000000..9b06c812b --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -0,0 +1,121 @@ +const APP_KEY = 'edr_passenger_app'; + +export type PassengerPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: APP_KEY, +}); + +export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'), + perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'), + perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'), + perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'), + perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'), + perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'), + perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'), + perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), + perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'), + perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'), + perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), + perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'), + perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'), + perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'), + perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'), + perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'), + perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'), + perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'), +]; + +export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key); + +export const PASSENGER_PERMS = { + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + viewAll: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manageMethods: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + dashboard: { + view: 'edr_passenger_app:dashboard:view', + }, + admin: 'edr_passenger_app:admin', +} as const; + +export const ROLE_PERMISSION_PRESETS = { + backofficeAdmin: [...PASSENGER_PERMISSION_KEYS], + + backofficeStaff: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.cancel, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.passengers.manage, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + PASSENGER_PERMS.notifications.send, + PASSENGER_PERMS.agents.view, + PASSENGER_PERMS.fraud.view, + PASSENGER_PERMS.audit.view, + ], + + agent: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.payments.refund, + ], + + finance: [ + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.payments.refund, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + ], +} as const; diff --git a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts new file mode 100644 index 000000000..b16138ea2 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_PASSENGER_STAFF'; +const EDR_ORG_KEY = 'edr'; + +const STAFF_USERS = [ + { email: 'passenger.admin@edr.local', username: 'passenger_admin', roleKey: 'edr_passenger_backoffice_admin' }, + { email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' }, + { email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' }, + { email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' }, +] as const; + +@Injectable() +export class PassengerStaffUsersSeeder { + private readonly logger = new Logger(PassengerStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) throw new Error(`missing_organization:${EDR_ORG_KEY}`); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await manager.getRepository(Role).findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + if (!role) throw new Error(`missing_role:${staff.roleKey}`); + + let user = await manager.getRepository(User).findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded passenger staff user ${staff.email}`); + } + + const credentialExists = await manager.getRepository(UserCredential).exists({ + where: { userId: user.id, isActive: true }, + }); + if (!credentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await manager.getRepository(UserRole).upsert( + { userId: user.id, roleId: role.id, organizationId: organization.id }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await manager.getRepository(Employee).exists({ + where: { userId: user.id, organizationId: organization.id, isCurrent: true }, + }); + if (!employeeExists) { + await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured passenger staff users'); + } +} diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index e9fbe1ffe..49158fb15 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -8,6 +8,8 @@ "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", "paths": { "@/*": ["./src/*"] }, + "module": "node16", + "moduleResolution": "node16", "strictPropertyInitialization": false, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 7b43fb677..e420c4e07 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react'; +import { Download, Eye, XCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -13,13 +13,21 @@ import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { BookingFilters } from '@/types'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function BookingsPage() { - const [filters, setFilters] = useState({ - page: 1, - pageSize: 20, - search: '', - status: '', - }); + const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [selectedBooking, setSelectedBooking] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); @@ -28,14 +36,8 @@ export default function BookingsPage() { const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ - bookingRef: true, - passenger: true, - status: true, - bookingType: false, - passengerCount: false, - totalMinor: true, - paymentStatus: true, - createdAt: true, + bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true, + contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true, }); const queryClient = useQueryClient(); @@ -45,10 +47,6 @@ export default function BookingsPage() { queryFn: () => bookingsApi.getAll(filters), }); - if (error) { - console.error('Bookings API Error:', error); - } - const cancelMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), onSuccess: () => { @@ -56,9 +54,7 @@ export default function BookingsPage() { setSuccessMessage('Booking cancelled successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, - onError: (error: any) => { - alert(`Error: ${error.message || 'Failed to cancel booking'}`); - }, + onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`), }); const deleteMutation = useMutation({ @@ -77,26 +73,22 @@ export default function BookingsPage() { }); const handleCancel = async (booking: any) => { - if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) { + if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) { await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); } }; - const handleDeleteClick = (booking: any) => { - setBookingToDelete(booking); - setDeleteConfirmOpen(true); - }; - - const handleConfirmDelete = async () => { - if (bookingToDelete) { - await deleteMutation.mutateAsync(bookingToDelete.id); - } - }; + const BOOKING_COLS = [ + { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, + { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, + { key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' }, + { key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' }, + { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' }, + ]; const confirmExport = () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); - if (cols.length === 0) { alert('Please select at least one column'); return; } - + if (!cols.length) { alert('Please select at least one column'); return; } const exportItems = (data?.items || []).filter((b: any) => { if (!exportDateFrom && !exportDateTo) return true; const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; @@ -104,27 +96,27 @@ export default function BookingsPage() { if (exportDateTo && (!d || d > exportDateTo)) return false; return true; }); - const csv = [ - cols.join(','), + BOOKING_COLS.map(c => `"${c.label}"`).join(','), ...exportItems.map((booking: any) => { - const values = cols.map(col => { - switch (col) { + const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { case 'bookingRef': return booking.bookingRef; - case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; - case 'status': return booking.status; - case 'bookingType': return booking.bookingType || 'N/A'; - case 'passengerCount': return booking.adultCount + booking.childCount; - case 'totalMinor': return booking.totalMinor; + case 'journeyType': return booking.bookingType || 'N/A'; + case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A'; + case 'contactPhone': return booking.contactPhone || 'N/A'; + case 'contactEmail': return booking.contactEmail || 'N/A'; + case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0); case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; - case 'createdAt': return booking.createdAt; + case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); + case 'status': return booking.status; + case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; default: return ''; } }); return values.map(v => `"${v}"`).join(','); }), ].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -136,91 +128,61 @@ export default function BookingsPage() { const columns = [ { - key: 'bookingRef', - label: 'Reference', - sortable: true, - render: (booking: any) => ( - {booking.bookingRef} - ), - }, - { - key: 'passenger', - label: 'Passenger', + key: 'bookingRef', label: 'Reference', sortable: true, render: (booking: any) => (
-
{booking.passenger?.fullName || booking.contactEmail || 'Guest'}
-
{booking.contactPhone || booking.passenger?.phone}
+
{booking.bookingRef}
+
{booking.bookingType || 'ONE_WAY'}
), }, { - key: 'bookingType', - label: 'Type', - sortable: true, - render: (booking: any) => booking.bookingType || 'ONE_WAY', - }, - { - key: 'passengerCount', - label: 'Passengers', + key: 'passengerNames', label: 'Names', render: (booking: any) => { - const adults = booking.adultCount || 0; - const children = booking.childCount || 0; - if (adults === 0 && children === 0) return '—'; - const parts = [`Adult: ${adults}`]; - if (children > 0) parts.push(`Child: ${children}`); - return parts.join(' / '); + const names: string[] = booking.passengerNames || []; + if (!names.length) return ; + return
{names.map((n, i) => {n})}
; }, }, { - key: 'status', - label: 'Status', + key: 'contact', label: 'Contact', render: (booking: any) => ( - {booking.status} +
+
{booking.contactPhone || booking.passenger?.phone}
+
{booking.contactEmail || booking.passenger?.email}
+
), }, { - key: 'totalMinor', - label: 'Amount', - sortable: true, - render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), + key: 'passengerCount', label: 'Passengers', + render: (booking: any) => { + const adults = booking.adultCount || 0, children = booking.childCount || 0; + if (!adults && !children) return '—'; + return <>
Adult: {adults}
Child: {children}
; + }, }, { - key: 'paymentStatus', - label: 'Payment', + key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( - - {booking.paymentIntent?.status || 'PENDING'} - +
+ {booking.paymentIntent?.status || 'PENDING'} +
{formatCurrency(booking.totalMinor, booking.currency)}
+
), }, { - key: 'createdAt', - label: 'Created', - sortable: true, - render: (booking: any) => formatDateTime(booking.createdAt), + key: 'status', label: 'Status', + render: (booking: any) => {booking.status}, }, ]; const actions = [ + { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { - label: 'View Details', - onClick: (booking: any) => setSelectedBooking(booking), - variant: 'secondary' as const, - icon: Eye, - }, - { - label: 'Cancel Booking', - onClick: handleCancel, - variant: 'danger' as const, - icon: XCircle, - show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED', - }, - { - label: 'Delete', - onClick: handleDeleteClick, - variant: 'danger' as const, - icon: Trash2, + label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle, + show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED', }, + { label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -235,9 +197,7 @@ export default function BookingsPage() {
{successMessage && ( -
- ✓ {successMessage} -
+
✓ {successMessage}
)} {error && (
@@ -246,222 +206,195 @@ export default function BookingsPage() { )}
- setFilters({ ...filters, search: e.target.value, page: 1 })} - /> + setFilters({ ...filters, search: e.target.value, page: 1 })} />
- setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}> - + - More Filters
- - - + {data?.meta && ( - setFilters({ ...filters, page })} - /> + setFilters({ ...filters, page })} /> )}
{/* Booking Details Modal */} setSelectedBooking(null)} title="Booking Details" size="xl"> - {selectedBooking && ( -
-
-
- -

{selectedBooking.bookingRef}

-
-
- -
- {selectedBooking.status} -
-
-
- -

{selectedBooking.bookingType || 'N/A'}

-
-
- -

{formatDateTime(selectedBooking.createdAt)}

-
-
- -
- + {selectedBooking && (() => { + const b = selectedBooking; + const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT'; + return (
-

Passenger Information

-
-
- -

{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}

-
-
- -

{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}

-
-
- -

{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}

-
-
- -

{selectedBooking.passengerId || 'N/A'}

-
-
-
- -
- -
-

Journey Details

-
-
- -

{selectedBooking.adultCount || 0}

-
-
- -

{selectedBooking.childCount || 0}

-
-
- -

{selectedBooking.scheduleId || 'N/A'}

-
-
- -

{selectedBooking.promoCode || 'None'}

-
-
-
- -
- -
-

Payment Information

-
-
- -

{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}

-
-
- -
- - {selectedBooking.paymentIntent?.status || 'PENDING'} - + {/* Gradient header */} +
+
+
+

Booking Reference

+

{b.bookingRef}

+
+
+ {b.status} +

{formatDateTime(b.createdAt)}

-
- -

{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}

-
-
- -

{selectedBooking.displayCurrency || selectedBooking.currency}

+
+ {[ + (b.bookingType || 'ONE_WAY').replace(/_/g, ' '), + `${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`, + b.displayCurrency || b.currency || 'ETB', + ].map((tag) => ( + + {tag} + + ))}
-
-
+
+ {/* Passenger */} +
+ +
+ + + + +
+
-
-

Additional Information

-
-
- -

{selectedBooking.source || 'N/A'}

-
-
- -

{formatDateTime(selectedBooking.updatedAt)}

-
+ {/* Journey */} +
+ +
+ + + + + + + + +
+
+ + {/* Return leg */} + {isRoundTrip && ( +
+ +
+ + + + +
+
+ )} + + {/* Payment */} +
+ +
+
+

Total Amount

+

{formatCurrency(b.totalMinor, b.currency || 'ETB')}

+ {b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && ( +

+ ≈ {formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)} +

+ )} +
+
+

Payment Status

+ {b.paymentIntent?.status || 'PENDING'} +
+ + + + +
+
+ + {/* Seats */} + {b.seats && b.seats.length > 0 && ( +
+ +
+ {b.seats.map((bs: any, i: number) => ( +
+
+ {i + 1} +
+

{bs.passengerName || '—'}

+

+ {bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''} + {bs.verifaydaVerified ? ' · ✓ Verified' : ''} +

+
+
+
+

{bs.seat?.seatNumber || bs.seatId || '—'}

+

{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}

+
+
+ ))} +
+
+ )} + + {/* Timestamps */} +
+ +
+ + + +
+
+
+ +
+ setSelectedBooking(null)}>Close
- -
- setSelectedBooking(null)}>Close -
-
- )} + ); + })()} - {/* Delete Confirmation Dialog */} { setDeleteConfirmOpen(false); setBookingToDelete(null); }} - onConfirm={handleConfirmDelete} + onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} title="Delete Booking" - message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} - confirmText="Delete" - cancelText="Cancel" - isLoading={deleteMutation.isPending} - isDanger={true} + message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} + confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger /> - {/* Export Modal */} setExportModalOpen(false)} title="Export Bookings" size="md">
-
- - setExportDateFrom(e.target.value)} /> -
-
- - setExportDateTo(e.target.value)} /> -
+
setExportDateFrom(e.target.value)} />
+
setExportDateTo(e.target.value)} />
-

Select Columns

- {[ - { key: 'bookingRef', label: 'Booking Reference' }, - { key: 'passenger', label: 'Passenger' }, - { key: 'status', label: 'Status' }, - { key: 'bookingType', label: 'Booking Type' }, - { key: 'passengerCount', label: 'Passenger Count' }, - { key: 'totalMinor', label: 'Amount' }, - { key: 'paymentStatus', label: 'Payment Status' }, - { key: 'createdAt', label: 'Created At' }, - ].map((col) => ( + {BOOKING_COLS.map((col) => ( ))}
-
setExportModalOpen(false)}>Cancel Export CSV diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index ab588b085..015eaf3a1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, Trash2 } from 'lucide-react'; +import { Download, Eye, Trash2, ShieldCheck, ShieldOff, Star, Wallet } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -13,13 +13,28 @@ import { passengersApi, apiClient } from '@/lib/api'; import { formatDate, formatDateTime } from '@/lib/utils'; import { PassengerFilters } from '@/types'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + +const TIER_COLORS: Record = { + BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800', + SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600', + GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800', + PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800', +}; + export default function PassengersPage() { - const [filters, setFilters] = useState({ - page: 1, - pageSize: 20, - search: '', - role: 'PASSENGER', - }); + const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' }); const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [exportModalOpen, setExportModalOpen] = useState(false); @@ -33,35 +48,23 @@ export default function PassengersPage() { const deleteMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['passengers'] }); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }), }); - const handleDelete = (passenger: any) => { - setDeleteConfirm({ isOpen: true, passenger }); - }; - - const confirmDelete = async () => { - if (deleteConfirm.passenger) { - await deleteMutation.mutateAsync(deleteConfirm.passenger.id); - setDeleteConfirm({ isOpen: false, passenger: null }); - } - }; - const { data, isLoading, error } = useQuery({ queryKey: ['passengers', filters], queryFn: () => passengersApi.getAll(filters), }); - if (error) { - console.error('Passengers API Error:', error); - } + const PASSENGER_COLS = [ + { key: 'fullName', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' }, + { key: 'dateOfBirth', label: 'Date of Birth' }, { key: 'gender', label: 'Gender' }, + { key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' }, + ]; const confirmExportPassengers = () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); - if (cols.length === 0) { alert('Please select at least one column'); return; } - + if (!cols.length) { alert('Please select at least one column'); return; } const exportItems = (data?.items || []).filter((p: any) => { if (!exportDateFrom && !exportDateTo) return true; const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; @@ -69,26 +72,24 @@ export default function PassengersPage() { if (exportDateTo && (!d || d > exportDateTo)) return false; return true; }); - const csv = [ - cols.join(','), - ...exportItems.map((passenger: any) => { - const values = cols.map(col => { - switch (col) { - case 'fullName': return passenger.fullName; - case 'email': return passenger.email || ''; - case 'phone': return passenger.phone || ''; - case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : ''; - case 'gender': return passenger.gender || ''; - case 'nationality': return passenger.nationality || ''; - case 'verified': return passenger.nationalId ? 'Yes' : 'No'; + PASSENGER_COLS.map(c => `"${c.label}"`).join(','), + ...exportItems.map((p: any) => { + const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { + case 'fullName': return p.fullName; + case 'email': return p.email || ''; + case 'phone': return p.phone || ''; + case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; + case 'gender': return p.gender || ''; + case 'nationality': return p.nationality || ''; + case 'verified': return p.nationalId ? 'Yes' : 'No'; default: return ''; } }); return values.map(v => `"${v}"`).join(','); }), ].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -99,65 +100,32 @@ export default function PassengersPage() { }; const columns = [ - { - key: 'fullName', - label: 'Name', - sortable: true, - render: (passenger: any) => ( + { + key: 'fullName', label: 'Name', sortable: true, + render: (p: any) => (
-
{passenger.fullName}
-
{passenger.email}
+
{p.fullName}
+
{p.email}
), }, - { - key: 'phone', - label: 'Phone', - sortable: true, - render: (passenger: any) => passenger.phone, - }, - { - key: 'gender', - label: 'Gender', - sortable: true, - render: (passenger: any) => passenger.gender || 'N/A', - }, - { - key: 'nationality', - label: 'Nationality', - sortable: true, - render: (passenger: any) => passenger.nationality || 'N/A', - }, - { - key: 'dateOfBirth', - label: 'Date of Birth', - sortable: true, - render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A', - }, - { - key: 'verified', - label: 'Status', - render: (passenger: any) => ( - - {passenger.nationalId ? 'Verified' : 'Unverified'} + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone }, + { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, + { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, + { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, + { + key: 'verified', label: 'Status', + render: (p: any) => ( + + {p.nationalId ? 'Verified' : 'Unverified'} ), }, ]; const actions = [ - { - label: 'View Details', - onClick: (passenger: any) => setSelectedPassenger(passenger), - variant: 'secondary' as const, - icon: Eye, - }, - { - label: 'Delete', - onClick: handleDelete, - variant: 'danger' as const, - icon: Trash2, - }, + { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, + { label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -167,9 +135,7 @@ export default function PassengersPage() {

Passengers

Manage passenger profiles and verification

-
- setExportModalOpen(true)}>Export -
+ setExportModalOpen(true)}>Export
@@ -180,254 +146,218 @@ export default function PassengersPage() { )}
- setFilters({ ...filters, search: e.target.value, page: 1 })} - /> + setFilters({ ...filters, search: e.target.value, page: 1 })} />
- setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
- - - + {data?.meta && ( - setFilters({ ...filters, page })} - /> + setFilters({ ...filters, page })} /> )}
- {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, passenger: null })} - onConfirm={confirmDelete} + onConfirm={async () => { + if (deleteConfirm.passenger) { + await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + setDeleteConfirm({ isOpen: false, passenger: null }); + } + }} title="Delete Passenger" message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} - confirmText="Delete" - isDanger={true} + confirmText="Delete" isDanger warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." /> {/* Passenger Details Modal */} - setSelectedPassenger(null)} - title="Passenger Details" - size="xl" - > - {selectedPassenger && ( -
- {/* Personal Information */} + setSelectedPassenger(null)} title="Passenger Details" size="xl"> + {selectedPassenger && (() => { + const p = selectedPassenger; + const isVerified = !!p.faydaVerified || !!p.nationalId; + const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier; + const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; + + return (
-

Personal Information

-
-
- -

{selectedPassenger.fullName}

-
-
- -

- {selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} -

-
-
- -

{selectedPassenger.gender || 'N/A'}

-
-
- -

{selectedPassenger.nationality || 'N/A'}

-
-
-
- -
- - {/* Contact Information */} -
-

Contact Information

-
-
- -

{selectedPassenger.email || 'N/A'}

-
-
- -

{selectedPassenger.phone || 'N/A'}

-
-
-
- -
- - {/* Identification */} -
-

Identification

-
-
- -

{selectedPassenger.passportNumber || 'N/A'}

-
-
- -

{selectedPassenger.passportCountry || 'N/A'}

-
-
- -
- - {selectedPassenger.nationalId ? 'Verified' : 'Unverified'} - + {/* Gradient header with avatar */} +
+
+
+ {(p.fullName || p.email || '?')[0].toUpperCase()} +
+
+

{p.fullName}

+

{p.email}

+
+
+
+ + {isVerified ? '✓ Verified' : 'Unverified'} + +
+ {tier && ( + + {tier} + + )}
-
-
-
- - {/* Account Information */} -
-

Account Information

-
-
- -

{selectedPassenger.id}

-
-
- -

{selectedPassenger.userId || 'N/A'}

-
-
-
- - {/* Loyalty & Wallet (if available) */} - {(selectedPassenger.loyalty || selectedPassenger.wallet) && ( - <> -
-
- {selectedPassenger.loyalty && ( -
-

Loyalty Account

-
-
- -

{selectedPassenger.loyalty.tier || 'N/A'}

-
-
- -

{selectedPassenger.loyalty.pointsBalance || 0}

-
-
+ {/* Quick stats */} +
+ {[ + { label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() }, + { label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' }, + { label: 'Nationality', value: p.nationality || '—' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

- )} - {selectedPassenger.wallet && ( -
-

Wallet

-
-
- -

- {(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency} -

-
-
-
- )} -
- - )} - -
- - {/* Timestamps */} -
-

Timestamps

-
-
- -

{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}

-
-
- -

{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}

+ ))}
-
-
- setSelectedPassenger(null)} - > - Close - +
+ {/* Personal */} +
+ +
+ + + + + + + + +
+
+ + {/* Contact */} +
+ +
+ + + +
+
+ + {/* Identification */} +
+ +
+
+

Fayda (National ID)

+
+ {isVerified + ? + : } + + {isVerified ? 'Verified' : 'Not verified'} + +
+ {p.faydaVerifiedAt &&

{formatDateTime(p.faydaVerifiedAt)}

} +
+ + + +
+
+ + {/* Loyalty & Wallet */} + {(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && ( +
+ +
+ {(p.passenger?.loyalty || p.loyalty) && (() => { + const loyalty = p.passenger?.loyalty || p.loyalty; + return ( + <> +
+

Tier

+
+ + {loyalty.tier} +
+
+ + + + ); + })()} + {(p.passenger?.wallet || p.wallet) && (() => { + const wallet = p.passenger?.wallet || p.wallet; + return ( +
+

Wallet Balance

+

+ ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)} +

+
+ ); + })()} +
+
+ )} + + {/* Account */} +
+ +
+ + +
+
+ + {/* Timestamps */} +
+ +
+ + + +
+
+
+ +
+ setSelectedPassenger(null)}>Close +
-
- )} + ); + })()} - {/* Export Modal */} + setExportModalOpen(false)} title="Export Passengers" size="md">
-
- - setExportDateFrom(e.target.value)} /> -
-
- - setExportDateTo(e.target.value)} /> -
+
setExportDateFrom(e.target.value)} />
+
setExportDateTo(e.target.value)} />
-

Select Columns

- {[ - { key: 'fullName', label: 'Full Name' }, - { key: 'email', label: 'Email' }, - { key: 'phone', label: 'Phone' }, - { key: 'dateOfBirth', label: 'Date of Birth' }, - { key: 'gender', label: 'Gender' }, - { key: 'nationality', label: 'Nationality' }, - { key: 'verified', label: 'Verified' }, - ].map((col) => ( + {PASSENGER_COLS.map((col) => ( ))}
-
setExportModalOpen(false)}>Cancel Export CSV diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index 2c1c8bcf5..c6a5cbc5b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -28,6 +28,15 @@ export default function PaymentsPage() { }), }); + const PAYMENT_COLS = [ + { key: 'reference', label: 'Reference' }, + { key: 'booking', label: 'Booking Reference' }, + { key: 'amount', label: 'Amount' }, + { key: 'method', label: 'Payment Method' }, + { key: 'status', label: 'Status' }, + { key: 'createdAt', label: 'Created At' }, + ]; + const confirmExport = () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); if (cols.length === 0) { alert('Please select at least one column'); return; } @@ -42,16 +51,16 @@ export default function PaymentsPage() { }); const csv = [ - cols.join(','), + PAYMENT_COLS.map(c => `"${c.label}"`).join(','), ...exportItems.map((payment: any) => { - const values = cols.map(col => { - switch (col) { + const values = PAYMENT_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; - case 'booking': return payment.booking?.bookingRef || 'N/A'; - case 'amount': return formatCurrency(payment.amountMinor, payment.currency); - case 'method': return payment.method || ''; - case 'status': return payment.status || ''; - case 'createdAt': return payment.createdAt || ''; + case 'booking': return payment.booking?.bookingRef || 'N/A'; + case 'amount': return formatCurrency(payment.amountMinor, payment.currency); + case 'method': return payment.method || ''; + case 'status': return payment.status || ''; + case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : ''; default: return ''; } }); @@ -84,7 +93,7 @@ export default function PaymentsPage() {

Payments

Manage payment transactions and refunds

- setExportModalOpen(true)}>Export + setExportModalOpen(true)}>Export
diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index 353b1fd3a..ceeb078d1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -239,9 +239,9 @@ export default function ReportsPage() { b.status === 'CONFIRMED').length }, - { name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length }, + { name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length }, { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, - { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length }, ].filter(d => d.value > 0)} cx="50%" cy="50%" @@ -306,7 +306,7 @@ export default function ReportsPage() {

Completed Bookings

-

{bookings.filter((b: any) => b.status === 'COMPLETED').length}

+

{bookings.filter((b: any) => b.status === 'BOARDED').length}

Cancelled Bookings

diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 33a03e8f5..60a7f80b8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -563,7 +563,7 @@ export default function SchedulesPage() { {trains.map((train: Train) => ( ))} @@ -580,7 +580,7 @@ export default function SchedulesPage() { {routes.map((route: Route) => ( ))} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index cb26bd431..82db4e9af 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -443,13 +443,12 @@ export default function SeatsPage() { className="input" > - {schedules.map((schedule: any) => { - const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; + {schedules.map((schedule: any) => { const routeName = schedule.route?.name || 'N/A'; const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; return ( ); })} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 97a1cf7ba..50d7ab822 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -72,8 +72,8 @@ export default function StationsPage() { name: formData.get('name') as string, city: formData.get('city') as string, countryCode: formData.get('countryCode') as string, - lat: parseFloat(formData.get('lat') as string) || null, - lng: parseFloat(formData.get('lng') as string) || null, + lat: parseFloat(formData.get('lat') as string) || undefined, + lng: parseFloat(formData.get('lng') as string) || undefined, timezone: formData.get('timezone') as string, sequence, isOperational: formData.get('isOperational') === 'true', @@ -304,28 +304,6 @@ export default function StationsPage() {
-
- - -
-
- - -