This commit is contained in:
Roba Boru
2026-06-24 11:53:32 +03:00
89 changed files with 18718 additions and 10470 deletions

View File

@@ -2,17 +2,46 @@
NODE_ENV=development NODE_ENV=development
PORT=4000 PORT=4000
# Database (Prisma) # Database (Prisma) — owns the `passenger` schema in edr_database
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger 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 # CORS
FRONTEND_URL=http://localhost:5174 FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184 BACK_OFFICE_URL=http://localhost:5184
# JWT # JWT (legacy passenger auth — being replaced by IAM)
JWT_SECRET=edr-platform-secret-change-in-production JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d 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
SENDGRID_API_KEY= SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com SENDGRID_FROM_EMAIL=noreply@edr-platform.com

View File

@@ -11,6 +11,8 @@
"test": "jest", "test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit", "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:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy", "prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",
@@ -27,26 +29,30 @@
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19", "@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4", "@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^11.1.24", "@nestjs/microservices": "^11.1.24",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
"@nestjs/typeorm": "^11.0.1",
"@prisma/client": "^6.19.3", "@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", "axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jose": "^5.10.0", "jose": "^5.10.0",
"passport": "^0.7.0", "pg": "^8.21.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",
"uuid": "^10.0.0" "uuid": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -55,11 +61,9 @@
"@nestjs/cli": "^11.0.21", "@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0", "@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19", "@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",

View File

@@ -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");

View File

@@ -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;

View File

@@ -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");

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
-- RenameIndex
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";

View File

@@ -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 $$;

View File

@@ -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;

View File

@@ -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";

View File

@@ -0,0 +1,282 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- DropIndex
DROP INDEX "Journey_bookingId_idx";
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY';
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -260,15 +260,8 @@ model User {
faydaVerifiedAt DateTime? faydaVerifiedAt DateTime?
faydaSub String? @unique faydaSub String? @unique
passenger Passenger? sessions Session[]
agent Agent?
sessions Session[]
devices Device[]
preferences UserPreferences?
auditLogs AuditLog[]
fraudAlerts FraudAlert[]
faydaVerificationSessions FaydaVerificationSession[]
@@schema("passenger") @@schema("passenger")
} }
@@ -286,10 +279,12 @@ model Session {
} }
model Passenger { model Passenger {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String? @unique
iamUserId String? @unique
defaultTravelerProfileId String? defaultTravelerProfileId String?
preferredLanguage String? preferredLanguage String?
blockedUntil DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
bookings Booking[] bookings Booking[]
@@ -298,8 +293,9 @@ model Passenger {
notifications Notification[] notifications Notification[]
travelerProfiles TravelerProfile[] travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[] savedRoutes SavedRoute[]
packageBookings PackageBooking[]
@@index([userId]) @@index([userId])
@@index([iamUserId])
@@schema("passenger") @@schema("passenger")
} }
@@ -376,6 +372,8 @@ model TrainSchedule {
liveStatus TripLiveStatus? liveStatus TripLiveStatus?
menuItems MenuItem[] menuItems MenuItem[]
journeySegments JourneySegment[] journeySegments JourneySegment[]
outboundPackages TravelPackage[] @relation("PackageOutbound")
returnPackages TravelPackage[] @relation("PackageReturn")
@@index([departureAt, originStationId]) @@index([departureAt, originStationId])
@@schema("passenger") @@schema("passenger")
@@ -895,7 +893,7 @@ model SupportMessage {
model UserPreferences { model UserPreferences {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique iamUserId String @unique
pushEnabled Boolean @default(true) pushEnabled Boolean @default(true)
emailEnabled Boolean @default(true) emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false) smsEnabled Boolean @default(false)
@@ -908,19 +906,19 @@ model UserPreferences {
locale String @default("en") locale String @default("en")
darkMode Boolean @default(false) darkMode Boolean @default(false)
language String @default("en") language String @default("en")
user User @relation(fields: [userId], references: [id])
@@schema("passenger") @@schema("passenger")
} }
model Device { model Device {
id String @id @default(uuid()) id String @id @default(uuid())
userId String iamUserId String
platform DevicePlatform platform DevicePlatform
name String name String
pushToken String? pushToken String?
trusted Boolean @default(false) trusted Boolean @default(false)
lastSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@schema("passenger") @@schema("passenger")
} }
@@ -1063,16 +1061,16 @@ model SegmentFareRule {
model Agent { model Agent {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique iamUserId String? @unique
agentCode String @unique agentCode String @unique
stationId String? stationId String?
commissionRate Int @default(5) commissionRate Int @default(5)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
bookings AgentBooking[] bookings AgentBooking[]
shifts AgentShift[] shifts AgentShift[]
commissions AgentCommission[] commissions AgentCommission[]
@@index([iamUserId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1191,19 +1189,17 @@ model BaggageBooking {
} }
model AuditLog { model AuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? iamUserId String?
action String action String
entityType String entityType String
entityId String? entityId String?
oldData Json? oldData Json?
newData Json? newData Json?
ipAddress String? ipAddress String?
userAgent String? userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id]) @@index([iamUserId, createdAt])
@@index([userId, createdAt])
@@index([entityType, entityId]) @@index([entityType, entityId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1259,16 +1255,14 @@ model FraudRule {
model FraudAlert { model FraudAlert {
id String @id @default(uuid()) id String @id @default(uuid())
userId String iamUserId String
eventType String eventType String
triggeredRules String[] triggeredRules String[]
context Json context Json
severity String @default("MEDIUM") severity String @default("MEDIUM")
acknowledged Boolean @default(false) acknowledged Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([iamUserId, createdAt])
@@index([userId, createdAt])
@@index([acknowledged]) @@index([acknowledged])
@@schema("passenger") @@schema("passenger")
} }
@@ -1338,14 +1332,144 @@ model FaydaVerificationSession {
expiresAt DateTime expiresAt DateTime
completedAt DateTime? completedAt DateTime?
userId String? iamUserId String?
bookingId String? bookingId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([iamUserId])
@@index([userId])
@@index([bookingId]) @@index([bookingId])
@@index([state]) @@index([state])
@@index([expiresAt]) @@index([expiresAt])
@@schema("passenger") @@schema("passenger")
} }
model SystemConfig {
id String @id @default(uuid())
key String @unique
value String
updatedAt DateTime @updatedAt
@@schema("passenger")
}
enum PackageStatus {
DRAFT
ACTIVE
SOLD_OUT
EXPIRED
CANCELLED
@@schema("passenger")
}
model TravelPackage {
id String @id @default(uuid())
code String @unique
name String
description String?
status PackageStatus @default(DRAFT)
outboundScheduleId String
returnScheduleId String
originStationId String
destinationStationId String
boardingTime DateTime
departureTime DateTime
arrivalTime DateTime
totalCapacity Int
bookedCount Int @default(0)
includedServices Json
coachConfiguration String?
busTransferIncluded Boolean @default(false)
busTransferRoute String?
validFrom DateTime
validUntil DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
priceTiers PackagePriceTier[]
bookings PackageBooking[]
@@index([status, validFrom])
@@schema("passenger")
}
model PackagePriceTier {
id String @id @default(uuid())
packageId String
seatType String
label String
priceMinor Int
currency String @default("ETB")
availableSeats Int @default(0)
bookedSeats Int @default(0)
package TravelPackage @relation(fields: [packageId], references: [id])
bookings PackageBooking[]
@@unique([packageId, seatType])
@@schema("passenger")
}
model PackageBooking {
id String @id @default(uuid())
bookingRef String @unique
packageId String
priceTierId String
passengerId String?
contactEmail String?
contactPhone String?
status BookingStatus @default(PENDING_PAYMENT)
passengerCount Int @default(1)
totalMinor Int
currency String @default("ETB")
displayCurrency Currency?
displayTotalMinor Int?
promoCode String?
source String @default("WEB")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
package TravelPackage @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
passenger Passenger? @relation(fields: [passengerId], references: [id])
passengers PackageBookingPassenger[]
paymentIntent PackagePaymentIntent?
@@index([packageId, status])
@@schema("passenger")
}
model PackageBookingPassenger {
id String @id @default(uuid())
bookingId String
passengerName String
dateOfBirth DateTime?
idDocumentType IdDocumentType?
idDocumentNumber String?
passportNumber String?
passportCountry String?
seatLabel String?
booking PackageBooking @relation(fields: [bookingId], references: [id])
@@schema("passenger")
}
model PackagePaymentIntent {
id String @id @default(uuid())
packageBookingId String @unique
amountMinor Int
currency String @default("ETB")
method PaymentMethodType
status PaymentIntentStatus @default(REQUIRES_ACTION)
providerRef String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
packageBooking PackageBooking @relation(fields: [packageBookingId], references: [id])
@@schema("passenger")
}

View File

@@ -670,6 +670,70 @@ async function seedFraudRules() {
console.log(`${rules.length} fraud detection rules created`); console.log(`${rules.length} fraud detection rules created`);
} }
async function seedKulubbiPackage() {
console.log('\n🚆 Seeding Kulubbi Gabriel 2025 package...');
const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } });
const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } });
if (!addisStation || !direDawaStation) {
console.log(' ⚠️ Stations not found, skipping Kulubbi package seed');
return;
}
// Use the first two schedules as outbound/return (or create dedicated ones)
const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } });
if (schedules.length < 2) {
console.log(' ⚠️ Not enough schedules found, skipping Kulubbi package seed');
return;
}
const [outboundSchedule, returnSchedule] = schedules;
await prisma.travelPackage.upsert({
where: { code: 'KULUBBI-2025' },
update: {},
create: {
code: 'KULUBBI-2025',
name: 'Kulubbi Gabriel Pilgrimage Package',
description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.',
outboundScheduleId: outboundSchedule.id,
returnScheduleId: returnSchedule.id,
originStationId: addisStation.id,
destinationStationId: direDawaStation.id,
boardingTime: new Date('2025-07-24T07:00:00+03:00'),
departureTime: new Date('2025-07-24T09:00:00+03:00'),
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
totalCapacity: 912,
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
busTransferIncluded: true,
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
validFrom: new Date('2025-07-01'),
validUntil: new Date('2025-07-24T09:00:00+03:00'),
status: 'ACTIVE',
includedServices: [
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',
'Lunch served on board',
'Refreshments and bottled water',
'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)',
'Onboard first aid and medical support',
'Entertainment (audio/video)',
'Service briefing and pilgrimage guidance',
'Pick-up and drop-off coordination',
],
priceTiers: {
create: [
{ seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 },
{ seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 },
{ seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 },
{ seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 },
{ seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 },
{ seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 },
],
},
},
});
console.log(' ✅ Kulubbi Gabriel 2025 package created');
}
// Run a seed step in isolation: if it throws (FK conflict, duplicate row, // Run a seed step in isolation: if it throws (FK conflict, duplicate row,
// missing record, etc.) log the error and keep going so the rest of the seed — // missing record, etc.) log the error and keep going so the rest of the seed —
// and the API startup that follows it — are never blocked by one bad step. // and the API startup that follows it — are never blocked by one bad step.
@@ -691,7 +755,8 @@ async function main() {
['fare rules', seedFareRules], ['fare rules', seedFareRules],
['segment fares', seedSegmentFares], ['segment fares', seedSegmentFares],
['currency', seedCurrency], ['currency', seedCurrency],
['notification templates', seedNotificationTemplates] ['notification templates', seedNotificationTemplates],
['kulubbi package', seedKulubbiPackage],
]; ];
let failed = 0; let failed = 0;

View 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);
});

View 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);
});

View File

@@ -1,14 +1,29 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import {
import { ConfigModule } from '@nestjs/config'; MiddlewareConsumer,
Module,
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter'; 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 { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module'; import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module'; import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware'; import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config'; import appConfig from './config/app.config';
import dbConfig from './config/database.config'; import dbConfig from './config/database.config';
import iamDatabaseConfig from './config/iam-database.config';
import telebirrConfig from './config/telebirr.config'; import telebirrConfig from './config/telebirr.config';
import cbeConfig from './config/cbe.config'; import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config'; import ebirrConfig from './config/ebirr.config';
@@ -42,6 +57,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.module'; import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.module'; import { CurrenciesModule } from './modules/currencies/currencies.module';
import { SystemConfigModule } from './modules/system-config/system-config.module';
import { PackagesModule } from './modules/packages/packages.module';
@Module({ @Module({
imports: [ imports: [
@@ -50,6 +67,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
load: [ load: [
appConfig, appConfig,
dbConfig, dbConfig,
iamDatabaseConfig,
telebirrConfig, telebirrConfig,
cbeConfig, cbeConfig,
ebirrConfig, ebirrConfig,
@@ -61,11 +79,20 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
EventEmitterModule.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, PrismaModule,
AuditModule, AuditModule,
I18nModule, I18nModule,
IamModule, AuthModule,
AuthModule,
StationsModule, StationsModule,
FleetModule, FleetModule,
SchedulesModule, SchedulesModule,
@@ -91,10 +118,28 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
VerifaydaModule, VerifaydaModule,
AuditModuleFeature, AuditModuleFeature,
CurrenciesModule, CurrenciesModule,
SystemConfigModule,
PackagesModule,
],
providers: [
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) { constructor(
consumer.apply(LocaleMiddleware).forRoutes('*'); 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();
} }
} }

View File

@@ -23,7 +23,7 @@ export class AuditService {
await this.prisma.auditLog.create({ await this.prisma.auditLog.create({
data: { data: {
userId: input.userId, iamUserId: input.userId,
action: input.action, action: input.action,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
@@ -62,8 +62,7 @@ export class AuditService {
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
{ entityId: { contains: filters.search, mode: 'insensitive' } }, { entityId: { contains: filters.search, mode: 'insensitive' } },
{ user: { email: { contains: filters.search, mode: 'insensitive' } } }, { iamUserId: { contains: filters.search, mode: 'insensitive' } },
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
]; ];
} }
@@ -77,16 +76,12 @@ export class AuditService {
return this.prisma.auditLog.findMany({ return this.prisma.auditLog.findMany({
where, where,
include: { user: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 500, // Limit to last 500 logs take: 500,
}); });
} }
async getLog(id: string) { async getLog(id: string) {
return this.prisma.auditLog.findUnique({ return this.prisma.auditLog.findUnique({ where: { id } });
where: { id },
include: { user: true },
});
} }
} }

View File

@@ -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);
});
});
});

View File

@@ -1,144 +1 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
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);
}
};
};

View 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'],
};
}

View File

@@ -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 {}

View File

@@ -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 { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { PrismaService } from '../prisma.service'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
@@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor {
private readonly inactivityMinutes: number; private readonly inactivityMinutes: number;
constructor( constructor(
private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService, private readonly config: ConfigService,
) { ) {
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10); 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>> { async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse(); const response = context.switchToHttp().getResponse();
const user = request.user; const sessionId: string | undefined = request.user?.sessionId;
if (user?.userId) { if (sessionId) {
const session = await this.prisma.session.findFirst({ const rows = await this.dataSource.query<Array<{ expiry_time: Date }>>(
where: { userId: user.userId }, `SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`,
orderBy: { lastActivityAt: 'desc' }, [sessionId],
}); );
if (session) { if (rows.length) {
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000; const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000;
if (minutesLeft < this.inactivityMinutes * 0.2) {
if (inactiveMinutes > this.inactivityMinutes) { response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString());
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException('Session expired due to inactivity');
} }
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes); // Extend session on every authenticated request
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString()); await this.dataSource.query(
`UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`,
await this.prisma.session.update({ [this.inactivityMinutes, sessionId],
where: { id: session.id }, );
data: { lastActivityAt: new Date() },
});
} }
} }

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common'; // Compatibility alias while passenger auth moves to @tria-plc IAM.
import { AuthGuard } from '@nestjs/passport'; // Existing controllers can keep importing `../../common/jwt.guard`, but the
// guard now validates IAM-issued session tokens from `iam.sessions`.
@Injectable() export { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
export class JwtGuard extends AuthGuard('jwt') {}

View File

@@ -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 };
}
}

View 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);

View File

@@ -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;
}

View File

@@ -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}`);
}

View File

@@ -1,5 +1,4 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -1,6 +1,5 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from './roles.decorator'; import { ROLES_KEY } from './roles.decorator';
@Injectable() @Injectable()
@@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [ const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (!requiredRoles) return true; if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest(); 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,
);
} }
} }

View 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(),
);

View File

@@ -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 "reflect-metadata";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common"; import { ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; 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. // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true }); 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({ app.enableCors({
origin: [ origin: [
process.env.PORTAL_URL ?? "http://localhost:5174", process.env.PORTAL_URL ?? "http://localhost:5174",

View File

@@ -2,39 +2,37 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; // IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
import { UserRole } from '@prisma/client'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents') @ApiTags('Agents')
@Controller('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') @ApiBearerAuth('IAM-auth')
export class AgentsController { export class AgentsController {
constructor(private service: AgentsService) {} constructor(private service: AgentsService) {}
@Post('bookings') @Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' }) @ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) { createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto); return this.service.createAgentBooking(dto);
} }
@Post('shifts/open') @Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' }) @ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) { openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto); return this.service.openShift(dto);
} }
@Post('shifts/close') @Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' }) @ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) { closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto); return this.service.closeShift(dto);
} }
@Get(':agentId/commissions') @Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' }) @ApiOperation({ summary: 'Get agent commissions' })
getCommissions( getCommissions(
@Param('agentId') agentId: string, @Param('agentId') agentId: string,
@@ -49,7 +47,6 @@ export class AgentsController {
} }
@Get(':agentId/shifts') @Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' }) @ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) { getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId); return this.service.getShifts(agentId);

View File

@@ -13,9 +13,13 @@ export class AgentsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async createAgentBooking(dto: CreateAgentBookingDto) { 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 || !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 } }); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
@@ -30,7 +34,7 @@ export class AgentsService {
const booking = await this.prisma.booking.create({ const booking = await this.prisma.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: agent.user.passenger.id, passengerId: passenger.id,
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT', status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor, totalMinor,

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AuditService } from '../../common/audit.service'; 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') @ApiTags('Audit')
@Controller('audit') @Controller('audit')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class AuditController { export class AuditController {
constructor(private auditService: AuditService) {} constructor(private auditService: AuditService) {}

View File

@@ -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 { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard'; 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') @ApiTags('Auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private service: AuthService) {} constructor(private passengerAuthService: PassengerAuthService) {}
@Post('register') @Post('register')
@ApiOperation({ @IsPublic()
summary: 'Register new passenger account', @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. Returns token + user.' })
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto }) @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') @Post('login')
@IsPublic()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({ summary: 'Login with email and password' })
summary: 'Login with email and password', @ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' })
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: 401, description: 'Invalid credentials' })
})
@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' })
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); } login(@Request() req: any, @Body() dto: LoginDto) {
return this.passengerAuthService.login(dto, req);
@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); }
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({ summary: 'Logout current user' })
summary: 'Logout current user', @ApiResponse({ status: 200, description: 'Logout successful' })
description: `Logout the authenticated user and invalidate their session. @ApiResponse({ status: 401, description: 'Unauthorized' })
### 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) { logout(@Request() req: any) {
if (!req.user || !req.user.userId) { if (!req.user?.id) throw new UnauthorizedException('User not authenticated');
throw new UnauthorizedException('User not authenticated'); return this.passengerAuthService.logout(req.user, req);
} }
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') @Get('profile')
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({ summary: 'Get current user profile' })
summary: 'Get current user profile', @ApiResponse({ status: 200, description: 'User profile retrieved successfully' })
description: `**Returns complete user profile with all connected data** @ApiResponse({ status: 401, description: 'Unauthorized' })
---
### 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) { getProfile(@Request() req: any) {
console.log('Profile request - User from JWT:', req.user); const userId = req.user?.id;
if (!req.user || !req.user.userId) { if (!userId) throw new UnauthorizedException('User not authenticated');
throw new UnauthorizedException('User not authenticated'); return this.passengerAuthService.getProfile(userId);
}
return this.service.getProfile(req.user.userId);
} }
@Get('users') // TODO: admin user management endpoints — implement when admin module is ready
@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);
}
} }

View File

@@ -1,152 +1,51 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { export class RegisterDto {
@ApiProperty({ @ApiProperty({ example: 'kelemu@email.com' })
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() @IsEmail()
email: string; email: string;
@ApiProperty({ @ApiProperty({ example: 'kelemu.ketsela' })
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString() @IsString()
phone: string; username: string;
@ApiProperty({ @ApiProperty({ example: '+251912345678' })
description: 'Password (minimum 8 characters)', @IsString()
example: 'SecurePass123', phoneNumber: string;
minLength: 8,
format: 'password' @ApiProperty({ type: NameDto })
}) @ValidateNested()
@Type(() => NameDto)
name: NameDto;
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString() @IsString()
@MinLength(8) @MinLength(8)
password: string; password: string;
@ApiPropertyOptional({ @ApiProperty({ example: 'SecurePass123', format: 'password' })
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString() @IsString()
nationality?: string; confirmPassword: 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;
} }
export class LoginDto { export class LoginDto {
@ApiProperty({ @ApiProperty({ example: 'kelemu@email.com' })
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail() @IsEmail()
email: string; email: string;
@ApiProperty({ @ApiProperty({ example: 'password123', format: 'password' })
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString() @IsString()
password: string; 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;
}

View File

@@ -1,24 +1,10 @@
import { Module } from '@nestjs/common'; 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 { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { PassengerAuthService } from './passenger-auth.service';
import { JwtStrategy } from '../../common/jwt.strategy';
@Module({ @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], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [PassengerAuthService],
exports: [JwtModule], exports: [PassengerAuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -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'
};
}
}

View File

@@ -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);
}
}
}

View File

@@ -5,7 +5,6 @@ import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Booking') @ApiTags('Booking')
@Controller('bookings') @Controller('bookings')
@@ -247,8 +246,8 @@ export class BookingsController {
}) })
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
createGuest(@Body() dto: CreateGuestBookingDto) { createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto); return this.guestService.createGuestBooking(dto, req);
} }
@Get('saved-passengers') @Get('saved-passengers')

View File

@@ -7,10 +7,11 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module'; import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module'; import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module'; import { CurrencyModule } from '../currency/currency.module';
import { AuthModule } from '../auth/auth.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({ @Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule], imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
controllers: [BookingsController], controllers: [BookingsController],
providers: [BookingsService, GuestBookingService], providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService] exports: [BookingsService, GuestBookingService]

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service'; import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -33,12 +35,13 @@ interface BookingFilters {
@Injectable() @Injectable()
export class BookingsService { export class BookingsService {
constructor( constructor(
private prisma: PrismaService, private readonly prisma: PrismaService,
private seatsService: SeatsService, @InjectDataSource() private readonly dataSource: DataSource,
private eventEmitter: EventEmitter2, private readonly seatsService: SeatsService,
private verifaydaService: VerifaydaService, private readonly eventEmitter: EventEmitter2,
private currencyService: CurrencyService, private readonly verifaydaService: VerifaydaService,
private fareEngine: FareEngineService, private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService,
) {} ) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -111,11 +114,11 @@ export class BookingsService {
const { search, status, page = 1, pageSize = 20 } = filters; const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
// Find user with this device ID // Find passenger linked to this device via iamUserId
const device = await this.prisma.device.findUnique({ const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null);
where: { id: deviceId }, const passenger = device?.iamUserId
include: { user: { include: { passenger: true } } }, ? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null)
}).catch(() => null); : null;
const searchConditions = search ? [ const searchConditions = search ? [
{ bookingRef: { contains: search, mode: 'insensitive' } }, { bookingRef: { contains: search, mode: 'insensitive' } },
@@ -126,7 +129,7 @@ export class BookingsService {
const where: any = { const where: any = {
OR: [ OR: [
{ userAgent: deviceId }, { userAgent: deviceId },
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []), ...(passenger ? [{ passengerId: passenger.id }] : []),
], ],
}; };
@@ -193,13 +196,26 @@ export class BookingsService {
const where: any = {}; const where: any = {};
if (search) { 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 = [ where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } }, { bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } }, { contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } }, { contactPhone: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } }, ...(matchedPassengers.length > 0
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } }, ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } }, : []),
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
]; ];
} }
@@ -214,7 +230,7 @@ export class BookingsService {
take: pageSize, take: pageSize,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
passenger: { include: { user: true } }, passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true, paymentIntent: true,
seats: { include: { seat: true } }, seats: { include: { seat: true } },
@@ -223,33 +239,47 @@ export class BookingsService {
this.prisma.booking.count({ where }), 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 { return {
items: items.map(booking => ({ items: items.map(booking => {
id: booking.id, const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
bookingRef: booking.bookingRef, return {
status: booking.status, id: booking.id,
totalMinor: booking.totalMinor, bookingRef: booking.bookingRef,
currency: 'ETB', status: booking.status,
displayCurrency: booking.displayCurrency, totalMinor: booking.totalMinor,
displayTotalMinor: booking.displayTotalMinor, currency: 'ETB',
contactEmail: booking.contactEmail, displayCurrency: booking.displayCurrency,
contactPhone: booking.contactPhone, displayTotalMinor: booking.displayTotalMinor,
bookingType: booking.bookingType, contactEmail: booking.contactEmail,
returnLegStatus: (booking as any).returnLegStatus ?? null, contactPhone: booking.contactPhone,
adultCount: booking.adultCount, bookingType: booking.bookingType,
childCount: booking.childCount, returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt, adultCount: booking.adultCount,
passenger: booking.passenger?.user, childCount: booking.childCount,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], createdAt: booking.createdAt,
schedule: { passenger: iam
train: booking.schedule.train, ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
originStation: booking.schedule.originStation, : null,
destinationStation: booking.schedule.destinationStation, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
departureAt: booking.schedule.departureAt, schedule: {
}, train: booking.schedule.train,
paymentIntent: booking.paymentIntent, originStation: booking.schedule.originStation,
seatCount: booking.seats.length, destinationStation: booking.schedule.destinationStation,
})), departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: { meta: {
page, page,
pageSize, pageSize,

View File

@@ -3,11 +3,11 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service'; import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { PassengerAuthService } from '../auth/passenger-auth.service';
import { FareEngineService } from '../fare-engine/fare-engine.service'; import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
function generateRef(): string { function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -44,18 +44,19 @@ export class GuestBookingService {
private seatsService: SeatsService, private seatsService: SeatsService,
private verifaydaService: VerifaydaService, private verifaydaService: VerifaydaService,
private currencyService: CurrencyService, private currencyService: CurrencyService,
private passengerAuthService: PassengerAuthService,
private fareEngine: FareEngineService, private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
) {} ) {}
async createGuestBooking(dto: CreateGuestBookingDto) { async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto); if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
return this.createGuestOneWayBooking(dto); return this.createGuestOneWayBooking(dto, req);
} }
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) { private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
// Validate hold // Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) { if (!hold || hold.expiresAt < new Date()) {
@@ -95,15 +96,12 @@ export class GuestBookingService {
let verifaydaData: Record<string, any> | undefined; let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality; let nationality = passenger.nationality;
// Determine if passenger is Ethiopian
const isEthiopian = passenger.nationality === 'Ethiopian' || const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' || passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID; passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
// Ethiopian with National ID
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) { if (passenger.idDocumentNumber) {
// Attempt Fayda verification
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) { if (!verification.verified) {
throw new BadRequestException( throw new BadRequestException(
@@ -115,22 +113,14 @@ export class GuestBookingService {
verifaydaData = verification.passengerData?.profileData; verifaydaData = verification.passengerData?.profileData;
} }
nationality = 'Ethiopian'; nationality = 'Ethiopian';
} } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// International passenger with Passport (non-Ethiopian)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Passport details are required for international passengers
if (!passenger.passportNumber || !passenger.passportCountry) { if (!passenger.passportNumber || !passenger.passportCountry) {
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
} }
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Ethiopian with Passport (manual entry without Fayda)
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Ethiopians can use passport instead of national ID
nationality = 'Ethiopian'; nationality = 'Ethiopian';
} } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
// International with National ID (e.g., Djiboutian national ID)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
nationality = nationality || 'Other'; nationality = nationality || 'Other';
} }
@@ -179,16 +169,27 @@ export class GuestBookingService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); 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 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) // Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
for (const passenger of passengersData) { for (const passenger of passengersData) {
// Note: SavedPassengerProfile will be available after migration await this.prisma.savedPassengerProfile.create({
// Temporarily disabled until prisma generate completes data: {
// await this.prisma.savedPassengerProfile.create({ ... }); 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,
},
});
} }
} }
@@ -196,7 +197,7 @@ export class GuestBookingService {
const booking = await this.prisma.booking.create({ const booking = await this.prisma.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassenger.id, passengerId: guestPassengerId,
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
totalMinor, totalMinor,
@@ -237,7 +238,7 @@ export class GuestBookingService {
return { return {
...booking, ...booking,
createdAccount, createdAccount,
userId, iamUserId,
fareBreakdown: { fareBreakdown: {
baseFareMinor, baseFareMinor,
adultCount, adultCount,
@@ -257,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) { if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
} }
@@ -374,7 +375,7 @@ export class GuestBookingService {
: totalMinor; : totalMinor;
// Create or resolve guest passenger (same as one-way) // 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 // Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId); const outboundSeatIds = dto.passengers.map(p => p.seatId);
@@ -383,7 +384,7 @@ export class GuestBookingService {
const booking = await this.prisma.booking.create({ const booking = await this.prisma.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassenger.id, passengerId: guestPassengerId,
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP', bookingType: 'ROUND_TRIP',
@@ -451,7 +452,7 @@ export class GuestBookingService {
return { return {
...booking, ...booking,
createdAccount, createdAccount,
userId, iamUserId,
fareBreakdown: { fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare, outboundBaseFareMinor: outboundBaseFare,
returnBaseFareMinor: returnBaseFare, returnBaseFareMinor: returnBaseFare,
@@ -470,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) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
} }
@@ -571,13 +572,13 @@ export class GuestBookingService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor; : 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 // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({ const booking = await this.prisma.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassenger.id, passengerId: guestPassengerId,
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT', bookingType: 'TRANSIT',
@@ -643,7 +644,7 @@ export class GuestBookingService {
return { return {
...booking, ...booking,
createdAccount, createdAccount,
userId, iamUserId,
fareBreakdown: { fareBreakdown: {
leg1BaseFareMinor: leg1BaseFare, leg1BaseFareMinor: leg1BaseFare,
leg2BaseFareMinor: leg2BaseFare, leg2BaseFareMinor: leg2BaseFare,
@@ -657,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 || if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
@@ -764,7 +765,7 @@ export class GuestBookingService {
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor; : 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) => ({ const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
seat: { connect: { id: seatId } }, seat: { connect: { id: seatId } },
@@ -785,7 +786,7 @@ export class GuestBookingService {
const booking = await this.prisma.booking.create({ const booking = await this.prisma.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassenger.id, passengerId: guestPassengerId,
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT', bookingType: 'ROUND_TRIP_TRANSIT',
@@ -832,7 +833,7 @@ export class GuestBookingService {
return { return {
...booking, ...booking,
createdAccount, createdAccount,
userId, iamUserId,
fareBreakdown: { fareBreakdown: {
outboundLeg1FareMinor: obL1Fare, outboundLeg1FareMinor: obL1Fare,
outboundLeg2FareMinor: obL2Fare, outboundLeg2FareMinor: obL2Fare,
@@ -851,59 +852,28 @@ export class GuestBookingService {
private async resolveGuestPassenger( private async resolveGuestPassenger(
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>, dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
firstPassenger: any, 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) { if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); const guestName = firstPassenger.passengerName ?? 'Guest';
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.'); const result = await this.passengerAuthService.register(
{
let accountPhone = firstPassenger.phone || null; email: firstPassenger.email,
if (accountPhone) { username: firstPassenger.email,
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); phoneNumber: firstPassenger.phone || `+251900000000`,
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); name: { en: guestName, am: guestName },
} password: dto.password,
if (!accountPhone) accountPhone = generateEthiopianPhone(); confirmPassword: dto.password,
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,
}, },
}); req,
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); );
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
return { guestPassenger, userId: user.id, createdAccount: true };
} }
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const guestPassenger = await this.prisma.passenger.create({ data: {} });
let guestEmail = firstPassenger.email || generateGuestEmail(uniqueId); await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
if (firstPassenger.email) { await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
if (existing) guestEmail = generateGuestEmail(uniqueId);
}
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existing) guestPhone = null;
}
if (!guestPhone) guestPhone = generateEthiopianPhone();
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 };
} }
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> { async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
@@ -911,10 +881,6 @@ export class GuestBookingService {
throw new BadRequestException('Either userId or deviceId is required'); 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({ const profiles = await this.prisma.savedPassengerProfile.findMany({
where: { where: {
OR: [ OR: [
@@ -929,14 +895,13 @@ export class GuestBookingService {
passengerName: p.passengerName, passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0], dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
idDocumentType: p.idDocumentType, idDocumentType: p.idDocumentType,
idDocumentNumber: undefined, // Never return sensitive data idDocumentNumber: undefined,
passportNumber: p.passportNumber || undefined, passportNumber: p.passportNumber || undefined,
passportCountry: p.passportCountry || undefined, passportCountry: p.passportCountry || undefined,
nationality: p.nationality || undefined, nationality: p.nationality || undefined,
phone: p.phone || undefined, phone: p.phone || undefined,
email: p.email || undefined, email: p.email || undefined,
})); }));
*/
} }
private async getBaseFare( private async getBaseFare(

View File

@@ -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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { CurrenciesService } from './currencies.service'; import { CurrenciesService } from './currencies.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; 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') @ApiTags('Currencies')
@Controller('currencies') @Controller('currencies')
@@ -15,8 +16,7 @@ export class CurrenciesController {
} }
@Post() @Post()
@UseGuards(IamGuard) @PassengerStaff(PASSENGER_PERMS.currencies.manage)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@HttpCode(201) @HttpCode(201)
createCurrency(@Body() dto: CreateCurrencyDto) { createCurrency(@Body() dto: CreateCurrencyDto) {
@@ -24,24 +24,21 @@ export class CurrenciesController {
} }
@Patch(':id') @Patch(':id')
@UseGuards(IamGuard) @PassengerStaff(PASSENGER_PERMS.currencies.manage)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) { updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
return this.currenciesService.updateCurrency(id, dto); return this.currenciesService.updateCurrency(id, dto);
} }
@Delete(':id') @Delete(':id')
@UseGuards(IamGuard) @PassengerAdmin()
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
deleteCurrency(@Param('id') id: string) { deleteCurrency(@Param('id') id: string) {
return this.currenciesService.deleteCurrency(id); return this.currenciesService.deleteCurrency(id);
} }
@Post('sync-rates') @Post('sync-rates')
@UseGuards(IamGuard) @PassengerStaff(PASSENGER_PERMS.currencies.manage)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@HttpCode(200) @HttpCode(200)
syncRates() { syncRates() {

View File

@@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
) {}
async getHomeDashboard(passengerId: string) { async getHomeDashboard(passengerId: string) {
const now = new Date(); const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ 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({ this.prisma.booking.findFirst({
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } }, where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
include: { include: {
@@ -27,7 +32,16 @@ export class DashboardService {
const hour = now.getHours(); const hour = now.getHours();
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING'; 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]; const seat = upcomingBooking?.seats[0];
return { return {

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service'; import { FraudService, FraudRuleConfig } from './fraud.service';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; import { PassengerStaff } from '../../common/passenger-guards';
import { UserRole } from '@prisma/client'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fraud Detection') @ApiTags('Fraud Detection')
@Controller('fraud') @Controller('fraud')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class FraudController { export class FraudController {
private readonly logger = new Logger(FraudController.name); private readonly logger = new Logger(FraudController.name);
@@ -17,7 +17,6 @@ export class FraudController {
* Get fraud alerts * Get fraud alerts
*/ */
@Get('alerts') @Get('alerts')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get fraud alerts' }) @ApiOperation({ summary: 'Get fraud alerts' })
async getAlerts( async getAlerts(
@Query('userId') userId?: string, @Query('userId') userId?: string,
@@ -32,7 +31,6 @@ export class FraudController {
* Get fraud rules * Get fraud rules
*/ */
@Get('rules') @Get('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Get fraud detection rules' }) @ApiOperation({ summary: 'Get fraud detection rules' })
async getRules() { async getRules() {
const rules = await this.fraudService.getRules(); const rules = await this.fraudService.getRules();
@@ -43,7 +41,7 @@ export class FraudController {
* Create or update fraud rule * Create or update fraud rule
*/ */
@Post('rules') @Post('rules')
@IamRoles('ADMIN') @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create or update fraud rule' }) @ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config); const rule = await this.fraudService.upsertRule(body.type, body.config);
@@ -54,10 +52,10 @@ export class FraudController {
* Block user temporarily * Block user temporarily
*/ */
@Post('actions/block') @Post('actions/block')
@IamRoles('ADMIN', 'SUPERVISOR') @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Block user temporarily' }) @ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { userId: string; durationMinutes: number }) { async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes); await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
return { message: `User blocked for ${body.durationMinutes} minutes` }; return { message: `User blocked for ${body.durationMinutes} minutes` };
} }
@@ -65,10 +63,10 @@ export class FraudController {
* Unblock user * Unblock user
*/ */
@Post('actions/unblock') @Post('actions/unblock')
@IamRoles('ADMIN', 'SUPERVISOR') @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Unblock user' }) @ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { userId: string }) { async unblockUser(@Body() body: { iamUserId: string }) {
await this.fraudService.unblockUser(body.userId); await this.fraudService.unblockUser(body.iamUserId);
return { message: 'User unblocked' }; return { message: 'User unblocked' };
} }
} }

View File

@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
export interface FraudRuleConfig { export interface FraudRuleConfig {
@@ -14,47 +16,37 @@ export interface FraudRuleConfig {
export class FraudService { export class FraudService {
private readonly logger = new Logger(FraudService.name); 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 * Evaluate fraud rules and create alerts if triggered
*/ */
async evaluateRules( async evaluateRules(
userId: string, passengerId: string,
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed', eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
context: Record<string, unknown>, context: Record<string, unknown>,
): Promise<{ triggered: boolean; rules: string[] }> { ): Promise<{ triggered: boolean; rules: string[] }> {
const triggeredRules: 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') { if (eventType === 'booking.created') {
const velocityTriggered = await this.checkVelocityRule(userId); const velocityTriggered = await this.checkVelocityRule(passengerId);
if (velocityTriggered) { if (velocityTriggered) triggeredRules.push('VELOCITY');
triggeredRules.push('VELOCITY');
}
// Check high-value booking
const amount = (context.amountMinor as number) || 0; const amount = (context.amountMinor as number) || 0;
const highValueTriggered = await this.checkHighValueRule(amount); const highValueTriggered = await this.checkHighValueRule(amount);
if (highValueTriggered) { if (highValueTriggered) triggeredRules.push('HIGH_VALUE');
triggeredRules.push('HIGH_VALUE');
}
} }
// Check repeated failed payments
if (eventType === 'payment.failed') { if (eventType === 'payment.failed') {
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId); const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId);
if (failedPaymentTriggered) { if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS');
triggeredRules.push('FAILED_PAYMENTS');
}
} }
// Create alert if rules triggered
if (triggeredRules.length > 0) { if (triggeredRules.length > 0) {
await this.createFraudAlert(userId, eventType, triggeredRules, context); await this.createFraudAlert(passengerId, eventType, triggeredRules, context);
return { triggered: true, rules: triggeredRules }; return { triggered: true, rules: triggeredRules };
} }
@@ -64,7 +56,7 @@ export class FraudService {
/** /**
* Check velocity rule: X bookings in Y minutes * 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({ const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'VELOCITY', enabled: true }, where: { type: 'VELOCITY', enabled: true },
}); });
@@ -72,18 +64,14 @@ export class FraudService {
if (!rule) return false; if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
const threshold = rule.threshold;
const bookingCount = await this.prisma.booking.count({ const bookingCount = await this.prisma.booking.count({
where: { where: {
passengerId: userId, passengerId,
createdAt: { createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
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 * 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({ const rule = await this.prisma.fraudRule.findFirst({
where: { type: 'FAILED_PAYMENTS', enabled: true }, where: { type: 'FAILED_PAYMENTS', enabled: true },
}); });
@@ -112,33 +100,33 @@ export class FraudService {
if (!rule) return false; if (!rule) return false;
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
const threshold = rule.threshold;
const failedCount = await this.prisma.paymentIntent.count({ const failedCount = await this.prisma.paymentIntent.count({
where: { where: {
booking: { passengerId: userId }, booking: { passengerId },
status: 'FAILED', status: 'FAILED',
updatedAt: { updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
},
}, },
}); });
return failedCount > threshold; return failedCount > rule.threshold;
} }
/** /**
* Create a fraud alert * Create a fraud alert
*/ */
private async createFraudAlert( private async createFraudAlert(
userId: string, passengerId: string,
eventType: string, eventType: string,
triggeredRules: string[], triggeredRules: string[],
context: Record<string, unknown>, context: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
select: { iamUserId: true },
});
const alert = await this.prisma.fraudAlert.create({ const alert = await this.prisma.fraudAlert.create({
data: { data: {
userId, iamUserId: passenger?.iamUserId ?? passengerId,
eventType, eventType,
triggeredRules, triggeredRules,
context: context as any, 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) { 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 * 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); const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
await this.prisma.user.update({ await this.prisma.passenger.updateMany({
where: { id: userId }, where: { iamUserId },
data: { blockedUntil }, data: { blockedUntil },
}); });
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`); this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`);
} }
/** /**
* Unblock user * Unblock user
*/ */
async unblockUser(userId: string): Promise<void> { async unblockUser(iamUserId: string): Promise<void> {
await this.prisma.user.update({ await this.prisma.passenger.updateMany({
where: { id: userId }, where: { iamUserId },
data: { blockedUntil: null }, 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) { async getAlerts(userId?: string, limit = 100, offset = 0) {
return this.prisma.fraudAlert.findMany({ return this.prisma.fraudAlert.findMany({
where: userId ? { userId } : {}, where: userId ? { iamUserId: userId } : {},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
skip: offset, skip: offset,
@@ -234,9 +221,10 @@ export class FraudService {
* Event listener for payment failed * Event listener for payment failed
*/ */
@OnEvent('payment.failed') @OnEvent('payment.failed')
async onPaymentFailed(payload: { intentId: string; userId: string }) { async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) {
await this.evaluateRules(payload.userId, 'payment.failed', { if (!payload.booking?.passengerId) return;
intentId: payload.intentId, 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 * Event listener for auth login failed
*/ */
@OnEvent('auth.login.failed') @OnEvent('auth.login.failed')
async onLoginFailed(payload: { userId: string; email: string }) { async onLoginFailed(payload: { email: string }) {
await this.evaluateRules(payload.userId, 'auth.login.failed', { if (!payload.email) return;
email: payload.email, 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 });
} }
} }

View File

@@ -2,7 +2,8 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard'; 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 { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service'; import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service'; import { SmsClientService } from './sms-client.service';
@@ -39,8 +40,7 @@ export class NotificationsController {
} }
@Post('send/email') @Post('send/email')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct email via the email microservice' }) @ApiOperation({ summary: 'Send a direct email via the email microservice' })
@ApiBody({ type: SendEmail }) @ApiBody({ type: SendEmail })
sendEmail(@Body() dto: SendEmail) { sendEmail(@Body() dto: SendEmail) {
@@ -48,8 +48,7 @@ export class NotificationsController {
} }
@Post('send/sms') @Post('send/sms')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SingleMessageDto }) @ApiBody({ type: SingleMessageDto })
sendSms(@Body() dto: SingleMessageDto) { sendSms(@Body() dto: SingleMessageDto) {
@@ -57,8 +56,7 @@ export class NotificationsController {
} }
@Post('send/sms/bulk') @Post('send/sms/bulk')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' }) @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
@ApiBody({ type: BulkMessagesDto }) @ApiBody({ type: BulkMessagesDto })
sendBulkSms(@Body() dto: BulkMessagesDto) { sendBulkSms(@Body() dto: BulkMessagesDto) {
@@ -66,8 +64,6 @@ export class NotificationsController {
} }
@Post('test') @Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Test notification delivery (Admin only)' }) @ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) { async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send( return this.service.send(

View File

@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { PushAdapter, NotificationChannel } from './notification.adapters'; import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service'; import { EmailClientService } from './email-client.service';
@@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; 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() @Injectable()
export class NotificationsService { export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name); private readonly logger = new Logger(NotificationsService.name);
@@ -14,6 +18,7 @@ export class NotificationsService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private emailClient: EmailClientService, private emailClient: EmailClientService,
private smsClient: SmsClientService, private smsClient: SmsClientService,
private pushAdapter: PushAdapter, private pushAdapter: PushAdapter,
@@ -112,22 +117,20 @@ export class NotificationsService {
body: string, body: string,
context: Record<string, unknown>, context: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
// Try to find passenger by ID or email
let passengerId = recipient; 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)) { if (!UUID_RE.test(recipient)) {
const user = await this.prisma.user.findFirst({ const iamUserId = await this.resolveIamUserId(recipient);
where: { if (!iamUserId) {
OR: [{ email: recipient }, { phone: recipient }],
},
include: { passenger: true },
});
if (user?.passenger) {
passengerId = user.passenger.id;
} else {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`); this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return; 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({ await this.prisma.notification.create({
@@ -163,26 +166,19 @@ export class NotificationsService {
} }
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> { private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const user = await this.prisma.user.findFirst({ const iamUserId = await this.resolveIamUserId(recipient);
where: { const preferences = iamUserId
OR: [ ? await this.prisma.userPreferences.findUnique({ where: { iamUserId } })
{ id: recipient }, : null;
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
},
include: { preferences: true },
});
if (!user?.preferences) { if (!preferences) {
return ['IN_APP', 'EMAIL']; return ['IN_APP', 'EMAIL'];
} }
const channels: NotificationChannelType[] = ['IN_APP']; const channels: NotificationChannelType[] = ['IN_APP'];
if (user.preferences.emailEnabled) channels.push('EMAIL'); if (preferences.emailEnabled) channels.push('EMAIL');
if (user.preferences.smsEnabled) channels.push('SMS'); if (preferences.smsEnabled) channels.push('SMS');
if (user.preferences.pushEnabled) channels.push('PUSH'); if (preferences.pushEnabled) channels.push('PUSH');
return channels; return channels;
} }
@@ -191,32 +187,44 @@ export class NotificationsService {
recipient: string, recipient: string,
channel: NotificationChannelType, channel: NotificationChannelType,
): Promise<string | null> { ): Promise<string | null> {
const user = await this.prisma.user.findFirst({ const iamUserId = await this.resolveIamUserId(recipient);
where: { if (!iamUserId) return null;
OR: [ const contact = await this.resolveContactInfo(iamUserId);
{ id: recipient },
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
},
});
if (!user) return null;
switch (channel) { switch (channel) {
case 'EMAIL': case 'EMAIL': return contact.email;
return user.email; case 'SMS': return contact.phone;
case 'SMS': case 'PUSH': return iamUserId;
return user.phone; default: return null;
case 'PUSH':
// Would need to fetch device push token
return user.id;
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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) { getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({ return this.prisma.notification.findMany({
where: { passengerId }, where: { passengerId },

View File

@@ -0,0 +1,70 @@
import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@ApiTags('Packages')
@Controller('packages')
export class PackagesController {
constructor(private readonly service: PackagesService) {}
@Get()
@ApiOperation({ summary: 'List active packages' })
listActive() {
return this.service.listActive();
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all packages (admin)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@Get('my-bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get my package bookings' })
myBookings(@Request() req: any) {
return this.service.getMyBookings(req.user.passengerId);
}
@Get('booking/:ref')
@ApiOperation({ summary: 'Get package booking by reference' })
getBookingByRef(@Param('ref') ref: string) {
return this.service.getBookingByRef(ref);
}
@Get(':id')
@ApiOperation({ summary: 'Get package details' })
getById(@Param('id') id: string) {
return this.service.getById(id);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
return this.service.create(dto);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
return this.service.activate(id);
}
@Post('book')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
book(@Body() dto: BookPackageDto, @Request() req: any) {
return this.service.book(dto, req.user?.passengerId);
}
}

View File

@@ -0,0 +1,94 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePriceTierDto {
@ApiProperty({ example: 'HSC' })
@IsString() seatType: string;
@ApiProperty({ example: 'Regular Seat (HSC)' })
@IsString() label: string;
@ApiProperty({ example: 1023200 })
@IsInt() @Min(0) priceMinor: number;
@ApiProperty({ example: 100 })
@IsInt() @Min(0) availableSeats: number;
}
export class CreatePackageDto {
@ApiProperty({ example: 'KULUBBI-2025' })
@IsString() code: string;
@ApiProperty({ example: 'Kulubbi Gabriel Pilgrimage Package' })
@IsString() name: string;
@ApiPropertyOptional()
@IsOptional() @IsString() description?: string;
@ApiProperty() @IsUUID() outboundScheduleId: string;
@ApiProperty() @IsUUID() returnScheduleId: string;
@ApiProperty() @IsUUID() originStationId: string;
@ApiProperty() @IsUUID() destinationStationId: string;
@ApiProperty({ example: '2025-07-24T07:00:00Z' })
@IsDateString() boardingTime: string;
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
@IsDateString() departureTime: string;
@ApiProperty({ example: '2025-07-25T06:00:00Z' })
@IsDateString() arrivalTime: string;
@ApiProperty({ example: 912 })
@IsInt() @Min(1) totalCapacity: number;
@ApiPropertyOptional({ example: '1 Locomotive + 2SBC + 2HBC + 6HSC' })
@IsOptional() @IsString() coachConfiguration?: string;
@ApiProperty({ type: [String] })
@IsArray() @IsString({ each: true }) includedServices: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() busTransferIncluded?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() busTransferRoute?: string;
@ApiProperty({ example: '2025-07-01T00:00:00Z' })
@IsDateString() validFrom: string;
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
@IsDateString() validUntil: string;
@ApiProperty({ type: [CreatePriceTierDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => CreatePriceTierDto)
priceTiers: CreatePriceTierDto[];
}
export class BookPackagePassengerDto {
@ApiProperty() @IsString() passengerName: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
}
export class BookPackageDto {
@ApiProperty() @IsUUID() packageId: string;
@ApiProperty() @IsUUID() priceTierId: string;
@ApiPropertyOptional()
@IsOptional() @IsString() displayCurrency?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() contactEmail?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() contactPhone?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() promoCode?: string;
@ApiProperty({ type: [BookPackagePassengerDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
passengers: BookPackagePassengerDto[];
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { PackagesController } from './packages.controller';
import { PackagesService } from './packages.service';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [PrismaModule, CurrencyModule],
controllers: [PackagesController],
providers: [PackagesService],
exports: [PackagesService],
})
export class PackagesModule {}

View File

@@ -0,0 +1,191 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { Currency } from '@prisma/client';
function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
).join('');
}
@Injectable()
export class PackagesService {
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
) {}
listActive() {
const now = new Date();
return this.prisma.travelPackage.findMany({
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { validFrom: 'asc' },
});
}
async getById(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
if (!pkg) throw new NotFoundException('Package not found');
return pkg;
}
create(dto: CreatePackageDto) {
return this.prisma.travelPackage.create({
data: {
code: dto.code,
name: dto.name,
description: dto.description,
outboundScheduleId: dto.outboundScheduleId,
returnScheduleId: dto.returnScheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
boardingTime: new Date(dto.boardingTime),
departureTime: new Date(dto.departureTime),
arrivalTime: new Date(dto.arrivalTime),
totalCapacity: dto.totalCapacity,
coachConfiguration: dto.coachConfiguration,
includedServices: dto.includedServices,
busTransferIncluded: dto.busTransferIncluded ?? false,
busTransferRoute: dto.busTransferRoute,
validFrom: new Date(dto.validFrom),
validUntil: new Date(dto.validUntil),
status: 'DRAFT',
priceTiers: { create: dto.priceTiers },
},
include: { priceTiers: true },
});
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
}
async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId },
include: { priceTiers: true },
});
if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = dto.passengers.length;
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const totalMinor = tier.priceMinor * passengerCount;
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const [booking] = await this.prisma.$transaction([
this.prisma.packageBooking.create({
data: {
bookingRef: generateRef(),
packageId: dto.packageId,
priceTierId: dto.priceTierId,
passengerId: passengerId ?? null,
contactEmail: dto.contactEmail,
contactPhone: dto.contactPhone,
promoCode: dto.promoCode,
passengerCount,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
status: 'PENDING_PAYMENT',
passengers: {
create: dto.passengers.map((p) => ({
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined,
idDocumentType: p.idDocumentType as any,
idDocumentNumber: p.idDocumentNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
})),
},
},
include: {
passengers: true,
priceTier: true,
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
},
},
}),
this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengerCount } },
}),
]);
return booking;
}
getMyBookings(passengerId: string) {
return this.prisma.packageBooking.findMany({
where: { passengerId },
include: { package: true, priceTier: true, passengers: true, paymentIntent: true },
orderBy: { createdAt: 'desc' },
});
}
async getBookingByRef(bookingRef: string) {
const booking = await this.prisma.packageBooking.findUnique({
where: { bookingRef },
include: {
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
},
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!booking) throw new NotFoundException('Package booking not found');
return booking;
}
async listAll(page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.travelPackage.findMany({
skip,
take: pageSize,
include: { priceTiers: true },
orderBy: { createdAt: 'desc' },
}),
this.prisma.travelPackage.count(),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -3,7 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@ne
import { PassengersService } from './passengers.service'; import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@@ -53,25 +52,17 @@ export class PassengersController {
}) })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
async getMe(@Request() req: any) { async getMe(@Request() req: any) {
if (!req.user || !req.user.userId) { if (!req.user || !req.user.id) {
throw new UnauthorizedException('User not authenticated'); throw new UnauthorizedException('User not authenticated');
} }
try { try {
const user = await this.prisma.user.findUnique({ const passenger = await this.prisma.passenger.findUnique({
where: { id: req.user.userId }, where: { iamUserId: req.user.id },
include: {
passenger: true,
},
}); });
if (!passenger) return null;
if (!user || !user.passenger) { return this.service.getProfile(passenger.id);
return null;
}
return this.service.getProfile(user.passenger.id);
} catch (error) { } catch (error) {
// If profile lookup fails for any reason, return null to allow app to continue
return null; return null;
} }
} }
@@ -251,7 +242,7 @@ The API automatically detects:
description: 'Invalid JWT token (only if token provided but invalid)' description: 'Invalid JWT token (only if token provided but invalid)'
}) })
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) { registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
const userId = req.user?.userId; const userId = req.user?.id;
return this.service.registerPassenger({ ...dto, userId }); return this.service.registerPassenger({ ...dto, userId });
} }

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
@@ -10,35 +12,66 @@ interface PassengerFilters {
pageSize?: number; pageSize?: number;
} }
type IamUserRow = {
id: string;
email: string;
name: { en: string; am: string } | null;
phone_number: string | null;
metadata: Record<string, any> | null;
};
@Injectable() @Injectable()
export class PassengersService { export class PassengersService {
constructor( constructor(
private prisma: PrismaService, private readonly prisma: PrismaService,
private verifaydaService: VerifaydaService, @InjectDataSource() private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
) {} ) {}
async findAll(filters: PassengerFilters = {}) { async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters; const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: any = { user: { role: 'PASSENGER' } }; let iamUserIdFilter: string[] | null = null;
if (search) { if (search || verified !== undefined) {
where.user = { const conditions: string[] = [];
...where.user, const params: any[] = [];
OR: [ let idx = 1;
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }, if (search) {
{ phone: { contains: search, mode: 'insensitive' } }, 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) { const where: any = {};
where.user = { if (iamUserIdFilter) {
...where.user, where.iamUserId = { in: iamUserIdFilter };
nationalId: verified ? { not: null } : null,
};
} }
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
@@ -48,42 +81,36 @@ export class PassengersService {
take: pageSize, take: pageSize,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
user: true,
loyalty: true, loyalty: true,
wallet: true, _count: { select: { bookings: true } },
_count: {
select: {
bookings: true,
},
},
}, },
}), }),
this.prisma.passenger.count({ where }), 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 { return {
items: items.map(passenger => { 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 { return {
id: passenger.id, id: passenger.id,
userId: passenger.userId, fullName: iam?.name?.en ?? iam?.name?.am ?? null,
fullName: user.fullName, email: iam?.email ?? null,
email: user.email, phone: iam?.phone_number ?? null,
phone: user.phone?.startsWith('+guest-') ? null : user.phone, verified: faydaVerified,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
gender: user.gender ?? null,
passportNumber: user.passportNumber,
passportCountry: user.passportCountry ?? null,
verified: !!user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE', loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0, loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings, totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt, createdAt: passenger.createdAt,
updatedAt: user.updatedAt,
loyalty: passenger.loyalty,
wallet: passenger.wallet,
}; };
}), }),
meta: { meta: {
@@ -99,14 +126,13 @@ export class PassengersService {
const passenger = await this.prisma.passenger.findUnique({ const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId }, where: { id: passengerId },
include: { include: {
user: true,
bookings: { bookings: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 10, take: 10,
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } } seats: { include: { seat: { include: { coach: true } } } },
} },
}, },
loyalty: true, loyalty: true,
wallet: true, wallet: true,
@@ -115,11 +141,21 @@ export class PassengersService {
}, },
}); });
if (!passenger) throw new NotFoundException('Passenger not found'); 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 { return {
id: passenger.id, id: passenger.id,
fullName: passenger.user.fullName, fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
email: passenger.user.email, email: iamUser?.email ?? null,
phone: passenger.user.phone, phone: iamUser?.phone_number ?? null,
createdAt: passenger.createdAt, createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({ bookings: passenger.bookings.map((b) => ({
id: b.id, id: b.id,
@@ -145,11 +181,7 @@ export class PassengersService {
}, },
passengers: b.seats.map((bs) => ({ passengers: b.seats.map((bs) => ({
fullName: bs.passengerName, fullName: bs.passengerName,
seat: { seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' },
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
class: 'N/A'
}
})), })),
})), })),
}; };
@@ -229,23 +261,53 @@ export class PassengersService {
async updatePassenger(id: string, dto: any) { async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } }); const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found'); if (!passenger) throw new NotFoundException('Passenger not found');
return this.prisma.passenger.update({
where: { id }, if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) {
data: { const updates: string[] = [];
user: { const params: any[] = [];
update: { let idx = 1;
fullName: dto.fullName || undefined,
email: dto.email || undefined, if (dto.fullName) {
phone: dto.phone || undefined, updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`);
nationality: dto.nationality || undefined, params.push(dto.fullName);
}, idx++;
}, }
}, if (dto.email) {
include: { updates.push(`email = $${idx}`);
user: true, params.push(dto.email);
loyalty: true, 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) { async registerPassenger(dto: RegisterPassengerDto) {
@@ -274,31 +336,16 @@ export class PassengersService {
}; };
if (isLoggedIn) { if (isLoggedIn) {
const user = await this.prisma.user.findUnique({ const linkedPassenger = await this.prisma.passenger.findUnique({
where: { id: dto.userId }, where: { iamUserId: dto.userId },
include: { passenger: true },
}); });
if (!user) { if (!linkedPassenger) {
throw new BadRequestException('User not found'); throw new BadRequestException('Passenger 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,
},
});
} }
return { return {
id: user.passenger?.id || user.id, id: linkedPassenger.id,
passengerName: finalData.passengerName, passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth, dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality, nationality: finalData.nationality,
@@ -336,7 +383,9 @@ export class PassengersService {
async deletePassenger(id: string) { async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } }); const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found'); 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) { async checkPassengerUsage(id: string) {

View File

@@ -28,10 +28,8 @@ import {
PaymentMethodTypeEnum, PaymentMethodTypeEnum,
PaymentPlatformDto, PaymentPlatformDto,
} from "./payments.dto"; } from "./payments.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards";
import { RolesGuard } from "../../common/roles.guard"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { Roles } from "../../common/roles.decorator";
import { UserRole } from "@prisma/client";
@ApiTags("Payment") @ApiTags("Payment")
@Controller("payments") @Controller("payments")
@@ -39,9 +37,8 @@ export class PaymentsController {
constructor(private service: PaymentsService) {} constructor(private service: PaymentsService) {}
@Get("all") @Get("all")
@UseGuards(JwtGuard, RolesGuard) @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) @ApiBearerAuth("IAM-auth")
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false }) @ApiQuery({ name: "status", required: false })
@@ -102,18 +99,16 @@ export class PaymentsController {
} }
@Post("refund") @Post("refund")
@UseGuards(JwtGuard, RolesGuard) @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) @ApiBearerAuth("IAM-auth")
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
refund(@Body() dto: RefundDto) { refund(@Body() dto: RefundDto) {
return this.service.refund(dto); return this.service.refund(dto);
} }
@Post("methods") @Post("methods")
@UseGuards(JwtGuard, RolesGuard) @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@Roles(UserRole.ADMIN, UserRole.STAFF) @ApiBearerAuth("IAM-auth")
@ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: "Add a payment system to the platform catalog (admin only)", summary: "Add a payment system to the platform catalog (admin only)",
}) })

View File

@@ -23,19 +23,7 @@ describe("Payments E2E", () => {
prisma = app.get<PrismaService>(PrismaService); prisma = app.get<PrismaService>(PrismaService);
const testUser = await prisma.user.create({ const passenger = await prisma.passenger.create({ data: { iamUserId: 'test-iam-payments-user' } });
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 },
});
await prisma.walletAccount.create({ await prisma.walletAccount.create({
data: { data: {
@@ -151,7 +139,6 @@ describe("Payments E2E", () => {
prisma.walletLedgerEntry.deleteMany(), prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(), prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(), prisma.passenger.deleteMany(),
prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }),
]); ]);
await app.close(); await app.close();
}); });

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto'; import { GenerateReportDto } from './reports.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; import { PassengerStaff } from '../../common/passenger-guards';
import { UserRole } from '@prisma/client'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Reports') @ApiTags('Reports')
@Controller('reports') @Controller('reports')
@UseGuards(IamGuard) @PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class ReportsController { export class ReportsController {
constructor(private service: ReportsService) {} constructor(private service: ReportsService) {}
@Post('generate') @Post('generate')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Generate operational report' }) @ApiOperation({ summary: 'Generate operational report' })
generateReport(@Body() dto: GenerateReportDto) { generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto); return this.service.generateReport(dto);
} }
@Get(':reportId') @Get(':reportId')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get report by ID' }) @ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) { getReport(@Param('reportId') reportId: string) {
return this.service.getReport(reportId); return this.service.getReport(reportId);
} }
@Get() @Get()
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'List reports' }) @ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) { listReports(@Query('type') type?: string) {
return this.service.listReports(type); return this.service.listReports(type);

View File

@@ -1,10 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto'; import { GenerateReportDto, ReportType } from './reports.dto';
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
) {}
async generateReport(dto: GenerateReportDto) { async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom); const dateFrom = new Date(dto.dateFrom);
@@ -113,13 +118,25 @@ export class ReportsService {
...(agentId ? { agentId } : {}) ...(agentId ? { agentId } : {})
}, },
include: { include: {
agent: { include: { user: true } }, agent: { select: { id: true, iamUserId: true, agentCode: true } },
booking: 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 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]) { if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
} }

View File

@@ -3,10 +3,10 @@ import { HttpModule } from '@nestjs/axios';
import { SeatsController } from './seats.controller'; import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service'; import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module'; import { SegmentsModule } from '../segments/segments.module';
import { IamModule } from '../../common/iam.module'; import { SystemConfigModule } from '../system-config/system-config.module';
@Module({ @Module({
imports: [SegmentsModule, HttpModule, IamModule], imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
controllers: [SeatsController], controllers: [SeatsController],
providers: [SeatsService], providers: [SeatsService],
exports: [SeatsService], exports: [SeatsService],

View File

@@ -3,12 +3,14 @@ import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto'; import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service'; import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@Injectable() @Injectable()
export class SeatsService { export class SeatsService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private segmentsService: SegmentsService, private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {} ) {}
async getSeatMap(scheduleId: string, coachTypeId?: string) { async getSeatMap(scheduleId: string, coachTypeId?: string) {
@@ -240,7 +242,8 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length) if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list'); throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => { const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ const seats = await tx.seat.findMany({

View File

@@ -0,0 +1,24 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { SystemConfigService } from './system-config.service';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@ApiTags('System Config')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
@Controller('system-config')
export class SystemConfigController {
constructor(private service: SystemConfigService) {}
@Get()
getAll() {
return this.service.getAll();
}
@Patch()
update(@Body() body: Record<string, string>) {
return this.service.updateMany(body);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SystemConfigService } from './system-config.service';
import { SystemConfigController } from './system-config.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule, HttpModule],
controllers: [SystemConfigController],
providers: [SystemConfigService],
exports: [SystemConfigService],
})
export class SystemConfigModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
} as const;
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
};
@Injectable()
export class SystemConfigService {
constructor(private prisma: PrismaService) {}
async getAll(): Promise<Record<string, string>> {
const rows = await this.prisma.systemConfig.findMany();
const result: Record<string, string> = { ...DEFAULTS };
for (const row of rows) result[row.key] = row.value;
return result;
}
async getValue(key: string): Promise<string> {
const row = await this.prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? DEFAULTS[key] ?? '';
}
async getNumber(key: string): Promise<number> {
return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10);
}
async set(key: string, value: string): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
async updateMany(entries: Record<string, string>): Promise<Record<string, string>> {
await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v)));
return this.getAll();
}
}

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
@@ -12,7 +14,10 @@ interface OfflineValidation {
@Injectable() @Injectable()
export class TicketsService { 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 }) { async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {}; const where: any = {};
@@ -38,50 +43,68 @@ export class TicketsService {
end.setDate(end.getDate() + 1); end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
} }
const tickets = await this.prisma.ticket.findMany({ const [tickets, total] = await Promise.all([
where, this.prisma.ticket.findMany({
include: { where,
booking: { include: {
include: { booking: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, include: {
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
passenger: { include: { user: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
}, },
}, },
}, skip: filters.skip,
skip: filters.skip, take: filters.take,
take: filters.take, orderBy: { issuedAt: 'desc' },
orderBy: { issuedAt: 'desc' }, }),
}); this.prisma.ticket.count({ where }),
const total = await 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 { return {
items: tickets.map((t) => ({ items: tickets.map((t) => {
id: t.id, const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
ticketNumber: t.barcodePayload, const passengerInfo = iam
bookingRef: t.bookingRef, ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
booking: { : { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
bookingRef: t.booking.bookingRef, return {
status: t.booking.status, id: t.id,
bookingType: t.booking.bookingType, ticketNumber: t.barcodePayload,
returnLegStatus: (t.booking as any).returnLegStatus ?? null, bookingRef: t.bookingRef,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, booking: {
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, bookingRef: t.booking.bookingRef,
totalMinor: t.booking.totalMinor, status: t.booking.status,
currency: t.booking.currency, bookingType: t.booking.bookingType,
displayCurrency: t.booking.displayCurrency, returnLegStatus: (t.booking as any).returnLegStatus ?? null,
displayTotalMinor: t.booking.displayTotalMinor, outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
contactEmail: t.booking.contactEmail, totalMinor: t.booking.totalMinor,
contactPhone: t.booking.contactPhone, currency: t.booking.currency,
returnSchedule: (t.booking as any).returnSchedule ?? null, displayCurrency: t.booking.displayCurrency,
}, displayTotalMinor: t.booking.displayTotalMinor,
schedule: t.booking.schedule, passenger: passengerInfo,
seat: t.booking.seats[0]?.seat, contactEmail: t.booking.contactEmail,
status: t.status, contactPhone: t.booking.contactPhone,
validatedAt: t.validatedAt, returnSchedule: (t.booking as any).returnSchedule ?? null,
createdAt: t.issuedAt, },
})), schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
};
}),
total, total,
skip: filters.skip, skip: filters.skip,
take: filters.take, take: filters.take,
@@ -390,8 +413,8 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' }, where: { scheduleId: tripId, status: 'CONFIRMED' },
include: { include: {
ticket: true, ticket: true,
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, seats: { include: { seat: { include: { coach: true } } } },
passenger: { include: { user: true } }, passenger: { select: { id: true, iamUserId: true } },
}, },
}); });

View File

@@ -1,21 +1,30 @@
import { Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; 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 * When a valid IAM bearer token is present, `request.user` is populated with
* populated from the JWT strategy (`{ userId, ... }`). When the token is * the package `TCurrentUser`. Missing or invalid tokens continue as guests.
* 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).
*/ */
@Injectable() @Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') { export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser { constructor(
return (user ?? null) as TUser; 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;
} }
} }

View File

@@ -15,6 +15,7 @@ import {
ApiOperation, ApiOperation,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard';
import { import {
@@ -25,21 +26,13 @@ import {
} from './verifayda.dto'; } from './verifayda.dto';
import { VerifaydaService } from './verifayda.service'; 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 /** Minimal slices of the Express req we touch (avoids a hard dependency on
* `@types/express`, which isn't resolved in this package). */ * `@types/express`, which isn't resolved in this package). */
interface RequestWithOptionalUser { interface RequestWithOptionalUser {
user?: AuthedUser; user?: TCurrentUser;
} }
interface RequestWithUser { interface RequestWithUser {
user: AuthedUser; user: TCurrentUser;
} }
@ApiTags('Fayda Verification') @ApiTags('Fayda Verification')
@@ -76,7 +69,7 @@ export class VerifaydaController {
const authorizationUrl = await this.service.startVerification({ const authorizationUrl = await this.service.startVerification({
purpose: dto.purpose ?? 'VERIFY', purpose: dto.purpose ?? 'VERIFY',
platform: dto.platform ?? 'WEB', platform: dto.platform ?? 'WEB',
userId: req.user?.userId, userId: req.user?.id,
}); });
return { authorizationUrl }; return { authorizationUrl };
} }
@@ -105,6 +98,6 @@ export class VerifaydaController {
async status( async status(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
): Promise<VerificationStatusDto> { ): Promise<VerificationStatusDto> {
return this.service.getVerificationStatus(req.user.userId); return this.service.getVerificationStatus(req.user.id);
} }
} }

View File

@@ -2,12 +2,9 @@ import { Module } from '@nestjs/common';
import { VerifaydaController } from './verifayda.controller'; import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service'; import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module'; import { PrismaModule } from '../../common/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry imports: [PrismaModule],
// config as /auth/login) to mint tokens for the LOGIN flow.
imports: [PrismaModule, AuthModule],
controllers: [VerifaydaController], controllers: [VerifaydaController],
providers: [VerifaydaService], providers: [VerifaydaService],
exports: [VerifaydaService], exports: [VerifaydaService],

View File

@@ -1,5 +1,4 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose'; import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config'; import { FaydaConfig } from '../../config/fayda.config';
@@ -16,12 +15,6 @@ function buildPrismaMock() {
bookingSeat: { bookingSeat: {
updateMany: jest.fn(), updateMany: jest.fn(),
}, },
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() }, passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() }, loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() }, walletAccount: { create: jest.fn() },
@@ -30,10 +23,8 @@ function buildPrismaMock() {
}; };
} }
function buildJwtMock(): jest.Mocked<JwtService> { function buildDataSourceMock() {
return { return { query: jest.fn().mockResolvedValue([]) };
sign: jest.fn(() => 'signed.jwt.token'),
} as unknown as jest.Mocked<JwtService>;
} }
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig { function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
@@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
describe('VerifaydaService (OIDC, client-callback)', () => { describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>; let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>; let dataSource: ReturnType<typeof buildDataSourceMock>;
let service: VerifaydaService; let service: VerifaydaService;
let realPrivateJwk: JWK; let realPrivateJwk: JWK;
@@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
beforeEach(() => { beforeEach(() => {
prisma = buildPrismaMock(); prisma = buildPrismaMock();
jwt = buildJwtMock(); dataSource = buildDataSourceMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService( service = new VerifaydaService(
buildConfigService(cfg), buildConfigService(cfg),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jwt, dataSource as any,
); );
(global as any).fetch = jest.fn(); (global as any).fetch = jest.fn();
}); });
@@ -135,7 +126,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
const disabledService = new VerifaydaService( const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })), buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jwt, buildDataSourceMock() as any,
); );
await expect( await expect(
disabledService.startVerification({ purpose: 'VERIFY' }), disabledService.startVerification({ purpose: 'VERIFY' }),
@@ -154,7 +145,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
status: 'PENDING', status: 'PENDING',
errorCode: null, errorCode: null,
errorDescription: null, errorDescription: null,
userId: null, iamUserId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...overrides,
}; };
@@ -215,7 +206,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
purpose: 'VERIFY', purpose: 'VERIFY',
platform: 'WEB', platform: 'WEB',
status: 'PENDING', status: 'PENDING',
userId: null, iamUserId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...overrides,
}; };
@@ -270,7 +261,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
expect(result.token).toBeUndefined(); expect(result.token).toBeUndefined();
expect(result.user).toBeUndefined(); expect(result.user).toBeUndefined();
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled(); expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
}); });
it('throws 502 when the token endpoint returns 4xx', async () => { it('throws 502 when the token endpoint returns 4xx', async () => {
@@ -337,7 +327,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
purpose: 'LOGIN', purpose: 'LOGIN',
platform: 'WEB', platform: 'WEB',
status: 'PENDING', status: 'PENDING',
userId: null, iamUserId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...overrides,
}; };
@@ -363,139 +353,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); (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(() => { beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
}); });
it('creates a new user when no match and returns { token, user }', async () => { it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', 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({});
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); 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 }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect( await expect(
service.completeVerification({ code: 'c', state: 'state-login' }), service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({
expect(prisma.user.update).not.toHaveBeenCalled(); status: 401,
expect(prisma.user.create).not.toHaveBeenCalled(); 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', () => { describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => { it('returns verified=true when IAM user metadata has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({ dataSource.query.mockResolvedValueOnce([{
faydaVerified: true, metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' },
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), name: { en: 'Test User', am: 'ቴስት ዩዘር' },
fullName: 'Test User', }]);
}); const result = await service.getVerificationStatus('iam-user-1');
const result = await service.getVerificationStatus('user-1');
expect(result).toEqual({ expect(result).toEqual({
verified: true, verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'), verifiedAt: new Date('2026-01-01T00:00:00Z'),
@@ -503,9 +395,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
}); });
}); });
it('returns verified=false when User row is missing or unverified', async () => { it('returns verified=false when IAM user is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null); dataSource.query.mockResolvedValueOnce([]);
const result = await service.getVerificationStatus('user-x'); const result = await service.getVerificationStatus('iam-user-x');
expect(result).toEqual({ verified: false }); expect(result).toEqual({ verified: false });
}); });
}); });

View File

@@ -6,10 +6,9 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import { import {
@@ -20,7 +19,6 @@ import {
import { generateClientAssertion } from './utils/client-assertion.util'; import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import { import {
FaydaIdentityConflictException,
FaydaTokenExchangeException, FaydaTokenExchangeException,
FaydaUserInfoException, FaydaUserInfoException,
} from './verifayda.errors'; } from './verifayda.errors';
@@ -48,7 +46,7 @@ export interface VerifaydaVerificationResult {
export interface StartVerificationInput { export interface StartVerificationInput {
purpose: VerifaydaPurpose; purpose: VerifaydaPurpose;
platform?: FaydaPlatform; platform?: FaydaPlatform;
userId?: string; userId?: string; // iamUserId of the authenticated user, if any
} }
export interface FaydaUserSummary { export interface FaydaUserSummary {
@@ -91,7 +89,7 @@ export class VerifaydaService {
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService, @InjectDataSource() private readonly dataSource: DataSource,
) { ) {
const fayda = this.config.get<FaydaConfig>('fayda'); const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) { if (!fayda) {
@@ -146,7 +144,7 @@ export class VerifaydaService {
codeVerifier, codeVerifier,
purpose: input.purpose, purpose: input.purpose,
platform: input.platform ?? 'WEB', platform: input.platform ?? 'WEB',
userId: input.userId ?? null, iamUserId: input.userId ?? null,
expiresAt, expiresAt,
}, },
}); });
@@ -257,52 +255,25 @@ export class VerifaydaService {
} }
} }
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken( private async issueLoginToken(
userId: string, _userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> { ): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({ throw new UnauthorizedException({
where: { id: userId }, code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
include: { passenger: true, agent: true }, 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> { async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({ const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
where: { id: userId }, `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true }, [iamUserId],
}); );
const iam = rows[0] ?? null;
return { const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
verified: user?.faydaVerified ?? false, const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
verifiedAt: user?.faydaVerifiedAt ?? undefined, const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
fullName: user?.fullName ?? undefined, return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
};
} }
// ========================================================================== // ==========================================================================
@@ -428,106 +399,16 @@ export class VerifaydaService {
}; };
} }
/** // LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
* Resolves the User for a LOGIN flow and returns its id (the caller mints the // This method is kept as a stub so completeVerification() still compiles;
* JWT via {@link issueLoginToken}). Resolution order: // it throws immediately without touching the database.
* 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.
*/
private async handleLoginSuccess( private async handleLoginSuccess(
normalized: NormalizedFaydaUserInfo, _normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> { ): Promise<{ userId: string }> {
let userId: string; throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
const bySub = await this.prisma.user.findUnique({ message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
where: { faydaSub: normalized.sub },
select: { id: true },
}); });
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( private async markSessionFailed(
@@ -548,7 +429,6 @@ export class VerifaydaService {
} }
private classifyFailureReason(err: unknown): string { private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
return 'verification_failed'; return 'verification_failed';
@@ -565,9 +445,8 @@ export class VerifaydaService {
): Promise<VerifaydaVerificationResult> { ): Promise<VerifaydaVerificationResult> {
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`); this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
if (this.stubEnabled != false || this.stubEnabled) { if (!this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)'); this.logger.warn('Verifayda not configured returning mock data (development mode)');
// In development mode, return mock verified data
return { return {
verified: true, verified: true,
passengerData: { passengerData: {

View 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`);
}
}

View 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],
},
];

View File

@@ -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;

View 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');
}
}

View File

@@ -8,6 +8,8 @@
"incremental": true, "incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo", "tsBuildInfoFile": "./.tsbuildinfo",
"paths": { "@/*": ["./src/*"] }, "paths": { "@/*": ["./src/*"] },
"module": "node16",
"moduleResolution": "node16",
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false "noUnusedParameters": false

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock } from 'lucide-react'; import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api'; import { agentsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime } from '@/lib/utils';
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 AgentsPage() { export default function AgentsPage() {
const [filters, setFilters] = useState({ search: '', active: '' }); const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['agents', filters], queryKey: ['agents', filters],
@@ -30,7 +45,7 @@ export default function AgentsPage() {
render: (agent: any) => ( render: (agent: any) => (
<div> <div>
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div> <div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
<div className="text-sm text-gray-500">{agent.user?.email}</div> <div className="text-sm text-muted-foreground">{agent.user?.email}</div>
</div> </div>
), ),
}, },
@@ -51,19 +66,21 @@ export default function AgentsPage() {
]; ];
const actions = [ const actions = [
{
label: 'View Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{ {
label: 'View Shifts', label: 'View Shifts',
onClick: (agent: any) => { onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
window.location.href = `/agents/${agent.id}/shifts`;
},
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Clock, icon: Clock,
}, },
{ {
label: 'View Commissions', label: 'View Commissions',
onClick: (agent: any) => { onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
window.location.href = `/agents/${agent.id}/commissions`;
},
variant: 'secondary' as const, variant: 'secondary' as const,
icon: DollarSign, icon: DollarSign,
}, },
@@ -119,6 +136,97 @@ export default function AgentsPage() {
loading={isLoading} loading={isLoading}
emptyMessage="No agents found" emptyMessage="No agents found"
/> />
{/* Agent Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
{selected && (() => {
const a = selected;
const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div>
<div className="from-emerald-600 to-emerald-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r 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-xl font-bold">{initials}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{a.user?.fullName || 'N/A'}</p>
<p className="text-emerald-200 text-sm font-mono">{a.agentCode}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
{a.active ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Agent Code', value: a.agentCode || '—' },
{ label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` },
{ label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() },
].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>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Agent Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Agent Code" value={a.agentCode} mono />
<Field label="Commission Rate" value={`${a.commissionRate ?? 0}%`} />
<Field label="Counter Location" value={a.counterLocation || a.location || 'N/A'} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
{a.active ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
</section>
<section>
<SectionHeader title="User Account" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={a.user?.fullName} />
<Field label="Email" value={a.user?.email} truncate />
<Field label="Phone" value={a.user?.phone} />
<Field label="Role" value={a.user?.role || 'AGENT'} />
<Field label="User ID" value={a.userId || a.user?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Performance" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Total Bookings" value={(a.totalBookings ?? 0).toLocaleString()} />
<Field label="Total Revenue" value={a.totalRevenue ? formatCurrency(a.totalRevenue, 'ETB') : 'N/A'} />
<Field label="Total Commission" value={a.totalCommission ? formatCurrency(a.totalCommission, 'ETB') : 'N/A'} />
<Field label="Pending Commission" value={a.pendingCommission ? formatCurrency(a.pendingCommission, 'ETB') : 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Agent Since" value={formatDateTime(a.createdAt)} />
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
<Field label="Agent ID" value={a.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div> </div>
); );
} }

View File

@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
{/* Details Modal */} {/* Details Modal */}
<Modal <Modal
isOpen={showDetailsModal} isOpen={showDetailsModal}
onClose={() => { onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
setShowDetailsModal(false); title="Audit Log Details"
setSelectedLog(null); size="xl"
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
> >
<div className="space-y-4"> {selectedLog && (() => {
{/* Basic Info */} const l = selectedLog;
<div className="grid grid-cols-2 gap-4"> const actionColor: Record<string, string> = {
<div> CREATE: 'from-emerald-600 to-emerald-700',
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label> UPDATE: 'from-blue-600 to-blue-700',
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p> DELETE: 'from-red-600 to-red-700',
</div> LOGIN: 'from-violet-600 to-violet-700',
<div> LOGOUT: 'from-gray-600 to-gray-700',
<label className="text-xs font-semibold text-muted-foreground">Action</label> };
<p className="text-sm mt-1"> const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
<Badge className={getActionBadgeColor(selectedLog?.action)}>
{selectedLog?.action}
</Badge>
</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
<p className="text-sm mt-1 font-mono text-muted-foreground">
{selectedLog?.entityId || 'System'}
</p>
</div>
</div>
{/* User Info */} const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
{selectedLog?.user && ( <div className="bg-muted/40 rounded-lg p-3">
<div className="border-t pt-4"> <p className="text-xs text-muted-foreground mb-1">{label}</p>
<h4 className="text-sm font-semibold mb-2">User Information</h4> <p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
<div className="grid grid-cols-2 gap-4"> </div>
<div> );
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p> 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>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div> </div>
<div> <div className="mt-4 grid grid-cols-2 gap-3">
<label className="text-xs font-semibold text-muted-foreground">Email</label> <div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-sm mt-1">{selectedLog?.user?.email}</p> <p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div> </div>
</div> </div>
</div>
)}
{/* Network Info */} <div className="space-y-6">
{(selectedLog?.ipAddress || selectedLog?.userAgent) && ( <section>
<div className="border-t pt-4"> <SectionHeader title="Event Details" />
<h4 className="text-sm font-semibold mb-2">Network Information</h4> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-2"> <Field label="Action" value={l.action} />
{selectedLog?.ipAddress && ( <Field label="Entity Type" value={l.entityType} mono />
<div> <Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<label className="text-xs font-semibold text-muted-foreground">IP Address</label> <Field label="Timestamp" value={formatDateTime(l.createdAt)} />
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
</div> </div>
</section>
{l.user && (
<section>
<SectionHeader title="User Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={l.user.fullName} />
<Field label="Email" value={l.user.email} truncate />
<Field label="User ID" value={l.userId} mono truncate />
</div>
</section>
)} )}
{selectedLog?.userAgent && (
<div> {(l.ipAddress || l.userAgent) && (
<label className="text-xs font-semibold text-muted-foreground">User Agent</label> <section>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground"> <SectionHeader title="Network Information" />
{selectedLog?.userAgent} <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
</p> <Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
</div>
</div>
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide"> Before</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide"> After</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
</div>
</section>
)}
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div> </div>
)} </section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
</div> </div>
</div> </div>
)} );
})()}
{/* Changes */}
{(selectedLog?.oldData || selectedLog?.newData) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedLog?.oldData && (
<div>
<label className="text-xs font-semibold text-red-600">Old Data</label>
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.oldData)}
</pre>
</div>
)}
{selectedLog?.newData && (
<div>
<label className="text-xs font-semibold text-green-600">New Data</label>
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.newData)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Raw Log ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
</div>
</div>
</Modal> </Modal>
</div> </div>
); );

View File

@@ -2,15 +2,37 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react'; import { AlertTriangle, CheckCircle, Ban, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { fraudApi } from '@/lib/api'; import { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
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 SEVERITY_GRAD: Record<string, string> = {
CRITICAL: 'from-red-700 to-red-800',
HIGH: 'from-red-600 to-red-700',
MEDIUM: 'from-amber-500 to-amber-600',
LOW: 'from-blue-500 to-blue-600',
};
export default function FraudDetectionPage() { export default function FraudDetectionPage() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
const handleBlockUser = async (alert: any) => { const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) { if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({ await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
} }
}; };
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
key: 'severity', key: 'severity',
label: 'Severity', label: 'Severity',
render: (alert: any) => ( render: (alert: any) => (
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}> <Badge variant="status" status={alert.severity === 'HIGH' || alert.severity === 'CRITICAL' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{alert.severity} {alert.severity}
</Badge> </Badge>
), ),
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
label: 'Rule Type', label: 'Rule Type',
render: (alert: any) => ( render: (alert: any) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" /> <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>{alert.ruleType}</span> <span>{alert.ruleType}</span>
</div> </div>
), ),
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
{ {
key: 'description', key: 'description',
label: 'Description', label: 'Description',
render: (alert: any) => ( render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
<span className="text-sm">{alert.description || alert.details}</span>
),
}, },
{ {
key: 'status', key: 'status',
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
]; ];
const actions = [ const actions = [
{
label: 'View Details',
onClick: (alert: any) => setSelected(alert),
variant: 'secondary' as const,
icon: Eye,
},
{ {
label: 'Acknowledge', label: 'Acknowledge',
onClick: handleAcknowledge, onClick: handleAcknowledge,
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="label">Search</label> <label className="label">Search</label>
<input <input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
type="text"
placeholder="Search alerts..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div> </div>
<div> <div>
<label className="label">Severity</label> <label className="label">Severity</label>
<select <select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
className="input"
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All Severities</option> <option value="">All Severities</option>
<option value="LOW">Low</option> <option value="LOW">Low</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
</div> </div>
<div> <div>
<label className="label">Status</label> <label className="label">Status</label>
<select <select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option> <option value="">All Status</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="acknowledged">Acknowledged</option> <option value="acknowledged">Acknowledged</option>
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
loading={isLoading} loading={isLoading}
emptyMessage="No fraud alerts found" emptyMessage="No fraud alerts found"
/> />
{/* Fraud Alert Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Fraud Alert Details" size="xl">
{selected && (() => {
const al = selected;
const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Fraud Alert</p>
<p className="text-white text-xl font-bold">{al.ruleType}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<Badge variant="status" status={al.severity === 'HIGH' || al.severity === 'CRITICAL' ? 'CANCELLED' : al.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{al.severity}
</Badge>
<div>
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{al.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
</div>
<p className="text-white/70 text-xs">{formatDateTime(al.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Severity', value: al.severity || '—' },
{ label: 'Rule Type', value: al.ruleType || '—' },
{ label: 'User', value: al.user?.fullName || al.user?.email || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Alert Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Rule Type" value={al.ruleType} />
<Field label="Severity" value={al.severity} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{al.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
</div>
<Field label="Detected At" value={formatDateTime(al.createdAt)} />
<div className="col-span-2 md:col-span-4 bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">Description</p>
<p className="text-sm font-medium">{al.description || al.details || '—'}</p>
</div>
</div>
</section>
<section>
<SectionHeader title="Flagged User" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={al.user?.fullName} />
<Field label="Email" value={al.user?.email} truncate />
<Field label="Phone" value={al.user?.phone} />
<Field label="User ID" value={al.userId || al.user?.id} mono truncate />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Blocked</p>
<Badge variant="status" status={al.user?.isBlocked ? 'CANCELLED' : 'CONFIRMED'}>
{al.user?.isBlocked ? 'Blocked' : 'Not Blocked'}
</Badge>
</div>
</div>
</section>
{al.bookingId && (
<section>
<SectionHeader title="Related Booking" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Booking ID" value={al.bookingId} mono truncate />
<Field label="Booking Ref" value={al.booking?.bookingRef} mono />
<Field label="Amount" value={al.booking?.totalMinor ? `ETB ${(al.booking.totalMinor / 100).toFixed(2)}` : 'N/A'} />
</div>
</section>
)}
{al.acknowledged && (
<section>
<SectionHeader title="Resolution" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Acknowledged At" value={al.acknowledgedAt ? formatDateTime(al.acknowledgedAt) : '—'} />
<Field label="Acknowledged By" value={al.acknowledgedBy?.fullName || al.acknowledgedBy?.email || '—'} />
<Field label="Notes" value={al.resolutionNotes || '—'} truncate />
</div>
</section>
)}
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Alert ID" value={al.id} mono truncate />
<Field label="Created" value={formatDateTime(al.createdAt)} />
<Field label="Last Updated" value={formatDateTime(al.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div> </div>
); );
} }

View File

@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store'; import { useTheme } from '@/lib/theme-store';
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; import {
Eye, EyeOff, Sun, Moon, ArrowRight, Loader2,
TicketCheck, Users, TrendingUp, ShieldCheck,
} from 'lucide-react';
const EDR_GREEN = 'rgb(20, 113, 76)';
const features = [
{ icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' },
{ icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' },
{ icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' },
{ icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' },
];
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const router = useRouter(); const [emailFocused, setEmailFocused] = useState(false);
const { login } = useAuthStore(); const [passwordFocused, setPasswordFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const { isDark, toggleTheme } = useTheme(); const { isDark, toggleTheme } = useTheme();
useEffect(() => { useEffect(() => { setIsMounted(true); }, []);
setIsMounted(true);
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await login(email, password); await login(email, password);
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.'; setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (!isMounted) { if (!isMounted) return null;
return null;
}
return ( return (
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]"> <div className="flex min-h-screen bg-white dark:bg-gray-950">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
{/* Content Overlay */} {/* ── LEFT PANEL — form ── */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16"> <div className="flex-1 lg:flex-none lg:w-[42%] xl:w-[38%] flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 relative">
<div className="w-full max-w-sm">
{/* Login Card with Shadow */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
{/* Card Header with Logo, App Name and Theme Toggle */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
<Train className="h-9 w-9 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
</div>
</div>
<button {/* Top bar */}
onClick={toggleTheme} <div className="flex items-center justify-between px-8 py-4 lg:px-10">
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors" {/* Logo — always visible on the form panel */}
aria-label="Toggle theme" <div className="flex items-center gap-2.5">
> <div className="w-8 h-8 rounded-lg bg-[rgb(20,113,76)] flex items-center justify-center shadow-md shadow-[rgb(20,113,76)]/30">
{isDark ? ( <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<Sun className="w-5 h-5 text-yellow-500" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
) : ( <path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
<Moon className="w-5 h-5 text-gray-700" /> <circle cx="9" cy="9" r="1" fill="currentColor" />
)} <circle cx="15" cy="9" r="1" fill="currentColor" />
</button> </svg>
</div>
<div>
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
<div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
aria-label="Toggle theme"
>
{isDark
? <Sun className="w-4 h-4 text-amber-400" />
: <Moon className="w-4 h-4" />
}
</button>
</div>
{/* Form area */}
<div className="flex-1 flex items-center justify-center px-8 py-10 lg:px-10 xl:px-14">
<div className="w-full max-w-xs">
{/* Heading */}
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
Sign in to continue
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Enter your credentials to access the back-office.
</p>
</div> </div>
{/* Card Body */} {/* Error */}
<div className="p-6"> {error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
<div className="mb-8"> <div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2> <div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p> <span className="text-white text-[10px] font-bold">!</span>
</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 border border-red-200 dark:border-red-800">
{error}
</div> </div>
)} <p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div></div>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label> {/* Email field */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Email address
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
emailFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" onFocus={() => setEmailFocused(true)}
placeholder="name@email.com" onBlur={() => setEmailFocused(false)}
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="name@edr.com"
required required
autoComplete="email"
/> />
</div> </div>
</div>
<div> {/* Password field */}
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label> <div>
<div className="relative"> <div className="flex items-center justify-between mb-2">
<input <label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
type={showPassword ? 'text' : 'password'} Password
value={password} </label>
onChange={(e) => setPassword(e.target.value)} <button
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" type="button"
placeholder="••••••••" className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
required >
/> Forgot password?
<button </button>
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Toggle password visibility"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div> </div>
<div className={`relative rounded-xl transition-all duration-200 ${
passwordFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
onFocus={() => setPasswordFocused(true)}
onBlur={() => setPasswordFocused(false)}
className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="••••••••••"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
aria-label="Toggle password visibility"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button {/* Submit */}
type="submit" <button
disabled={loading} type="submit"
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200" disabled={loading || !email || !password}
> className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
{loading ? 'Signing in...' : 'Sign in'} style={{ background: loading || !email || !password
</button> ? 'rgb(20,113,76)'
</form> : `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
}}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Signing in
</>
) : (
<>
Sign in
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
</>
)}
</button>
</form>
{/* Divider */}
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800/60 animate-fade-up" style={{ animationDelay: '160ms' }}>
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
<ShieldCheck className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
Access is restricted to authorised EDR staff only. All sessions are logged and audited.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Bottom bar */}
<div className="px-8 py-4 lg:px-10 flex items-center justify-between">
<span className="text-xs text-gray-400 dark:text-gray-600">
Back-office · v1.0
</span>
<span className="text-xs text-gray-400 dark:text-gray-600">
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
</span>
</div>
</div>
{/* ── RIGHT PANEL — photo ── */}
<div className="hidden lg:flex flex-1 relative flex-col overflow-hidden">
{/* Layer 1 — base photo, desaturated */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: "url('/banner.jpg')",
filter: isDark
? 'saturate(0.1) brightness(1)'
: 'saturate(0.15) brightness(1)',
}}
/>
{/* Layer 2 — brand green color wash */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(145deg, rgb(5,46,30) 0%, rgb(20,113,76) 55%, rgb(4,120,67) 100%)',
mixBlendMode: 'multiply',
opacity: isDark ? 0.8 : 0.4,
}}
/>
{/* Content */}
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
{/* Badge */}
<div className="flex justify-start">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-3 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-white/80 text-xs font-medium tracking-wide">Back-office Portal v1.0</span>
</div>
</div>
{/* Hero text */}
<div className="mt-auto mb-auto">
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-4">
Passenger<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">
Management
</span>
<br />System
</h1>
<p className="text-white/60 text-base leading-relaxed max-w-sm">
Unified platform for booking operations, passenger services, revenue analytics, and real-time train management.
</p>
</div>
{/* Feature grid */}
<div className="mt-auto grid grid-cols-2 gap-3">
{features.map(({ icon: Icon, label, desc }) => (
<div
key={label}
className="flex items-start gap-3 bg-white/5 hover:bg-white/10 backdrop-blur-sm border border-white/10 rounded-xl p-3.5 transition-colors duration-200"
>
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[rgb(20,113,76)]/40 flex items-center justify-center">
<Icon className="w-4 h-4 text-emerald-300" />
</div>
<div>
<div className="text-white text-xs font-semibold">{label}</div>
<div className="text-white/40 text-xs mt-0.5">{desc}</div>
</div>
</div>
))}
</div>
{/* Bottom rule */}
<div className="mt-8 pt-6 border-t border-white/10">
<span className="text-white/30 text-xs block">© 2026 Ethio-Djibouti Railway S.C. Secure · Encrypted · Monitored</span>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,15 +2,44 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react'; import { Download, Eye, Star } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { loyaltyApi } from '@/lib/api'; import { loyaltyApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
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',
};
const TIER_GRAD: Record<string, string> = {
BRONZE: 'from-orange-500 to-orange-600',
SILVER: 'from-gray-500 to-gray-600',
GOLD: 'from-yellow-500 to-yellow-600',
PLATINUM: 'from-indigo-600 to-indigo-700',
};
export default function LoyaltyPage() { export default function LoyaltyPage() {
const [filters, setFilters] = useState({ search: '', tier: '' }); const [filters, setFilters] = useState({ search: '', tier: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters], queryKey: ['loyalty', filters],
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
}); });
const columns = [ const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, { key: 'passenger', label: 'Passenger', render: (account: any) => (
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> }, <div>
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 }, <div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 }, <div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
]; </div>
)},
{ key: 'tier', label: 'Tier', render: (account: any) => (
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${TIER_COLORS[account.tier] || TIER_COLORS.BRONZE}`}>
<Star className="w-3 h-3" />{account.tier}
</span>
)},
{ key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() },
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() },
];
const actions = [
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Tier</label>
<label className="label">Tier</label> <select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}> <option value="">All Tiers</option>
<option value="">All Tiers</option> <option value="BRONZE">Bronze</option>
<option value="BRONZE">Bronze</option> <option value="SILVER">Silver</option>
<option value="SILVER">Silver</option> <option value="GOLD">Gold</option>
<option value="GOLD">Gold</option> <option value="PLATINUM">Platinum</option>
<option value="PLATINUM">Platinum</option> </select>
</select> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={Array.isArray(data) ? data : (data?.items || [])} data={Array.isArray(data) ? data : (data?.items || [])}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} loading={isLoading}
emptyMessage="No loyalty program found" emptyMessage="No loyalty accounts found"
/> />
{/* Loyalty Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Loyalty Account Details" size="xl">
{selected && (() => {
const a = selected;
const tier = a.tier || 'BRONZE';
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700';
const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} 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">
<Star className="w-7 h-7 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
<p className="text-white/70 text-sm">{a.passenger?.email || a.user?.email || ''}</p>
</div>
<div className="text-right shrink-0">
<span className={`inline-flex items-center gap-1 text-xs font-bold px-3 py-1 rounded-full border ${tierColor}`}>
<Star className="w-3 h-3" />{tier}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() },
{ label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() },
{ label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Account Overview" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Current Tier" value={tier} />
<Field label="Points Balance" value={(a.pointsBalance ?? 0).toLocaleString()} />
<Field label="Lifetime Points" value={(a.lifetimePoints ?? 0).toLocaleString()} />
<Field label="Points Redeemed" value={(a.pointsRedeemed ?? 0).toLocaleString()} />
<Field label="Points Expiring" value={a.pointsExpiring ? a.pointsExpiring.toLocaleString() : 'N/A'} />
<Field label="Expiry Date" value={a.expiryDate ? formatDateTime(a.expiryDate) : 'N/A'} />
<Field label="Tier Since" value={a.tierAchievedAt ? formatDateTime(a.tierAchievedAt) : 'N/A'} />
<Field label="Next Tier" value={a.nextTier || 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={a.passenger?.fullName || a.user?.fullName} />
<Field label="Email" value={a.passenger?.email || a.user?.email} truncate />
<Field label="Phone" value={a.passenger?.phone || a.user?.phone} />
<Field label="Passenger ID" value={a.passengerId || a.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Account Created" value={formatDateTime(a.createdAt)} />
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
<Field label="Account ID" value={a.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div> </div>
); );
} }

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react'; import { Download, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api'; import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
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 || '\u2014'}</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 PaymentsPage() { export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' }); const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
]; ];
const paymentActions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
<DataTable <DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])} data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns} columns={columns}
actions={[]} actions={paymentActions}
loading={isLoading} loading={isLoading}
emptyMessage="No payments found" emptyMessage="No payments found"
/> />
{/* Payment Details Modal */}
<Modal isOpen={!!selectedPayment} onClose={() => setSelectedPayment(null)} title="Payment Details" size="xl">
{selectedPayment && (() => {
const p = selectedPayment;
const statusGrad: Record<string, string> = {
COMPLETED: 'from-emerald-600 to-emerald-700',
FAILED: 'from-red-600 to-red-700',
PENDING: 'from-amber-500 to-amber-600',
REFUNDED: 'from-blue-600 to-blue-700',
};
const grad = statusGrad[p.status] || 'from-gray-600 to-gray-700';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Payment Reference</p>
<p className="text-white text-2xl font-mono font-bold">{p.reference || p.id?.substring(0, 8)}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={p.status}>{p.status}</Badge>
<p className="text-white/70 text-xs mt-1">{formatDateTime(p.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
{ label: 'Method', value: p.method || '—' },
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Transaction" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
<p className="text-xs text-muted-foreground mb-1">Amount</p>
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
</div>
<Field label="Method" value={p.method} />
<Field label="Status" value={p.status} />
<Field label="Reference" value={p.reference} mono truncate />
<Field label="Provider Ref" value={p.providerReference || p.externalReference} mono truncate />
<Field label="Created" value={formatDateTime(p.createdAt)} />
<Field label="Completed At" value={p.completedAt ? formatDateTime(p.completedAt) : 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Booking" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Booking Ref" value={p.booking?.bookingRef} mono />
<Field label="Booking Status" value={p.booking?.status} />
<Field label="Passenger" value={p.booking?.passenger?.fullName || p.booking?.contactEmail} truncate />
<Field label="Booking ID" value={p.bookingId} mono truncate />
</div>
</section>
{(p.failureReason || p.failureCode) && (
<section>
<SectionHeader title="Failure Information" />
<div className="grid grid-cols-2 gap-3">
<Field label="Failure Code" value={p.failureCode} mono />
<Field label="Failure Reason" value={p.failureReason} truncate />
</div>
</section>
)}
<section>
<SectionHeader title="IDs" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Payment ID" value={p.id} mono truncate />
<Field label="Last Updated" value={formatDateTime(p.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedPayment(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
{/* Export Modal */} {/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md"> <Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -1,11 +1,48 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Save, Users } from 'lucide-react'; import { Save } from 'lucide-react';
import Link from 'next/link'; import { systemConfigApi } from '@/lib/api';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() { export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general'); const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configMessage, setConfigMessage] = useState('');
useEffect(() => {
if (activeTab !== 'configurations') return;
setConfigLoading(true);
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
})
.catch(() => {})
.finally(() => setConfigLoading(false));
}, [activeTab]);
const saveConfigurations = async () => {
setConfigSaving(true);
setConfigMessage('');
try {
await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes });
setConfigMessage('Saved successfully.');
} catch {
setConfigMessage('Failed to save.');
} finally {
setConfigSaving(false);
}
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'payment', label: 'Payment' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'configurations', label: 'Configurations' },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -14,31 +51,24 @@ export default function SettingsPage() {
<h1 className="text-2xl font-bold text-foreground">Settings</h1> <h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage system settings and configurations</p> <p className="text-muted-foreground">Manage system settings and configurations</p>
</div> </div>
<button className="btn btn-primary flex items-center gap-2"> {activeTab !== 'configurations' && (
<Save className="h-4 w-4" /> <button className="btn btn-primary flex items-center gap-2">
Save Changes <Save className="h-4 w-4" />
</button> Save Changes
</button>
)}
</div> </div>
<div className="flex gap-2 border-b border-border"> <div className="flex gap-2 border-b border-border">
<button {tabs.map((tab) => (
onClick={() => setActiveTab('general')} <button
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`} key={tab.id}
> onClick={() => setActiveTab(tab.id)}
General className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
</button> >
<button {tab.label}
onClick={() => setActiveTab('payment')} </button>
className={`px-4 py-2 font-medium ${activeTab === 'payment' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`} ))}
>
Payment
</button>
<button
onClick={() => setActiveTab('integrations')}
className={`px-4 py-2 font-medium ${activeTab === 'integrations' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Integrations
</button>
</div> </div>
{activeTab === 'general' && ( {activeTab === 'general' && (
@@ -139,6 +169,46 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
)} )}
{activeTab === 'configurations' && (
<div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="hold-duration">
Seat Hold Duration (minutes)
</label>
<input
id="hold-duration"
type="number"
min="1"
max="60"
className="input"
value={seatHoldMinutes}
onChange={(e) => setSeatHoldMinutes(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
</p>
</div>
)}
<div className="flex items-center gap-3">
<button
className="btn btn-primary flex items-center gap-2"
onClick={saveConfigurations}
disabled={configSaving || configLoading}
>
<Save className="h-4 w-4" />
{configSaving ? 'Saving...' : 'Save Changes'}
</button>
{configMessage && (
<span className="text-sm text-muted-foreground">{configMessage}</span>
)}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -23,6 +23,19 @@ export default function TicketsPage() {
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
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 [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
@@ -534,111 +547,116 @@ export default function TicketsPage() {
isOpen={detailsModalOpen} isOpen={detailsModalOpen}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }} onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details" title="Ticket Details"
size="lg" size="xl"
> >
{selectedTicket && ( {selectedTicket && (() => {
<div className="space-y-6"> const t = selectedTicket;
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> const b = t.booking;
<div> const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
<p className="text-sm text-muted-foreground">Ticket Number</p> const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p> return (
</div> <div>
<div> {/* Gradient header */}
<p className="text-sm text-muted-foreground">Status</p> <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="mt-1"> <div className="flex items-start justify-between gap-4">
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
{selectedTicket.status || 'ACTIVE'}
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Booking Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Booking Reference</p>
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Passenger</p>
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Trip Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Route</p>
<p className="font-medium">
{selectedTicket.schedule?.originStation?.name || 'N/A'} {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Departure</p>
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Seat Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Coach</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seat Number</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Class</p>
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
</div>
</div>
</div>
{selectedTicket.validatedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Validated At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
</div>
)}
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<p className="text-sm text-muted-foreground">Leg Status</p> <p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p> <p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div> </div>
<div> <div className="text-right shrink-0">
<p className="text-sm text-muted-foreground">Outbound Boarded</p> <Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p> {t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
</div>
<div>
<p className="text-sm text-muted-foreground">Return Boarded</p>
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
</div> </div>
</div> </div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Passenger', value: passengerName },
{ label: 'Route', value: `${t.schedule?.originStation?.name || '?'}${t.schedule?.destinationStation?.name || '?'}` },
{ label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') },
].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>
))}
</div>
</div> </div>
)}
<div className="flex justify-end gap-2 pt-4"> <div className="space-y-6">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}> {/* Booking */}
Close <section>
</ActionButton> <SectionHeader title="Booking Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Booking Ref" value={b?.bookingRef} mono />
<Field label="Booking Type" value={(b?.bookingType || 'ONE_WAY').replace(/_/g, ' ')} />
<Field label="Payment Status" value={b?.paymentIntent?.status || 'N/A'} />
<Field label="Contact Phone" value={b?.contactPhone || b?.passenger?.phone} />
<Field label="Contact Email" value={b?.contactEmail || b?.passenger?.email} truncate />
<Field label="Adults" value={String(b?.adultCount ?? 0)} />
<Field label="Children" value={String(b?.childCount ?? 0)} />
<Field label="Booking ID" value={b?.id} mono truncate />
</div>
</section>
{/* Trip */}
<section>
<SectionHeader title="Trip Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={t.schedule?.originStation?.name} />
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
<Field label="Schedule ID" value={t.scheduleId} mono truncate />
</div>
</section>
{/* Seat */}
<section>
<SectionHeader title="Seat Information" />
<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 md:col-span-1 flex flex-col items-center justify-center">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Seat</p>
<p className="text-2xl font-mono font-bold text-emerald-800 dark:text-emerald-300">{t.seat?.seatNumber || '—'}</p>
</div>
<Field label="Coach" value={t.seat?.coach?.number} mono />
<Field label="Class" value={t.seat?.coach?.coachType?.name || t.seat?.coach?.coachType?.type} />
<Field label="Seat ID" value={t.seatId} mono truncate />
</div>
</section>
{/* Round-trip */}
{isRoundTrip && (
<section>
<SectionHeader title="Round-Trip Legs" />
<div className="grid grid-cols-2 md:grid-cols-3 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'} />
</div>
</section>
)}
{/* Validation */}
<section>
<SectionHeader title="Validation & Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
<Field label="Created" value={formatDateTime(t.createdAt)} />
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
<Field label="Ticket ID" value={t.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close</ActionButton>
</div>
</div> </div>
</div> );
)} })()}
</Modal> </Modal>
{/* Export Modal */} {/* Export Modal */}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react'; import { Download, Eye, ShieldCheck, ShieldOff } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { verifaydaApi } from '@/lib/api'; import { verifaydaApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
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 VerifaydaPage() { export default function VerifaydaPage() {
const [filters, setFilters] = useState({ search: '', verified: '' }); const [filters, setFilters] = useState({ search: '', verified: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['verifayda', filters], queryKey: ['verifayda', filters],
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
}); });
const columns = [ const columns = [
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> }, { key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' }, { key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || ver.returnedName || 'N/A' },
{ key: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> }, { key: 'verified', label: 'Status', render: (ver: any) => (
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) }, <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>
]; {ver.verified ? 'Verified' : 'Failed'}
</Badge>
)},
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
];
const actions = [
{ label: 'View Details', onClick: (v: any) => setSelected(v), variant: 'secondary' as const, icon: Eye },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Status</label>
<label className="label">Status</label> <select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}> <option value="">All</option>
<option value="">All</option> <option value="true">Verified</option>
<option value="true">Verified</option> <option value="false">Failed</option>
<option value="false">Failed</option> </select>
</select> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={data?.items || data || []} data={data?.items || data || []}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} loading={isLoading}
emptyMessage="No verifayda integration found" emptyMessage="No verification records found"
/> />
{/* Verifayda Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Verification Details" size="xl">
{selected && (() => {
const v = selected;
const isVerified = !!v.verified;
const grad = isVerified ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700';
const name = v.fullName || v.returnedName || 'N/A';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} 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">
{isVerified
? <ShieldCheck className="w-7 h-7 text-white" />
: <ShieldOff className="w-7 h-7 text-white" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{name}</p>
<p className="text-white/70 text-sm font-mono">{v.nationalId}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'CANCELLED'}>
{isVerified ? '✓ Verified' : '✗ Failed'}
</Badge>
<p className="text-white/70 text-xs mt-1">{formatDateTime(v.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'National ID', value: v.nationalId || '—' },
{ label: 'Date of Birth', value: v.dateOfBirth || v.returnedDob || '—' },
{ label: 'Nationality', value: v.nationality || 'Ethiopian' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Verification Result" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<div className="flex items-center gap-2">
{isVerified
? <ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
: <ShieldOff className="w-4 h-4 text-red-500 shrink-0" />}
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{isVerified ? 'Verified' : 'Failed'}
</span>
</div>
</div>
<Field label="Verified At" value={formatDateTime(v.createdAt)} />
<Field label="Failure Reason" value={v.failureReason || (isVerified ? 'N/A' : 'Verification failed')} truncate />
<Field label="Response Code" value={v.responseCode || 'N/A'} mono />
</div>
</section>
<section>
<SectionHeader title="Identity Data (from Fayda)" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="National ID" value={v.nationalId} mono />
<Field label="Full Name" value={v.fullName || v.returnedName} />
<Field label="Date of Birth" value={v.dateOfBirth || v.returnedDob} />
<Field label="Gender" value={v.gender || v.returnedGender} />
<Field label="Nationality" value={v.nationality || 'Ethiopian'} />
<Field label="Phone" value={v.phone || v.returnedPhone} />
</div>
</section>
<section>
<SectionHeader title="Linked Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Passenger Name" value={v.passenger?.fullName || v.user?.fullName} />
<Field label="Email" value={v.passenger?.email || v.user?.email} truncate />
<Field label="Passenger ID" value={v.passengerId || v.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Record ID" value={v.id} mono truncate />
<Field label="Created" value={formatDateTime(v.createdAt)} />
<Field label="Last Updated" value={formatDateTime(v.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div> </div>
); );
} }

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react'; import { Download, Eye, Wallet } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { walletApi } from '@/lib/api'; import { walletApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
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 WalletPage() { export default function WalletPage() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['wallet', filters], queryKey: ['wallet', filters],
@@ -18,10 +33,25 @@ export default function WalletPage() {
}); });
const columns = [ const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, { key: 'passenger', label: 'Passenger', render: (account: any) => (
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') }, <div>
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> }, <div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
]; <div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
</div>
)},
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => (
<span className="font-semibold">{formatCurrency(account.balanceMinor, account.currency || 'ETB')}</span>
)},
{ key: 'status', label: 'Status', render: (account: any) => (
<Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{account.isActive ? 'Active' : 'Inactive'}
</Badge>
)},
];
const actions = [
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -35,21 +65,113 @@ export default function WalletPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={data?.items || data || []} data={data?.items || data || []}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} loading={isLoading}
emptyMessage="No wallet management found" emptyMessage="No wallet accounts found"
/> />
{/* Wallet Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Wallet Account Details" size="xl">
{selected && (() => {
const w = selected;
const balance = w.balanceMinor ?? 0;
const passengerName = w.passenger?.fullName || w.user?.fullName || 'N/A';
return (
<div>
<div className="from-blue-600 to-blue-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r 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">
<Wallet className="w-7 h-7 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
<p className="text-blue-200 text-sm">{w.passenger?.email || w.user?.email || ''}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') },
{ label: 'Currency', value: w.currency || 'ETB' },
{ label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-blue-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Balance" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-blue-700 dark:text-blue-400 mb-1">Current Balance</p>
<p className="text-xl font-bold text-blue-800 dark:text-blue-300">{formatCurrency(balance, w.currency || 'ETB')}</p>
</div>
<Field label="Total Topped Up" value={formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB')} />
<Field label="Total Spent" value={formatCurrency(w.totalSpent ?? 0, w.currency || 'ETB')} />
</div>
</section>
<section>
<SectionHeader title="Account Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Currency" value={w.currency || 'ETB'} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<Field label="Locked" value={w.isLocked ? 'Yes' : 'No'} />
<Field label="Lock Reason" value={w.lockReason || 'N/A'} truncate />
</div>
</section>
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={w.passenger?.fullName || w.user?.fullName} />
<Field label="Email" value={w.passenger?.email || w.user?.email} truncate />
<Field label="Phone" value={w.passenger?.phone || w.user?.phone} />
<Field label="Passenger ID" value={w.passengerId || w.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(w.createdAt)} />
<Field label="Last Updated" value={formatDateTime(w.updatedAt)} />
<Field label="Account ID" value={w.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div> </div>
); );
} }

View File

@@ -373,3 +373,9 @@ export const reportsApi = {
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] }; return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
}, },
}; };
// System Config API
export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
};

View File

@@ -4,9 +4,17 @@ import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; 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 { interface AuthState {
user: AdminUser | null; user: AdminUser | null;
token: string | null; token: string | null;
refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
@@ -17,6 +25,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
user: null, user: null,
token: null, token: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
initialize: () => { initialize: () => {
@@ -27,57 +36,47 @@ export const useAuthStore = create<AuthState>((set) => ({
try { try {
const user = JSON.parse(userStr); const user = JSON.parse(userStr);
set({ user, token, isAuthenticated: true }); set({ user, token, isAuthenticated: true });
} catch (e) { } catch {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user');
} }
} }
}, },
login: async (email: string, password: string) => { login: async (email: string, password: string) => {
try { // Step 1: IAM login — returns token + refreshToken only
console.log('Attempting login to:', `${API_URL}/auth/login`); const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
const response = await axios.post(`${API_URL}/auth/login`, { email, password }); const loginData = loginRes.data?.data ?? loginRes.data;
console.log('Full response:', response.data); const { token, refreshToken } = loginData;
if (!token) throw new Error('No token received from server');
// Backend wraps response in { success, data: { token, user }, timestamp } // Step 2: fetch full user info with the token
const responseData = response.data.data || response.data; const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const iamUser = meRes.data?.data ?? meRes.data;
if (!responseData || !responseData.token || !responseData.user) { const user: AdminUser = {
console.error('Invalid response structure:', response.data); id: iamUser.id,
throw new Error('Invalid response from server'); email: iamUser.email,
} fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
role: mapIamRole(iamUser.roles ?? []),
active: true,
};
const { token, user: apiUser } = responseData; localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
const user: AdminUser = { set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
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;
}
}, },
logout: () => { logout: () => {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user'); 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) => { setUser: (user: AdminUser, token: string) => {

View File

@@ -60,6 +60,16 @@
} }
} }
@layer utilities {
@keyframes fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-up {
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
@layer components { @layer components {
.card { .card {
background-color: hsl(var(--card)); background-color: hsl(var(--card));

22066
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff