mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #263 from Tria-plc/passenger/feat/iam
Passenger/feat/iam
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Empty placeholder migration
|
||||
SELECT 1;
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal file
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal file
@@ -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);
|
||||
});
|
||||
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal file
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal file
@@ -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 <token>" 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);
|
||||
});
|
||||
@@ -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<TypeOrmModuleOptions>('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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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>(IamGuard);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
reflector = module.get<Reflector>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
|
||||
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
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<string[]>('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<IamValidationResponse> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<IamValidationResponse>(
|
||||
`${this.iamApiUrl}/v1/auth/validate`,
|
||||
{ token },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.config.get<string>('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';
|
||||
|
||||
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal file
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal file
@@ -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'],
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
||||
@@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
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<Array<{ expiry_time: Date }>>(
|
||||
`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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string>('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 };
|
||||
}
|
||||
}
|
||||
14
apps/edr-passenger-api/src/common/passenger-guards.ts
Normal file
14
apps/edr-passenger-api/src/common/passenger-guards.ts
Normal file
@@ -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);
|
||||
@@ -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<CanActivate> {
|
||||
@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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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}`);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<UserRole[]>(ROLES_KEY, [
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal file
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal file
@@ -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(),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, any> | 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<IamAuthService> {
|
||||
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<IamUserRow[]>(
|
||||
`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<IamUserRow[]>(
|
||||
`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<IamUserRow[]>(
|
||||
`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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, any> | 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<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
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<SavedPassengerProfileDto[]> {
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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<boolean> {
|
||||
private async checkVelocityRule(passengerId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
private async checkFailedPaymentRule(passengerId: string): Promise<boolean> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise<void> {
|
||||
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<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
async unblockUser(iamUserId: string): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): Promise<void> {
|
||||
// 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<NotificationChannelType[]> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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 },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any> | 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<IamUserRow[]>(
|
||||
`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<IamUserRow[]>(
|
||||
`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<IamUserRow[]>(
|
||||
`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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,19 +23,7 @@ describe("Payments E2E", () => {
|
||||
|
||||
prisma = app.get<PrismaService>(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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
|
||||
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<string[]> {
|
||||
@@ -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() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <jwt>` 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<TUser = unknown>(_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<boolean> {
|
||||
try {
|
||||
await super.canActivate(context);
|
||||
} catch {
|
||||
context.switchToHttp().getRequest().user = undefined;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VerificationStatusDto> {
|
||||
return this.service.getVerificationStatus(req.user.userId);
|
||||
return this.service.getVerificationStatus(req.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<JwtService> {
|
||||
return {
|
||||
sign: jest.fn(() => 'signed.jwt.token'),
|
||||
} as unknown as jest.Mocked<JwtService>;
|
||||
function buildDataSourceMock() {
|
||||
return { query: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||
@@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
|
||||
|
||||
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||
let jwt: jest.Mocked<JwtService>;
|
||||
let dataSource: ReturnType<typeof buildDataSourceMock>;
|
||||
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<any> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FaydaConfig>('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<VerificationStatusDto> {
|
||||
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<VerificationStatusDto> {
|
||||
const rows = await this.dataSource.query<{ metadata: Record<string, any> | 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<void> {
|
||||
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<string> {
|
||||
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<VerifaydaVerificationResult> {
|
||||
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: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
|
||||
|
||||
export interface FaydaTokenResponse {
|
||||
access_token: string;
|
||||
|
||||
165
apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts
Normal file
165
apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts
Normal file
@@ -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<SeedOrganization> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
47
apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
Normal file
47
apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
Normal file
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
106
apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts
Normal file
106
apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function BookingsPage() {
|
||||
const [filters, setFilters] = useState<BookingFilters>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
@@ -28,14 +36,8 @@ export default function BookingsPage() {
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
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) => (
|
||||
<span className="font-mono font-semibold">{booking.bookingRef}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
key: 'bookingRef', label: 'Reference', sortable: true,
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
|
||||
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||
<div className="font-mono font-semibold">{booking.bookingRef}</div>
|
||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <span className="text-muted-foreground">—</span>;
|
||||
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: 'contact', label: 'Contact',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
|
||||
<div>
|
||||
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment',
|
||||
key: 'paymentStatus', label: 'Payment',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
|
||||
{booking.paymentIntent?.status || 'PENDING'}
|
||||
</Badge>
|
||||
<div>
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatDateTime(booking.createdAt),
|
||||
key: 'status', label: 'Status',
|
||||
render: (booking: any) => <Badge variant="status" status={booking.status}>{booking.status}</Badge>,
|
||||
},
|
||||
];
|
||||
|
||||
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() {
|
||||
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ {successMessage}
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">✓ {successMessage}</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
@@ -246,222 +206,195 @@ export default function BookingsPage() {
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by reference, email, or phone..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
|
||||
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
|
||||
</div>
|
||||
<select
|
||||
className="input w-48"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
|
||||
>
|
||||
<select className="input w-48" value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING_PAYMENT">Pending Payment</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="BOARDED">Boarded</option>
|
||||
</select>
|
||||
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No bookings found"
|
||||
/>
|
||||
|
||||
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })}
|
||||
/>
|
||||
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Details Modal */}
|
||||
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{selectedBooking && (() => {
|
||||
const b = selectedBooking;
|
||||
const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Adults</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Children</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
|
||||
{selectedBooking.paymentIntent?.status || 'PENDING'}
|
||||
</Badge>
|
||||
{/* Gradient header */}
|
||||
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
|
||||
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={b.status}>{b.status}</Badge>
|
||||
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
(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) => (
|
||||
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
<div className="space-y-6">
|
||||
{/* Passenger */}
|
||||
<section>
|
||||
<SectionHeader title="Passenger" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
|
||||
<Field label="Email" value={b.contactEmail || b.passenger?.email} />
|
||||
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
|
||||
<Field label="Passenger ID" value={b.passengerId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Source</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
|
||||
</div>
|
||||
{/* Journey */}
|
||||
<section>
|
||||
<SectionHeader title="Journey" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Origin" value={b.schedule?.originStation?.name} />
|
||||
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
|
||||
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
|
||||
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
|
||||
<Field label="Adults" value={String(b.adultCount ?? 0)} />
|
||||
<Field label="Children" value={String(b.childCount ?? 0)} />
|
||||
<Field label="Promo Code" value={b.promoCode || 'None'} />
|
||||
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Return leg */}
|
||||
{isRoundTrip && (
|
||||
<section>
|
||||
<SectionHeader title="Return Leg" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Leg Status" value={(b.returnLegStatus || '—').replace(/_/g, ' ')} />
|
||||
<Field label="Outbound Boarded" value={b.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
|
||||
<Field label="Return Boarded" value={b.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
|
||||
<Field label="Return Schedule ID" value={b.returnScheduleId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Payment */}
|
||||
<section>
|
||||
<SectionHeader title="Payment" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
|
||||
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
|
||||
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
|
||||
≈ {formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
</div>
|
||||
<Field label="Method" value={b.paymentIntent?.method || '—'} />
|
||||
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
|
||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seats */}
|
||||
{b.seats && b.seats.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title={`Seats (${b.seats.length})`} />
|
||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||
{b.seats.map((bs: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
|
||||
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<section>
|
||||
<SectionHeader title="Timestamps & Meta" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Created" value={formatDateTime(b.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
|
||||
<Field label="Source / Device" value={b.source || b.userAgent || '—'} truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => { 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 */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Created)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Created)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div><label className="label">Date From (Created)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
|
||||
<div><label className="label">Date To (Created)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ 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) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
<input type="checkbox" checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
className="w-4 h-4 rounded border-gray-300" />
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
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<PassengerFilters>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
role: 'PASSENGER',
|
||||
});
|
||||
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(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) => (
|
||||
<div>
|
||||
<div className="font-medium">{passenger.fullName}</div>
|
||||
<div className="text-sm text-muted-foreground">{passenger.email}</div>
|
||||
<div className="font-medium">{p.fullName}</div>
|
||||
<div className="text-sm text-muted-foreground">{p.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Badge variant="status" status={passenger.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{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) => (
|
||||
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.nationalId ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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() {
|
||||
<h1 className="text-2xl font-bold">Passengers</h1>
|
||||
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -180,254 +146,218 @@ export default function PassengersPage() {
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, or phone..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
<input type="text" placeholder="Search by name, email, or phone..." className="input"
|
||||
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
|
||||
</div>
|
||||
<select
|
||||
className="input w-48"
|
||||
value={filters.verified?.toString() || ''}
|
||||
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}
|
||||
>
|
||||
<select className="input w-48" value={filters.verified?.toString() || ''}
|
||||
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
|
||||
<option value="">All Passengers</option>
|
||||
<option value="true">Verified</option>
|
||||
<option value="false">Unverified</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No passengers found"
|
||||
/>
|
||||
|
||||
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })}
|
||||
/>
|
||||
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={!!selectedPassenger}
|
||||
onClose={() => setSelectedPassenger(null)}
|
||||
title="Passenger Details"
|
||||
size="xl"
|
||||
>
|
||||
{selectedPassenger && (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<Modal isOpen={!!selectedPassenger} onClose={() => 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 (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Gender</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Identification */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Identification</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
|
||||
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge
|
||||
variant="status"
|
||||
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
|
||||
>
|
||||
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
{/* Gradient header with avatar */}
|
||||
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{p.fullName}</p>
|
||||
<p className="text-emerald-200 text-sm truncate">{p.email}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<div>
|
||||
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
|
||||
{isVerified ? '✓ Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
</div>
|
||||
{tier && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
|
||||
<Star className="w-3 h-3" />{tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Account Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">User ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loyalty & Wallet (if available) */}
|
||||
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
|
||||
<>
|
||||
<hr className="border-muted" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedPassenger.loyalty && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Tier</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-emerald-200 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPassenger.wallet && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Balance</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedPassenger(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
<div className="space-y-6">
|
||||
{/* Personal */}
|
||||
<section>
|
||||
<SectionHeader title="Personal Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Full Name" value={p.fullName} />
|
||||
<Field label="Date of Birth" value={p.dateOfBirth ? formatDate(p.dateOfBirth) : ''} />
|
||||
<Field label="Gender" value={p.gender} />
|
||||
<Field label="Nationality" value={p.nationality} />
|
||||
<Field label="Nationality Code" value={p.nationalityCode} />
|
||||
<Field label="Preferred Language" value={p.passenger?.preferredLanguage || p.preferredLanguage} />
|
||||
<Field label="Last Login" value={p.lastLoginAt ? formatDateTime(p.lastLoginAt) : 'Never'} />
|
||||
<Field label="Role" value={p.role} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<SectionHeader title="Contact Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Email" value={p.email} />
|
||||
<Field label="Phone" value={p.phone} />
|
||||
<Field label="Address" value={p.address} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Identification */}
|
||||
<section>
|
||||
<SectionHeader title="Identification & Verification" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/40 rounded-lg p-3 col-span-2 md:col-span-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">Fayda (National ID)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isVerified
|
||||
? <ShieldCheck className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
: <ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" />}
|
||||
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-muted-foreground'}`}>
|
||||
{isVerified ? 'Verified' : 'Not verified'}
|
||||
</span>
|
||||
</div>
|
||||
{p.faydaVerifiedAt && <p className="text-xs text-muted-foreground mt-1">{formatDateTime(p.faydaVerifiedAt)}</p>}
|
||||
</div>
|
||||
<Field label="Passport Number" value={p.passportNumber} mono />
|
||||
<Field label="Passport Country" value={p.passportCountry} />
|
||||
<Field label="Passport Expiry" value={p.passportExpiryDate ? formatDate(p.passportExpiryDate) : ''} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Loyalty & Wallet */}
|
||||
{(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && (
|
||||
<section>
|
||||
<SectionHeader title="Loyalty & Wallet" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{(p.passenger?.loyalty || p.loyalty) && (() => {
|
||||
const loyalty = p.passenger?.loyalty || p.loyalty;
|
||||
return (
|
||||
<>
|
||||
<div className={`rounded-lg p-3 border ${tierColor}`}>
|
||||
<p className="text-xs font-medium mb-1 opacity-70">Tier</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-sm font-bold">{loyalty.tier}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Points Balance" value={(loyalty.pointsBalance ?? 0).toLocaleString()} />
|
||||
<Field label="Lifetime Points" value={(loyalty.lifetimePoints ?? 0).toLocaleString()} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(p.passenger?.wallet || p.wallet) && (() => {
|
||||
const wallet = p.passenger?.wallet || p.wallet;
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1"><Wallet className="w-3 h-3" />Wallet Balance</p>
|
||||
<p className="text-base font-bold text-blue-800 dark:text-blue-300">
|
||||
ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<section>
|
||||
<SectionHeader title="Account IDs" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="User ID" value={p.id || p.userId} mono truncate />
|
||||
<Field label="Passenger ID" value={p.passenger?.id || p.passengerId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timestamps */}
|
||||
<section>
|
||||
<SectionHeader title="Timestamps" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Registered" value={p.createdAt ? formatDateTime(p.createdAt) : ''} />
|
||||
<Field label="Last Updated" value={p.updatedAt ? formatDateTime(p.updatedAt) : ''} />
|
||||
<Field label="Fayda Verified At" value={p.faydaVerifiedAt ? formatDateTime(p.faydaVerifiedAt) : 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedPassenger(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
{/* Export Modal */}
|
||||
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
|
||||
<div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ 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) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
<input type="checkbox" checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
className="w-4 h-4 rounded border-gray-300" />
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
|
||||
|
||||
@@ -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() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -239,9 +239,9 @@ export default function ReportsPage() {
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => 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() {
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Completed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
|
||||
|
||||
@@ -563,7 +563,7 @@ export default function SchedulesPage() {
|
||||
<option value="">Select Train</option>
|
||||
{trains.map((train: Train) => (
|
||||
<option key={train.id} value={train.id}>
|
||||
{train.name} ({train.number})
|
||||
{train.number} ({train.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -580,7 +580,7 @@ export default function SchedulesPage() {
|
||||
<option value="">Select Route</option>
|
||||
{routes.map((route: Route) => (
|
||||
<option key={route.id} value={route.id}>
|
||||
{route.name} ({route.code})
|
||||
{route.code} ({route.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -443,13 +443,12 @@ export default function SeatsPage() {
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{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 (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
{date} - {routeName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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() {
|
||||
<option value="DJ">Djibouti (DJ)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
name="lat"
|
||||
className="input"
|
||||
defaultValue={editingStation?.lat}
|
||||
step="0.0001"
|
||||
placeholder="e.g., 9.0320"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
name="lng"
|
||||
className="input"
|
||||
defaultValue={editingStation?.lng}
|
||||
step="0.0001"
|
||||
placeholder="e.g., 38.7469"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Timezone *</label>
|
||||
<select
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LogIn, Trash2 } from 'lucide-react';
|
||||
import { LogIn, ListCollapse, Trash2, Printer } from 'lucide-react';
|
||||
import { Download } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
@@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
@@ -18,6 +18,8 @@ export default function TicketsPage() {
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
||||
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
|
||||
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
|
||||
const [ticketToPrint, setTicketToPrint] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
@@ -88,9 +90,75 @@ export default function TicketsPage() {
|
||||
};
|
||||
|
||||
const handleConfirmBoard = async () => {
|
||||
if (ticketToBoard) {
|
||||
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
|
||||
}
|
||||
if (!ticketToBoard) return;
|
||||
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
|
||||
printBoardingPass(ticketToBoard, 'outbound');
|
||||
};
|
||||
|
||||
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
|
||||
const w = window.open('', '_blank', 'width=520,height=460');
|
||||
if (!w) return;
|
||||
const isInbound = leg === 'inbound';
|
||||
const origin = isInbound
|
||||
? (ticket.booking?.returnOriginStation?.name || ticket.schedule?.destinationStation?.name || 'N/A')
|
||||
: (ticket.schedule?.originStation?.name || 'N/A');
|
||||
const dest = isInbound
|
||||
? (ticket.booking?.returnDestinationStation?.name || ticket.schedule?.originStation?.name || 'N/A')
|
||||
: (ticket.schedule?.destinationStation?.name || 'N/A');
|
||||
const date = isInbound
|
||||
? (ticket.booking?.returnBoardedAt ? new Date(ticket.booking.returnBoardedAt).toLocaleString() : 'N/A')
|
||||
: (ticket.schedule?.departureAt ? new Date(ticket.schedule.departureAt).toLocaleString() : 'N/A');
|
||||
const seat = ticket.seat?.seatNumber || 'N/A';
|
||||
const coach = ticket.seat?.coach?.number || 'N/A';
|
||||
const bookingRef = ticket.booking?.bookingRef || 'N/A';
|
||||
const ticketNum = ticket.ticketNumber || 'N/A';
|
||||
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
|
||||
w.document.write(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
|
||||
'*{box-sizing:border-box;margin:0;padding:0}' +
|
||||
'body{font-family:"Segoe UI",sans-serif;background:#f0fdf4;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}' +
|
||||
'.pass{background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12);width:460px}' +
|
||||
'.header{background:linear-gradient(135deg,#10b981,#059669);color:#fff;padding:24px 28px 20px}' +
|
||||
'.header-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' +
|
||||
'.airline{font-size:12px;letter-spacing:2px;text-transform:uppercase;opacity:.85}' +
|
||||
'.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:3px 12px;font-size:11px;letter-spacing:1px}' +
|
||||
'.route{display:flex;align-items:center;gap:8px;margin-top:14px}' +
|
||||
'.city{font-size:24px;font-weight:700}' +
|
||||
'.arrow{font-size:20px;opacity:.7;flex:1;text-align:center}' +
|
||||
'.body{padding:24px 28px}' +
|
||||
'.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}' +
|
||||
'.field label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#6b7280;font-weight:600}' +
|
||||
'.field p{font-size:14px;font-weight:600;color:#111827;margin-top:3px}' +
|
||||
'.divider{border:none;border-top:2px dashed #d1fae5;margin:20px 0}' +
|
||||
'.footer{display:flex;justify-content:space-between;align-items:center}' +
|
||||
'.seat-box{background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:8px 20px;text-align:center}' +
|
||||
'.seat-box label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#059669;font-weight:700}' +
|
||||
'.seat-box p{font-size:28px;font-weight:800;color:#065f46}' +
|
||||
'@media print{body{background:#fff}.pass{box-shadow:none}}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="pass">' +
|
||||
'<div class="header">' +
|
||||
'<div class="header-top"><span class="airline">EDR — Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
|
||||
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">🡢</span><span class="city">' + dest + '</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="body">' +
|
||||
'<div class="grid">' +
|
||||
'<div class="field"><label>Booking Ref</label><p>' + bookingRef + '</p></div>' +
|
||||
'<div class="field"><label>Ticket No.</label><p>' + ticketNum + '</p></div>' +
|
||||
'<div class="field"><label>Date & Time</label><p>' + date + '</p></div>' +
|
||||
'<div class="field"><label>Coach</label><p>' + coach + '</p></div>' +
|
||||
'</div>' +
|
||||
'<hr class="divider"/>' +
|
||||
'<div class="footer">' +
|
||||
'<div class="field"><label>Passenger</label><p>' + passenger + '</p></div>' +
|
||||
'<div class="seat-box"><label>Seat</label><p>' + seat + '</p></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
|
||||
'</body></html>'
|
||||
);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ticket: any) => {
|
||||
@@ -104,6 +172,19 @@ export default function TicketsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const TICKET_COLS = [
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
{ key: 'passenger', label: 'Passenger Name' },
|
||||
{ key: 'trip', label: 'Trip (Origin - Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded' },
|
||||
];
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(selectedColumns)
|
||||
.filter(([, selected]) => selected)
|
||||
@@ -125,19 +206,20 @@ export default function TicketsPage() {
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
TICKET_COLS.map(c => `"${c.label}"`).join(','),
|
||||
...exportItems.map((ticket: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || '';
|
||||
case 'booking': return ticket.booking?.bookingRef || '';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
||||
case 'coach': return ticket.seat?.coach?.number || '';
|
||||
case 'seat': return ticket.seat?.seatNumber || '';
|
||||
case 'seatClass': return ticket.seat?.coach?.coachType?.name || '';
|
||||
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
|
||||
case 'status': return ticket.status || '';
|
||||
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
|
||||
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
|
||||
switch (key) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
|
||||
case 'booking': return ticket.booking?.bookingRef || 'N/A';
|
||||
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
|
||||
case 'coach': return ticket.seat?.coach?.number || 'N/A';
|
||||
case 'seat': return ticket.seat?.seatNumber || 'N/A';
|
||||
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
|
||||
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
|
||||
case 'status': return ticket.status || 'N/A';
|
||||
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
@@ -160,17 +242,22 @@ export default function TicketsPage() {
|
||||
label: 'Ticket Number',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
|
||||
<div>
|
||||
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.booking?.passenger?.fullName || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'booking',
|
||||
label: 'Booking',
|
||||
key: 'contact',
|
||||
label: 'Contact',
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
|
||||
<div className="font-medium">{ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
|
||||
<div className="text-xs text-muted-foreground">{ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -178,16 +265,23 @@ export default function TicketsPage() {
|
||||
{
|
||||
key: 'trip',
|
||||
label: 'Trip',
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||
render: (ticket: any) => {
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
|
||||
{isRoundTrip && (
|
||||
<span> → {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seat',
|
||||
@@ -195,54 +289,29 @@ export default function TicketsPage() {
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'Amount',
|
||||
sortable: true,
|
||||
render: (ticket: any) => formatCurrency(ticket.booking?.totalMinor || 0, ticket.booking?.currency || 'ETB'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<Badge variant="status" status={ticket.status || 'ACTIVE'}>
|
||||
{ticket.status || 'ACTIVE'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
key: 'boardingTimes',
|
||||
label: 'Boarding Times',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
|
||||
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
|
||||
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
|
||||
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
|
||||
};
|
||||
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
|
||||
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
const outbound = ticket.booking?.outboundBoardedAt;
|
||||
const inbound = ticket.booking?.returnBoardedAt;
|
||||
const hasAny = outbound || inbound;
|
||||
if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-sm">
|
||||
<span className={outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
|
||||
➡ {outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
|
||||
</span>
|
||||
<span className={inbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
|
||||
⬅ {inbound ? formatDateTimeShort(inbound) : 'Not boarded'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -253,7 +322,25 @@ export default function TicketsPage() {
|
||||
onClick: handleBoard,
|
||||
variant: 'primary' as const,
|
||||
icon: LogIn,
|
||||
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
|
||||
show: (ticket: any) => {
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isRoundTrip) {
|
||||
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
|
||||
const returnLegStatus = ticket.booking?.returnLegStatus;
|
||||
return !ticket.validatedAt || (!inboundBoarded && returnLegStatus !== 'BOTH_USED');
|
||||
}
|
||||
return !ticket.validatedAt;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Print BP',
|
||||
onClick: (ticket: any) => {
|
||||
setTicketToPrint(ticket);
|
||||
setPrintBpModalOpen(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Printer,
|
||||
show: (ticket: any) => !!ticket.validatedAt || !!ticket.booking?.outboundBoardedAt || !!ticket.booking?.returnBoardedAt,
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
@@ -262,6 +349,7 @@ export default function TicketsPage() {
|
||||
setDetailsModalOpen(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: ListCollapse,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
@@ -280,7 +368,7 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -366,17 +454,67 @@ export default function TicketsPage() {
|
||||
emptyMessage="No tickets found"
|
||||
/>
|
||||
|
||||
{/* Board Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
{/* Board Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={boardConfirmOpen}
|
||||
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
|
||||
onConfirm={handleConfirmBoard}
|
||||
title="Board Ticket"
|
||||
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
|
||||
confirmText="Board"
|
||||
cancelText="Cancel"
|
||||
isLoading={boardMutation.isPending}
|
||||
/>
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Are you sure you want to board ticket {ticketToBoard?.ticketNumber}?</p>
|
||||
<p className="text-sm text-muted-foreground">This will mark the ticket as USED.</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
|
||||
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Print BP Modal */}
|
||||
<Modal
|
||||
isOpen={printBpModalOpen}
|
||||
onClose={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}
|
||||
title="Print Boarding Pass"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const isRoundTrip = ticketToPrint?.booking?.bookingType === 'ROUND_TRIP' || ticketToPrint?.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const outboundBoarded = !!ticketToPrint?.booking?.outboundBoardedAt || !!ticketToPrint?.validatedAt;
|
||||
const inboundBoarded = !!ticketToPrint?.booking?.returnBoardedAt;
|
||||
if (isRoundTrip && outboundBoarded && inboundBoarded) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Select which leg to print:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, 'outbound'); setPrintBpModalOpen(false); }}>
|
||||
Outbound
|
||||
</ActionButton>
|
||||
<ActionButton icon={Printer} variant="secondary" onClick={() => { printBoardingPass(ticketToPrint, 'inbound'); setPrintBpModalOpen(false); }}>
|
||||
Inbound (Return)
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const leg = isRoundTrip && inboundBoarded && !outboundBoarded ? 'inbound' : 'outbound';
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Print {leg === 'inbound' ? 'inbound (return)' : 'outbound'} boarding pass for ticket {ticketToPrint?.ticketNumber}?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}>Cancel</ActionButton>
|
||||
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, leg); setPrintBpModalOpen(false); }}>Print</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,9 +4,17 @@ import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
|
||||
const keys = roles.map((r) => r.key ?? '');
|
||||
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
|
||||
if (keys.some((k) => k.includes('agent'))) return 'AGENT';
|
||||
return 'SUPERVISOR';
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AdminUser | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -17,6 +25,7 @@ interface AuthState {
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
initialize: () => {
|
||||
@@ -27,57 +36,47 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
set({ user, token, isAuthenticated: true });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_refresh_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
try {
|
||||
console.log('Attempting login to:', `${API_URL}/auth/login`);
|
||||
const response = await axios.post(`${API_URL}/auth/login`, { email, password });
|
||||
console.log('Full response:', response.data);
|
||||
|
||||
// Backend wraps response in { success, data: { token, user }, timestamp }
|
||||
const responseData = response.data.data || response.data;
|
||||
|
||||
if (!responseData || !responseData.token || !responseData.user) {
|
||||
console.error('Invalid response structure:', response.data);
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
const { token, user: apiUser } = responseData;
|
||||
|
||||
const user: AdminUser = {
|
||||
id: apiUser.id,
|
||||
email: apiUser.email,
|
||||
fullName: apiUser.fullName,
|
||||
role: apiUser.role,
|
||||
active: true,
|
||||
};
|
||||
|
||||
console.log('Login successful! User:', user);
|
||||
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
|
||||
set({ user, token, isAuthenticated: true });
|
||||
} catch (error: any) {
|
||||
console.error('Login error details:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
// Step 1: IAM login — returns token + refreshToken only
|
||||
const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
|
||||
const loginData = loginRes.data?.data ?? loginRes.data;
|
||||
const { token, refreshToken } = loginData;
|
||||
if (!token) throw new Error('No token received from server');
|
||||
|
||||
// Step 2: fetch full user info with the token
|
||||
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const iamUser = meRes.data?.data ?? meRes.data;
|
||||
|
||||
const user: AdminUser = {
|
||||
id: iamUser.id,
|
||||
email: iamUser.email,
|
||||
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
|
||||
role: mapIamRole(iamUser.roles ?? []),
|
||||
active: true,
|
||||
};
|
||||
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
|
||||
|
||||
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_refresh_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
setUser: (user: AdminUser, token: string) => {
|
||||
|
||||
@@ -22,6 +22,13 @@ export const formatDateTime = (date?: string | Date | null): string => {
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeShort = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'dd MMM yy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeLocal = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
@@ -34,7 +41,7 @@ export const getStatusColor = (status: string): string => {
|
||||
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
COMPLETED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
BOARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
PAID: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
REFUNDED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Booking {
|
||||
passengerId: string;
|
||||
passenger?: Passenger.IPassenger;
|
||||
scheduleId: string;
|
||||
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' | 'REFUNDED';
|
||||
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED' | 'NO_SHOW' | 'REFUNDED';
|
||||
currency: string;
|
||||
totalMinor: number;
|
||||
adultCount: number;
|
||||
@@ -33,8 +33,8 @@ export interface Station {
|
||||
countryCode?: string;
|
||||
isOperational: boolean;
|
||||
timezone: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface Booking {
|
||||
passengerId: string;
|
||||
passenger?: Passenger.IPassenger;
|
||||
tripId: string;
|
||||
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED';
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
paymentStatus: Passenger.PaymentStatus;
|
||||
|
||||
@@ -37,4 +37,4 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function DmoneySuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { bookingId } = useBookingStore();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
|
||||
// D-Money callback query params (mirrors Telebirr)
|
||||
const orderid = searchParams.get('orderid') || '';
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
|
||||
|
||||
useEffect(() => {
|
||||
const confirm = async () => {
|
||||
try {
|
||||
if (bookingIdQp) {
|
||||
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
|
||||
paymentReference: orderid || trxRef,
|
||||
paymentMethod: 'DMONEY',
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push('/booking/confirmation'), 1500);
|
||||
} catch (err: any) {
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push('/booking/confirmation'), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
confirm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your D-Money payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your D-Money payment was received.</p>
|
||||
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
|
||||
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation…</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
|
||||
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
|
||||
<button onClick={() => router.push('/booking/confirmation')}
|
||||
className="btn-primary w-full">Go to confirmation</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DmoneySuccessPage() {
|
||||
return <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}><DmoneySuccessContent /></Suspense>;
|
||||
}
|
||||
@@ -150,68 +150,40 @@ export default function ReviewPage() {
|
||||
return apiClient.post(endpoint, data);
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
console.log('=== API RESPONSE SUCCESS ===');
|
||||
console.log('Response Data:', JSON.stringify(data, null, 2));
|
||||
console.log('Booking created successfully:', data);
|
||||
const bookingIdValue = data.bookingId || data.id;
|
||||
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
|
||||
|
||||
console.log('Setting booking ID:', bookingIdValue);
|
||||
console.log('Setting PNR:', pnrValue);
|
||||
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||
|
||||
setBookingId(bookingIdValue);
|
||||
setPNR(pnrValue);
|
||||
|
||||
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
|
||||
|
||||
console.log('Total amount:', totalAmount);
|
||||
|
||||
const totalAmount = data.totalMinor || data.totalAmount || 0;
|
||||
setTimeout(() => {
|
||||
const currentState = useBookingStore.getState();
|
||||
console.log('Current booking store state:', currentState);
|
||||
console.log('bookingId:', currentState.bookingId);
|
||||
console.log('pnr:', currentState.pnr);
|
||||
|
||||
if (totalAmount > 0) {
|
||||
console.log('Redirecting to payment page');
|
||||
router.push('/booking/payment');
|
||||
} else {
|
||||
console.log('Redirecting to confirmation page');
|
||||
router.push('/booking/confirmation');
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.log('=== API RESPONSE ERROR ===');
|
||||
console.error('Error Object:', error);
|
||||
console.error('Error Response:', error?.response);
|
||||
console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2));
|
||||
console.error('Error Status:', error?.response?.status);
|
||||
console.error('Error Message:', error?.message);
|
||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
|
||||
alert(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
console.log('handleConfirm called');
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
console.log('Search criteria:', searchCriteria);
|
||||
console.log('Seat hold:', seatHold);
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Outbound schedule:', outboundSchedule);
|
||||
console.log('Inbound schedule:', inboundSchedule);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) {
|
||||
console.error('No seat hold found');
|
||||
if (!seatHold?.holdId) {
|
||||
alert('Please select seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoundTrip && !seatHold.returnHoldId) {
|
||||
alert('Please select return seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
@@ -247,37 +219,24 @@ export default function ReviewPage() {
|
||||
console.log('Token found, length:', token.length);
|
||||
|
||||
let passengerId = getPassengerIdFromToken(token);
|
||||
console.log('Extracted passenger ID from JWT token:', passengerId);
|
||||
|
||||
// Fallback 1: Use passengerId from booking store
|
||||
if (!passengerId && storedPassengerId) {
|
||||
passengerId = storedPassengerId;
|
||||
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
|
||||
}
|
||||
|
||||
// Fallback 2: Use passengerId from localStorage
|
||||
if (!passengerId && typeof window !== 'undefined') {
|
||||
const localStoragePassengerId = localStorage.getItem('booking_passengerId');
|
||||
if (localStoragePassengerId) {
|
||||
passengerId = localStoragePassengerId;
|
||||
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
|
||||
}
|
||||
if (localStoragePassengerId) passengerId = localStoragePassengerId;
|
||||
}
|
||||
|
||||
// Fallback 3: Use passengerId from user object
|
||||
if (!passengerId && user) {
|
||||
passengerId = (user as any).passengerId;
|
||||
console.log('Fallback 3: Using passengerId from user object:', passengerId);
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
console.error('Failed to extract passengerId');
|
||||
console.error('User object:', user);
|
||||
console.error('User object keys:', user ? Object.keys(user) : 'null');
|
||||
console.error('Stored passengerId from booking store:', storedPassengerId);
|
||||
if (typeof window !== 'undefined') {
|
||||
console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId'));
|
||||
}
|
||||
throw new Error('Passenger ID not found in authentication token. Please log in again.');
|
||||
}
|
||||
|
||||
@@ -312,7 +271,7 @@ export default function ReviewPage() {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
@@ -356,7 +315,7 @@ export default function ReviewPage() {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
@@ -407,31 +366,12 @@ export default function ReviewPage() {
|
||||
|
||||
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
|
||||
console.log('Selected schedule:', displaySchedule);
|
||||
console.log('Base fare adult:', displaySchedule?.baseFareAdult);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (outboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (inboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult ||
|
||||
(selectedSchedule as any)?.fareAdult ||
|
||||
(selectedSchedule as any)?.price ||
|
||||
0;
|
||||
|
||||
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
|
||||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
return sum + farePerPassenger;
|
||||
}, 0);
|
||||
|
||||
console.log('Calculated base fare:', baseFare);
|
||||
|
||||
const total = baseFare;
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user