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
PORT=4000
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
# Database (Prisma) — owns the `passenger` schema in edr_database
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource.
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=edr_database
DATABASE_USER=edr
DATABASE_PASSWORD=edr_secret
DATABASE_SCHEMA=iam
# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications).
# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev.
RABBITMQ_URL=amqp://localhost:5672
# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these).
# Placeholders for dev; only contacted when file upload/download features are actually used.
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=edr-dev
# CORS
FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184
# JWT
# JWT (legacy passenger auth — being replaced by IAM)
JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
JWT_ACCESS_TOKEN_EXPIRES=1h
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
JWT_REFRESH_TOKEN_EXPIRES=7d
# SendGrid
SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com

View File

@@ -11,6 +11,8 @@
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit",
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
@@ -27,26 +29,30 @@
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^11.1.24",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@nestjs/typeorm": "^11.0.1",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"jose": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.21.0",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",
"uuid": "^10.0.0"
},
"devDependencies": {
@@ -55,11 +61,9 @@
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.0",

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?
faydaSub String? @unique
passenger Passenger?
agent Agent?
sessions Session[]
devices Device[]
preferences UserPreferences?
auditLogs AuditLog[]
fraudAlerts FraudAlert[]
sessions Session[]
faydaVerificationSessions FaydaVerificationSession[]
@@schema("passenger")
}
@@ -286,10 +279,12 @@ model Session {
}
model Passenger {
id String @id @default(uuid())
userId String @unique
id String @id @default(uuid())
userId String? @unique
iamUserId String? @unique
defaultTravelerProfileId String?
preferredLanguage String?
blockedUntil DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
bookings Booking[]
@@ -298,8 +293,9 @@ model Passenger {
notifications Notification[]
travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
packageBookings PackageBooking[]
@@index([userId])
@@index([iamUserId])
@@schema("passenger")
}
@@ -376,6 +372,8 @@ model TrainSchedule {
liveStatus TripLiveStatus?
menuItems MenuItem[]
journeySegments JourneySegment[]
outboundPackages TravelPackage[] @relation("PackageOutbound")
returnPackages TravelPackage[] @relation("PackageReturn")
@@index([departureAt, originStationId])
@@schema("passenger")
@@ -895,7 +893,7 @@ model SupportMessage {
model UserPreferences {
id String @id @default(uuid())
userId String @unique
iamUserId String @unique
pushEnabled Boolean @default(true)
emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false)
@@ -908,19 +906,19 @@ model UserPreferences {
locale String @default("en")
darkMode Boolean @default(false)
language String @default("en")
user User @relation(fields: [userId], references: [id])
@@schema("passenger")
}
model Device {
id String @id @default(uuid())
userId String
iamUserId String
platform DevicePlatform
name String
pushToken String?
trusted Boolean @default(false)
lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@schema("passenger")
}
@@ -1063,16 +1061,16 @@ model SegmentFareRule {
model Agent {
id String @id @default(uuid())
userId String @unique
iamUserId String? @unique
agentCode String @unique
stationId String?
commissionRate Int @default(5)
active Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
bookings AgentBooking[]
shifts AgentShift[]
commissions AgentCommission[]
@@index([iamUserId])
@@schema("passenger")
}
@@ -1191,19 +1189,17 @@ model BaggageBooking {
}
model AuditLog {
id String @id @default(uuid())
userId String?
action String
entityType String
entityId String?
oldData Json?
newData Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
@@index([userId, createdAt])
id String @id @default(uuid())
iamUserId String?
action String
entityType String
entityId String?
oldData Json?
newData Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([iamUserId, createdAt])
@@index([entityType, entityId])
@@schema("passenger")
}
@@ -1259,16 +1255,14 @@ model FraudRule {
model FraudAlert {
id String @id @default(uuid())
userId String
iamUserId String
eventType String
triggeredRules String[]
context Json
severity String @default("MEDIUM")
acknowledged Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([iamUserId, createdAt])
@@index([acknowledged])
@@schema("passenger")
}
@@ -1338,14 +1332,144 @@ model FaydaVerificationSession {
expiresAt DateTime
completedAt DateTime?
userId String?
iamUserId String?
bookingId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([iamUserId])
@@index([bookingId])
@@index([state])
@@index([expiresAt])
@@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`);
}
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,
// 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.
@@ -691,7 +755,8 @@ async function main() {
['fare rules', seedFareRules],
['segment fares', seedSegmentFares],
['currency', seedCurrency],
['notification templates', seedNotificationTemplates]
['notification templates', seedNotificationTemplates],
['kulubbi package', seedKulubbiPackage],
];
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 { ConfigModule } from '@nestjs/config';
import {
MiddlewareConsumer,
Module,
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder';
import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module';
import {
EDR_PASSENGER_APPLICATION,
EDR_PASSENGER_PERMISSIONS,
} from './seed/edr-passenger.seed';
import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder';
import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder';
import { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import iamDatabaseConfig from './config/iam-database.config';
import telebirrConfig from './config/telebirr.config';
import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config';
@@ -42,6 +57,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.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({
imports: [
@@ -50,6 +67,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
load: [
appConfig,
dbConfig,
iamDatabaseConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
@@ -61,11 +79,20 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>('iamDatabase')!,
}),
TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS,
}),
SharedAuthModule,
PrismaModule,
AuditModule,
I18nModule,
IamModule,
AuthModule,
AuthModule,
StationsModule,
FleetModule,
SchedulesModule,
@@ -91,10 +118,28 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
VerifaydaModule,
AuditModuleFeature,
CurrenciesModule,
SystemConfigModule,
PackagesModule,
],
providers: [
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LocaleMiddleware).forRoutes('*');
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
) {}
async onApplicationBootstrap() {
try {
await this.seeder.run();
} catch (err) {
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
}
await this.edrPassengerOrgSeeder.run();
await this.passengerStaffUsersSeeder.run();
}
}

View File

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

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';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
/**
* IAM Adapter for @tria-plc corporate identity integration
*
* This adapter wraps the corporate IAM guards and provides a bridge
* between the corporate identity system and the EDR passenger API.
*
* For back-office roles (agent, supervisor, admin, staff), this guard
* validates tokens against the corporate IAM service.
*
* For passenger-facing routes, the existing JWT guard is used.
*/
export interface IamTokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
organizationId?: string;
exp: number;
iat: number;
}
export interface IamValidationResponse {
valid: boolean;
payload?: IamTokenPayload;
error?: string;
}
@Injectable()
export class IamGuard implements CanActivate {
private readonly iamApiUrl: string;
private readonly iamEnabled: boolean;
constructor(
private readonly reflector: Reflector,
private readonly config: ConfigService,
private readonly http: HttpService,
) {
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.iamEnabled) {
// IAM disabled - allow access (for development)
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No authentication token provided');
}
const validation = await this.validateToken(token);
if (!validation.valid || !validation.payload) {
throw new UnauthorizedException(validation.error || 'Invalid token');
}
// Check required roles
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
}
// Attach user to request
request.user = {
userId: validation.payload.sub,
email: validation.payload.email,
roles: validation.payload.roles,
permissions: validation.payload.permissions,
organizationId: validation.payload.organizationId,
};
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}
private async validateToken(token: string): Promise<IamValidationResponse> {
try {
const response = await firstValueFrom(
this.http.post<IamValidationResponse>(
`${this.iamApiUrl}/v1/auth/validate`,
{ token },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
},
timeout: 5000,
},
),
);
return response.data;
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : 'Token validation failed',
};
}
}
}
/**
* Decorator to mark routes as requiring IAM authentication
*/
export const UseIamAuth = () => {
// This is a marker decorator that can be used with @UseGuards(IamGuard)
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
// Marker only - actual guard is applied via @UseGuards
};
};
/**
* Decorator to specify required roles for IAM-protected routes
*/
export const IamRoles = (...roles: string[]) => {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata('roles', roles, descriptor.value);
}
};
};
export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';

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

View File

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

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 { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

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

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 { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
@@ -12,6 +16,11 @@ async function bootstrap() {
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
app.enableVersioning({ type: VersioningType.URI });
app.enableCors({
origin: [
process.env.PORTAL_URL ?? "http://localhost:5174",

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 { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents')
@Controller('agents')
@UseGuards(IamGuard)
// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
export class AgentsController {
constructor(private service: AgentsService) {}
@Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto);
}
@Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto);
}
@Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto);
}
@Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' })
getCommissions(
@Param('agentId') agentId: string,
@@ -49,7 +47,6 @@ export class AgentsController {
}
@Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId);

View File

@@ -13,9 +13,13 @@ export class AgentsService {
constructor(private prisma: PrismaService) {}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
const passenger = agent.iamUserId
? await this.prisma.passenger.findUnique({ where: { iamUserId: agent.iamUserId } })
: null;
if (!passenger) throw new BadRequestException('Agent must have a linked passenger account');
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
@@ -30,7 +34,7 @@ export class AgentsService {
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: agent.user.passenger.id,
passengerId: passenger.id,
scheduleId: dto.scheduleId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor,

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

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 { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private service: AuthService) {}
constructor(private passengerAuthService: PassengerAuthService) {}
@Post('register')
@ApiOperation({
summary: 'Register new passenger account',
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@IsPublic()
@ApiOperation({ summary: 'Register new passenger account' })
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
register(@Request() req: any, @Body() dto: RegisterDto) {
return this.passengerAuthService.register(dto, req);
}
@Post('login')
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Login with email and password',
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
})
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
@ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request OTP verification code',
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
})
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
@ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Verify OTP code',
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
})
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
@Post('password/reset-request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request password reset link',
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Email not found' })
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
@ApiBody({ type: RequestPasswordResetDto })
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
@Post('password/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password with token',
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
login(@Request() req: any, @Body() dto: LoginDto) {
return this.passengerAuthService.login(dto, req);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Logout current user',
description: `Logout the authenticated user and invalidate their session.
@ApiOperation({ summary: 'Logout current user' })
@ApiResponse({ status: 200, description: 'Logout successful' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
logout(@Request() req: any) {
if (!req.user?.id) throw new UnauthorizedException('User not authenticated');
return this.passengerAuthService.logout(req.user, req);
}
### What happens:
- Invalidates the current session token
- Records logout in audit log
- Frontend should clear stored token and redirect to home
### Authentication:
- **Required**: JWT Bearer Token
- Token will be invalidated after successful logout`
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
example: {
success: true,
message: 'Logged out successfully'
}
}
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
logout(@Request() req: any) {
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.logout(req.user.userId);
@Get('me')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard' })
@ApiResponse({ status: 200, description: 'Returns the full req.user object set by JwtGuard' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
getMe(@Request() req: any) {
return { user: req.user };
}
@Get('profile')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current user profile',
description: `**Returns complete user profile with all connected data**
---
### Response Includes
#### User Information
- Basic details (id, email, phone, fullName, role)
- Nationality and document information
- Fayda verification status
- Account timestamps (created, last login)
#### Passenger Data (if role=PASSENGER)
- Passenger ID and preferences
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### Devices
- List of registered devices with platform, name, push token, and last seen time
#### User Preferences
- Language, notification settings, etc.
---
### Use Cases
1. **App Initialization**: Fetch on app load to get user context
2. **Profile Pre-fill**: Use data to auto-fill booking forms
3. **Verification Check**: Check \`faydaVerified\` before registration
4. **Loyalty Display**: Show tier and points in UI
5. **Wallet Balance**: Display available balance
6. **Device Management**: Get list of user's registered devices
---
### Authentication
- **Required**: JWT Bearer Token
- Token must be valid and not expired
- Returns profile for authenticated user only`,
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
schema: {
example: {
id: 'user-uuid-123',
email: 'kelemu@email.com',
phone: '+251911234567',
fullName: 'Kelemu Abebe',
role: 'PASSENGER',
nationality: 'Ethiopian',
nationalityCode: 'ET',
nationalId: null,
passportNumber: null,
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
lastLoginAt: '2024-01-20T14:22:00.000Z',
createdAt: '2023-12-01T08:00:00.000Z',
passenger: {
id: 'passenger-uuid-456',
preferredLanguage: 'am',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
}
},
preferences: {
emailNotifications: true,
smsNotifications: true,
language: 'am'
},
devices: [
{
id: 'device-uuid-1',
platform: 'WEB',
name: 'Chrome on Windows',
pushToken: 'token-abc123',
trusted: true,
lastSeenAt: '2024-01-20T14:22:00.000Z'
},
{
id: 'device-uuid-2',
platform: 'IOS',
name: 'iPhone 14',
pushToken: 'token-xyz789',
trusted: false,
lastSeenAt: '2024-01-19T10:15:00.000Z'
}
]
}
}
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing JWT token',
schema: {
example: {
statusCode: 401,
message: 'Unauthorized'
}
}
})
getProfile(@Request() req: any) {
console.log('Profile request - User from JWT:', req.user);
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.getProfile(req.user.userId);
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
getProfile(@Request() req: any) {
const userId = req.user?.id;
if (!userId) throw new UnauthorizedException('User not authenticated');
return this.passengerAuthService.getProfile(userId);
}
@Get('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
getUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getUsers({
search,
role,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
createUser(@Body() dto: any) {
return this.service.createUser(dto);
}
@Patch('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
updateUser(@Param('id') id: string, @Body() dto: any) {
return this.service.updateUser(id, dto);
}
@Delete('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
deleteUser(@Param('id') id: string) {
return this.service.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
return this.service.resetUserPassword(id, dto.tempPassword);
}
// TODO: admin user management endpoints — implement when admin module is ready
}

View File

@@ -1,152 +1,51 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class NameDto {
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
@IsString()
am: string;
@ApiProperty({ example: 'Kelemu Ketsela' })
@IsString()
en: string;
}
export class RegisterDto {
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString()
phone: string;
@ApiProperty({ example: 'kelemu.ketsela' })
@IsString()
username: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
@ApiProperty({ example: '+251912345678' })
@IsString()
phoneNumber: string;
@ApiProperty({ type: NameDto })
@ValidateNested()
@Type(() => NameDto)
name: NameDto;
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@IsOptional()
@IsString()
passportNumber?: string;
@ApiProperty({ example: 'SecurePass123', format: 'password' })
@IsString()
confirmPassword: string;
}
export class LoginDto {
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString()
@ApiProperty({ example: 'password123', format: 'password' })
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -1,24 +1,10 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from '../../common/jwt.strategy';
import { PassengerAuthService } from './passenger-auth.service';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (c: ConfigService) => ({
secret: c.get('JWT_SECRET'),
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtModule],
providers: [PassengerAuthService],
exports: [PassengerAuthService],
})
export class AuthModule {}

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

View File

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

View File

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

View File

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

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

View File

@@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
) {}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { loyalty: true } }),
this.prisma.booking.findFirst({
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
include: {
@@ -27,7 +32,16 @@ export class DashboardService {
const hour = now.getHours();
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
let firstName = '';
if (passenger?.iamUserId) {
const iamRows = await this.dataSource.query<{ name: { en?: string; am?: string } | null }[]>(
`SELECT name FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
);
const name = iamRows[0]?.name;
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
}
const seat = upcomingBooking?.seats[0];
return {

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

View File

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

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

View File

@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
@@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
@@ -14,6 +18,7 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
private pushAdapter: PushAdapter,
@@ -112,22 +117,20 @@ export class NotificationsService {
body: string,
context: Record<string, unknown>,
): Promise<void> {
// Try to find passenger by ID or email
let passengerId = recipient;
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: recipient }, { phone: recipient }],
},
include: { passenger: true },
});
if (user?.passenger) {
passengerId = user.passenger.id;
} else {
if (!UUID_RE.test(recipient)) {
const iamUserId = await this.resolveIamUserId(recipient);
if (!iamUserId) {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } });
if (!passenger) {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
passengerId = passenger.id;
}
await this.prisma.notification.create({
@@ -163,26 +166,19 @@ export class NotificationsService {
}
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const user = await this.prisma.user.findFirst({
where: {
OR: [
{ id: recipient },
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
},
include: { preferences: true },
});
const iamUserId = await this.resolveIamUserId(recipient);
const preferences = iamUserId
? await this.prisma.userPreferences.findUnique({ where: { iamUserId } })
: null;
if (!user?.preferences) {
if (!preferences) {
return ['IN_APP', 'EMAIL'];
}
const channels: NotificationChannelType[] = ['IN_APP'];
if (user.preferences.emailEnabled) channels.push('EMAIL');
if (user.preferences.smsEnabled) channels.push('SMS');
if (user.preferences.pushEnabled) channels.push('PUSH');
if (preferences.emailEnabled) channels.push('EMAIL');
if (preferences.smsEnabled) channels.push('SMS');
if (preferences.pushEnabled) channels.push('PUSH');
return channels;
}
@@ -191,32 +187,44 @@ export class NotificationsService {
recipient: string,
channel: NotificationChannelType,
): Promise<string | null> {
const user = await this.prisma.user.findFirst({
where: {
OR: [
{ id: recipient },
{ email: recipient },
{ phone: recipient },
{ passenger: { id: recipient } },
],
},
});
if (!user) return null;
const iamUserId = await this.resolveIamUserId(recipient);
if (!iamUserId) return null;
const contact = await this.resolveContactInfo(iamUserId);
switch (channel) {
case 'EMAIL':
return user.email;
case 'SMS':
return user.phone;
case 'PUSH':
// Would need to fetch device push token
return user.id;
default:
return null;
case 'EMAIL': return contact.email;
case 'SMS': return contact.phone;
case 'PUSH': return iamUserId;
default: return null;
}
}
private async resolveIamUserId(recipient: string): Promise<string | null> {
if (UUID_RE.test(recipient)) {
const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } });
return passenger?.iamUserId ?? recipient;
}
const rows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[recipient],
);
return rows[0]?.id ?? null;
}
private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> {
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null };
}
private sanitize(value: string): string {
return value
.replace(/[\r\n]/g, ' ')
.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({
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 { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PrismaService } from '../../common/prisma.service';
@@ -53,25 +52,17 @@ export class PassengersController {
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
async getMe(@Request() req: any) {
if (!req.user || !req.user.userId) {
if (!req.user || !req.user.id) {
throw new UnauthorizedException('User not authenticated');
}
try {
const user = await this.prisma.user.findUnique({
where: { id: req.user.userId },
include: {
passenger: true,
},
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: req.user.id },
});
if (!user || !user.passenger) {
return null;
}
return this.service.getProfile(user.passenger.id);
if (!passenger) return null;
return this.service.getProfile(passenger.id);
} catch (error) {
// If profile lookup fails for any reason, return null to allow app to continue
return null;
}
}
@@ -251,7 +242,7 @@ The API automatically detects:
description: 'Invalid JWT token (only if token provided but invalid)'
})
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
const userId = req.user?.userId;
const userId = req.user?.id;
return this.service.registerPassenger({ ...dto, userId });
}

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
@@ -10,37 +12,68 @@ interface PassengerFilters {
pageSize?: number;
}
type IamUserRow = {
id: string;
email: string;
name: { en: string; am: string } | null;
phone_number: string | null;
metadata: Record<string, any> | null;
};
@Injectable()
export class PassengersService {
constructor(
private prisma: PrismaService,
private verifaydaService: VerifaydaService,
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { user: { role: 'PASSENGER' } };
if (search) {
where.user = {
...where.user,
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
],
};
let iamUserIdFilter: string[] | null = null;
if (search || verified !== undefined) {
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (search) {
conditions.push(`(
u.email ILIKE $${idx} OR
u.phone_number ILIKE $${idx} OR
(u.name->>'en') ILIKE $${idx} OR
(u.name->>'am') ILIKE $${idx}
)`);
params.push(`%${search}%`);
idx++;
}
if (verified !== undefined) {
if (verified) {
conditions.push(`u.metadata->>'faydaVerified' = 'true'`);
} else {
conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`);
}
}
const rows = await this.dataSource.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`,
params,
);
iamUserIdFilter = rows.map(r => r.id);
if (iamUserIdFilter.length === 0) {
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
}
if (verified !== undefined) {
where.user = {
...where.user,
nationalId: verified ? { not: null } : null,
};
const where: any = {};
if (iamUserIdFilter) {
where.iamUserId = { in: iamUserIdFilter };
}
const [items, total] = await Promise.all([
this.prisma.passenger.findMany({
where,
@@ -48,42 +81,36 @@ export class PassengersService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: true,
loyalty: true,
wallet: true,
_count: {
select: {
bookings: true,
},
},
_count: { select: { bookings: true } },
},
}),
this.prisma.passenger.count({ where }),
]);
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: items.map(passenger => {
const user = passenger.user as any;
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
return {
id: passenger.id,
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
gender: user.gender ?? null,
passportNumber: user.passportNumber,
passportCountry: user.passportCountry ?? null,
verified: !!user.nationalId,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
verified: faydaVerified,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
updatedAt: user.updatedAt,
loyalty: passenger.loyalty,
wallet: passenger.wallet,
};
}),
meta: {
@@ -99,33 +126,42 @@ export class PassengersService {
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: true,
bookings: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
}
bookings: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
},
loyalty: true,
wallet: true,
travelerProfiles: true,
loyalty: true,
wallet: true,
travelerProfiles: true,
savedRoutes: true,
},
});
if (!passenger) throw new NotFoundException('Passenger not found');
let iamUser: IamUserRow | null = null;
if (passenger.iamUserId) {
const rows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
);
iamUser = rows[0] ?? null;
}
return {
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
email: iamUser?.email ?? null,
phone: iamUser?.phone_number ?? null,
createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalFare: b.totalMinor / 100,
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalFare: b.totalMinor / 100,
createdAt: b.createdAt,
trip: {
number: b.schedule.train.number,
@@ -143,13 +179,9 @@ export class PassengersService {
},
departureAt: b.schedule.departureAt,
},
passengers: b.seats.map((bs) => ({
fullName: bs.passengerName,
seat: {
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
class: 'N/A'
}
passengers: b.seats.map((bs) => ({
fullName: bs.passengerName,
seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' },
})),
})),
};
@@ -229,23 +261,53 @@ export class PassengersService {
async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
return this.prisma.passenger.update({
where: { id },
data: {
user: {
update: {
fullName: dto.fullName || undefined,
email: dto.email || undefined,
phone: dto.phone || undefined,
nationality: dto.nationality || undefined,
},
},
},
include: {
user: true,
loyalty: true,
},
});
if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) {
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.fullName) {
updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`);
params.push(dto.fullName);
idx++;
}
if (dto.email) {
updates.push(`email = $${idx}`);
params.push(dto.email);
idx++;
}
if (dto.phone) {
updates.push(`phone_number = $${idx}`);
params.push(dto.phone);
idx++;
}
params.push(passenger.iamUserId);
await this.dataSource.query(
`UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`,
params,
);
}
const [updated, iamRows] = await Promise.all([
this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }),
passenger.iamUserId
? this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
)
: Promise.resolve([] as IamUserRow[]),
]);
const iamUser = iamRows[0] ?? null;
return {
id: updated!.id,
fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
email: iamUser?.email ?? null,
phone: iamUser?.phone_number ?? null,
loyalty: updated!.loyalty,
};
}
async registerPassenger(dto: RegisterPassengerDto) {
@@ -274,31 +336,16 @@ export class PassengersService {
};
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
include: { passenger: true },
const linkedPassenger = await this.prisma.passenger.findUnique({
where: { iamUserId: dto.userId },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
data: {
fullName: finalData.passengerName,
nationality: finalData.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber,
faydaVerified: !!verifiedData,
faydaVerifiedAt: verifiedData ? new Date() : null,
},
});
if (!linkedPassenger) {
throw new BadRequestException('Passenger not found');
}
return {
id: user.passenger?.id || user.id,
id: linkedPassenger.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,
@@ -336,7 +383,9 @@ export class PassengersService {
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
return this.prisma.passenger.delete({ where: { id } });
await this.prisma.passenger.delete({ where: { id } });
return { deleted: true, passengerId: id };
}
async checkPassengerUsage(id: string) {

View File

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

View File

@@ -23,19 +23,7 @@ describe("Payments E2E", () => {
prisma = app.get<PrismaService>(PrismaService);
const testUser = await prisma.user.create({
data: {
email: "payment-test@example.com",
phone: "+251911111112",
fullName: "Payment Test User",
passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
role: "PASSENGER",
},
});
const passenger = await prisma.passenger.create({
data: { userId: testUser.id },
});
const passenger = await prisma.passenger.create({ data: { iamUserId: 'test-iam-payments-user' } });
await prisma.walletAccount.create({
data: {
@@ -151,7 +139,6 @@ describe("Payments E2E", () => {
prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(),
prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }),
]);
await app.close();
});

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

View File

@@ -1,10 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
@Injectable()
export class ReportsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
) {}
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
@@ -113,13 +118,25 @@ export class ReportsService {
...(agentId ? { agentId } : {})
},
include: {
agent: { include: { user: true } },
agent: { select: { id: true, iamUserId: true, agentCode: true } },
booking: true
}
});
const iamUserIds = [...new Set(
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
)];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const byAgent = agentBookings.reduce((acc, ab) => {
const agentName = ab.agent.user.fullName;
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}

View File

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

View File

@@ -3,12 +3,14 @@ import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@Injectable()
export class SeatsService {
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string) {
@@ -240,7 +242,8 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
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 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 { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
@@ -12,7 +14,10 @@ interface OfflineValidation {
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {};
@@ -38,50 +43,68 @@ export class TicketsService {
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
const tickets = await this.prisma.ticket.findMany({
where,
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
},
},
},
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
});
const total = await this.prisma.ticket.count({ where });
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
}),
this.prisma.ticket.count({ where }),
]);
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: tickets.map((t) => ({
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
contactPhone: t.booking.contactPhone,
returnSchedule: (t.booking as any).returnSchedule ?? null,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
})),
items: tickets.map((t) => {
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
const passengerInfo = iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
return {
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
passenger: passengerInfo,
contactEmail: t.booking.contactEmail,
contactPhone: t.booking.contactPhone,
returnSchedule: (t.booking as any).returnSchedule ?? null,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
};
}),
total,
skip: filters.skip,
take: filters.take,
@@ -390,8 +413,8 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
seats: { include: { seat: { include: { coach: true } } } },
passenger: { select: { id: true, iamUserId: true } },
},
});

View File

@@ -1,21 +1,30 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { DataSource } from 'typeorm';
/**
* Like {@link JwtGuard}, but never rejects the request.
* Like the IAM JwtGuard, but never rejects the request.
*
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
* populated from the JWT strategy (`{ userId, ... }`). When the token is
* missing or invalid, the request still proceeds with `request.user`
* undefined — the handler decides what to do.
*
* Used on `POST /fayda/verification/start`, which must work for both
* logged-in users (who can opt to save the verification to their account)
* and guests (anchored to a booking only).
* When a valid IAM bearer token is present, `request.user` is populated with
* the package `TCurrentUser`. Missing or invalid tokens continue as guests.
*/
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
return (user ?? null) as TUser;
export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
constructor(
reflector: Reflector,
@InjectDataSource() dataSource: DataSource,
) {
super(reflector, dataSource);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
await super.canActivate(context);
} catch {
context.switchToHttp().getRequest().user = undefined;
}
return true;
}
}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
@@ -16,12 +15,6 @@ function buildPrismaMock() {
bookingSeat: {
updateMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() },
@@ -30,10 +23,8 @@ function buildPrismaMock() {
};
}
function buildJwtMock(): jest.Mocked<JwtService> {
return {
sign: jest.fn(() => 'signed.jwt.token'),
} as unknown as jest.Mocked<JwtService>;
function buildDataSourceMock() {
return { query: jest.fn().mockResolvedValue([]) };
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
@@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>;
let dataSource: ReturnType<typeof buildDataSourceMock>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
@@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
beforeEach(() => {
prisma = buildPrismaMock();
jwt = buildJwtMock();
dataSource = buildDataSourceMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
jwt,
dataSource as any,
);
(global as any).fetch = jest.fn();
});
@@ -135,7 +126,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService,
jwt,
buildDataSourceMock() as any,
);
await expect(
disabledService.startVerification({ purpose: 'VERIFY' }),
@@ -154,7 +145,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
status: 'PENDING',
errorCode: null,
errorDescription: null,
userId: null,
iamUserId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
@@ -215,7 +206,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
purpose: 'VERIFY',
platform: 'WEB',
status: 'PENDING',
userId: null,
iamUserId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
@@ -270,7 +261,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
expect(result.token).toBeUndefined();
expect(result.user).toBeUndefined();
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('throws 502 when the token endpoint returns 4xx', async () => {
@@ -337,7 +327,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
purpose: 'LOGIN',
platform: 'WEB',
status: 'PENDING',
userId: null,
iamUserId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
@@ -363,139 +353,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
function mockUserFindUnique(bySub: any, fullUser: any) {
prisma.user.findUnique.mockImplementation(async (args: any) => {
if (args?.where?.faydaSub !== undefined) return bySub;
if (args?.where?.id !== undefined) return fullUser;
return null;
});
}
beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
});
it('creates a new user when no match and returns { token, user }', async () => {
const fullUser = {
id: 'new-user',
email: 'new@example.com',
role: 'PASSENGER',
passenger: { id: 'p-new' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({ id: 'new-user' });
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
prisma.loyaltyAccount.create.mockResolvedValue({});
prisma.walletAccount.create.mockResolvedValue({});
prisma.userPreferences.create.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => {
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result).toMatchObject({
purpose: 'LOGIN',
verified: true,
token: 'signed.jwt.token',
user: { id: 'new-user', passengerId: 'p-new' },
});
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
faydaSub: 'login-sub-1',
faydaVerified: true,
email: 'new@example.com',
}),
}),
);
expect(prisma.passenger.create).toHaveBeenCalled();
expect(jwt.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
);
});
it('logs in an existing user already linked by faydaSub', async () => {
const fullUser = {
id: 'known-user',
email: 'k@example.com',
role: 'PASSENGER',
passenger: { id: 'p-k' },
agent: null,
};
mockUserFindUnique({ id: 'known-user' }, fullUser);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('known-user');
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('links Fayda to an existing account matched by email', async () => {
const fullUser = {
id: 'acc-1',
email: 'match@example.com',
role: 'PASSENGER',
passenger: { id: 'p-1' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('acc-1');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'acc-1' },
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
}),
);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
mockUserFindUnique(null, null);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
).rejects.toMatchObject({
status: 401,
response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }),
});
});
it('does not touch the database for LOGIN purpose', async () => {
mockLoginFetch({ sub: 'login-sub-2', name: 'Person' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 401 });
expect(dataSource.query).not.toHaveBeenCalled();
expect(prisma.passenger.create).not.toHaveBeenCalled();
});
});
describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({
faydaVerified: true,
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
const result = await service.getVerificationStatus('user-1');
it('returns verified=true when IAM user metadata has the flag', async () => {
dataSource.query.mockResolvedValueOnce([{
metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' },
name: { en: 'Test User', am: 'ቴስት ዩዘር' },
}]);
const result = await service.getVerificationStatus('iam-user-1');
expect(result).toEqual({
verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'),
@@ -503,9 +395,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
});
});
it('returns verified=false when User row is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const result = await service.getVerificationStatus('user-x');
it('returns verified=false when IAM user is missing or unverified', async () => {
dataSource.query.mockResolvedValueOnce([]);
const result = await service.getVerificationStatus('iam-user-x');
expect(result).toEqual({ verified: false });
});
});

View File

@@ -6,10 +6,9 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import {
@@ -20,7 +19,6 @@ import {
import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import {
FaydaIdentityConflictException,
FaydaTokenExchangeException,
FaydaUserInfoException,
} from './verifayda.errors';
@@ -48,7 +46,7 @@ export interface VerifaydaVerificationResult {
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string;
userId?: string; // iamUserId of the authenticated user, if any
}
export interface FaydaUserSummary {
@@ -91,7 +89,7 @@ export class VerifaydaService {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
@InjectDataSource() private readonly dataSource: DataSource,
) {
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
@@ -146,7 +144,7 @@ export class VerifaydaService {
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
userId: input.userId ?? null,
iamUserId: input.userId ?? null,
expiresAt,
},
});
@@ -257,52 +255,25 @@ export class VerifaydaService {
}
}
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken(
userId: string,
_userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { passenger: true, agent: true },
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
});
if (!user) {
// Should not happen — we just resolved/created this user.
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_FAILED',
message: 'Could not load the verified user',
});
}
const summary: FaydaUserSummary = {
id: user.id,
email: user.email,
role: user.role,
passengerId: user.passenger?.id,
agentId: user.agent?.id,
};
const token = this.jwt.sign({
sub: summary.id,
email: summary.email,
role: summary.role,
passengerId: summary.passengerId,
agentId: summary.agentId,
});
this.logger.log(`Fayda login issued token for user ${user.id}`);
return { token, user: summary };
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
});
return {
verified: user?.faydaVerified ?? false,
verifiedAt: user?.faydaVerifiedAt ?? undefined,
fullName: user?.fullName ?? undefined,
};
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
const iam = rows[0] ?? null;
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
}
// ==========================================================================
@@ -428,106 +399,16 @@ export class VerifaydaService {
};
}
/**
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
* JWT via {@link issueLoginToken}). Resolution order:
* 1. Existing user already linked to this Fayda `sub`.
* 2. Existing account whose email/phone matches — linked to this `sub`.
* 3. Otherwise a fresh Fayda-backed account is created.
*/
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
// This method is kept as a stub so completeVerification() still compiles;
// it throws immediately without touching the database.
private async handleLoginSuccess(
normalized: NormalizedFaydaUserInfo,
_normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> {
let userId: string;
const bySub = await this.prisma.user.findUnique({
where: { faydaSub: normalized.sub },
select: { id: true },
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
});
if (bySub) {
userId = bySub.id;
} else {
const matchers: Array<{ email?: string; phone?: string }> = [];
if (normalized.email) matchers.push({ email: normalized.email });
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
const existing = matchers.length
? await this.prisma.user.findFirst({
where: { OR: matchers },
select: { id: true, faydaSub: true },
})
: null;
if (existing) {
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
// The matched account is already tied to a different Fayda identity.
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: existing.id },
data: {
faydaSub: normalized.sub,
faydaVerified: true,
faydaVerifiedAt: new Date(),
},
});
userId = existing.id;
this.logger.log(`Fayda login linked existing user ${existing.id}`);
} else {
userId = await this.createFaydaUser(normalized);
this.logger.log(`Fayda login created new user ${userId}`);
}
}
return { userId };
}
/**
* Creates a Fayda-backed User plus the same satellite rows registration makes
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
*
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
* so password login is impossible; they authenticate only via Fayda. When
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
*/
private async createFaydaUser(
normalized: NormalizedFaydaUserInfo,
): Promise<string> {
const passwordHash = await bcrypt.hash(
randomBytes(32).toString('hex'),
10,
);
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
const fullName = normalized.fullName ?? 'Fayda User';
const user = await this.prisma.user.create({
data: {
fullName,
email,
phone,
passwordHash,
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
select: { id: true },
});
const passenger = await this.prisma.passenger.create({
data: { userId: user.id },
select: { id: true },
});
await this.prisma.loyaltyAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.walletAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return user.id;
}
private async markSessionFailed(
@@ -548,7 +429,6 @@ export class VerifaydaService {
}
private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
return 'verification_failed';
@@ -565,9 +445,8 @@ export class VerifaydaService {
): Promise<VerifaydaVerificationResult> {
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
if (this.stubEnabled != false || this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)');
// In development mode, return mock verified data
if (!this.stubEnabled) {
this.logger.warn('Verifayda not configured returning mock data (development mode)');
return {
verified: true,
passengerData: {

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,
"tsBuildInfoFile": "./.tsbuildinfo",
"paths": { "@/*": ["./src/*"] },
"module": "node16",
"moduleResolution": "node16",
"strictPropertyInitialization": false,
"noUnusedLocals": false,
"noUnusedParameters": false

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
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 ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api';
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() {
const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
@@ -30,7 +45,7 @@ export default function AgentsPage() {
render: (agent: any) => (
<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>
),
},
@@ -51,19 +66,21 @@ export default function AgentsPage() {
];
const actions = [
{
label: 'View Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'View Shifts',
onClick: (agent: any) => {
window.location.href = `/agents/${agent.id}/shifts`;
},
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
variant: 'secondary' as const,
icon: Clock,
},
{
label: 'View Commissions',
onClick: (agent: any) => {
window.location.href = `/agents/${agent.id}/commissions`;
},
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
variant: 'secondary' as const,
icon: DollarSign,
},
@@ -119,6 +136,97 @@ export default function AgentsPage() {
loading={isLoading}
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>
);
}

View File

@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
title="Audit Log Details"
size="xl"
>
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Action</label>
<p className="text-sm mt-1">
<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>
{selectedLog && (() => {
const l = selectedLog;
const actionColor: Record<string, string> = {
CREATE: 'from-emerald-600 to-emerald-700',
UPDATE: 'from-blue-600 to-blue-700',
DELETE: 'from-red-600 to-red-700',
LOGIN: 'from-violet-600 to-violet-700',
LOGOUT: 'from-gray-600 to-gray-700',
};
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
{/* User Info */}
{selectedLog?.user && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">User Information</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
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>
);
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>
<label className="text-xs font-semibold text-muted-foreground">Email</label>
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<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>
)}
{/* Network Info */}
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
<div className="space-y-2">
{selectedLog?.ipAddress && (
<div>
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
</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>
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
{selectedLog?.userAgent}
</p>
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<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>
)}
</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>
)}
{/* 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>
</div>
);

View File

@@ -2,15 +2,37 @@
import { useState } from 'react';
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 Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { fraudApi } from '@/lib/api';
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() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
}
};
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
key: 'severity',
label: 'Severity',
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}
</Badge>
),
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
label: 'Rule Type',
render: (alert: any) => (
<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>
</div>
),
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
{
key: 'description',
label: 'Description',
render: (alert: any) => (
<span className="text-sm">{alert.description || alert.details}</span>
),
render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
},
{
key: 'status',
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
];
const actions = [
{
label: 'View Details',
onClick: (alert: any) => setSelected(alert),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Acknowledge',
onClick: handleAcknowledge,
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search alerts..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
<input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Severity</label>
<select
className="input"
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
<option value="">All Severities</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="acknowledged">Acknowledged</option>
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
loading={isLoading}
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>
);
}

View File

@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-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() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const [emailFocused, setEmailFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => { setIsMounted(true); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await login(email, password);
router.push('/dashboard');
} catch (err: any) {
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.';
setError(message);
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
} finally {
setLoading(false);
}
};
if (!isMounted) {
return null;
}
if (!isMounted) return null;
return (
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
<div className="flex min-h-screen bg-white dark:bg-gray-950">
{/* Content Overlay */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
<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
onClick={toggleTheme}
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-700" />
)}
</button>
{/* ── LEFT PANEL — form ── */}
<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">
{/* Top bar */}
<div className="flex items-center justify-between px-8 py-4 lg:px-10">
{/* Logo — always visible on the form panel */}
<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">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" />
<circle cx="9" cy="9" r="1" fill="currentColor" />
<circle cx="15" cy="9" r="1" fill="currentColor" />
</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>
{/* Card Body */}
<div className="p-6">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
</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}
{/* Error */}
{error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
<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">
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
<span className="text-white text-[10px] font-bold">!</span>
</div>
)}
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div></div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
{/* 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
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
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"
placeholder="name@email.com"
onChange={(e) => { setEmail(e.target.value); setError(''); }}
onFocus={() => setEmailFocused(true)}
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
autoComplete="email"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
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"
placeholder="••••••••"
required
/>
<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>
{/* Password field */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Password
</label>
<button
type="button"
className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
>
Forgot password?
</button>
</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
type="submit"
disabled={loading}
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"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{/* Submit */}
<button
type="submit"
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"
style={{ background: loading || !email || !password
? 'rgb(20,113,76)'
: `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>
{/* 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>
);

View File

@@ -2,15 +2,44 @@
import { useState } from 'react';
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 Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
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() {
const [filters, setFilters] = useState({ search: '', tier: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters],
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> },
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 },
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 },
];
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
<div>
<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: '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 (
<div className="space-y-6">
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Tier</label>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<option value="">All Tiers</option>
<option value="BRONZE">Bronze</option>
<option value="SILVER">Silver</option>
<option value="GOLD">Gold</option>
<option value="PLATINUM">Platinum</option>
</select>
</div>
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Tier</label>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<option value="">All Tiers</option>
<option value="BRONZE">Bronze</option>
<option value="SILVER">Silver</option>
<option value="GOLD">Gold</option>
<option value="PLATINUM">Platinum</option>
</select>
</div>
</div>
</div>
<DataTable
data={Array.isArray(data) ? data : (data?.items || [])}
columns={columns}
actions={actions}
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>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
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() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
{ 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={[]}
actions={paymentActions}
loading={isLoading}
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 */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -1,11 +1,48 @@
'use client';
import { useState } from 'react';
import { Save, Users } from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { Save } from 'lucide-react';
import { systemConfigApi } from '@/lib/api';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
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 (
<div className="space-y-6">
@@ -14,31 +51,24 @@ export default function SettingsPage() {
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage system settings and configurations</p>
</div>
<button className="btn btn-primary flex items-center gap-2">
<Save className="h-4 w-4" />
Save Changes
</button>
{activeTab !== 'configurations' && (
<button className="btn btn-primary flex items-center gap-2">
<Save className="h-4 w-4" />
Save Changes
</button>
)}
</div>
<div className="flex gap-2 border-b border-border">
<button
onClick={() => setActiveTab('general')}
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
General
</button>
<button
onClick={() => setActiveTab('payment')}
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>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && (
@@ -139,6 +169,46 @@ export default function SettingsPage() {
</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>
);
}

View File

@@ -23,6 +23,19 @@ export default function TicketsPage() {
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
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 [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -534,111 +547,116 @@ export default function TicketsPage() {
isOpen={detailsModalOpen}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
size="xl"
>
{selectedTicket && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="mt-1">
<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">
{selectedTicket && (() => {
const t = selectedTicket;
const b = t.booking;
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
return (
<div>
{/* Gradient header */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">Leg Status</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</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 className="text-right shrink-0">
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
</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 className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
</ActionButton>
<div className="space-y-6">
{/* Booking */}
<section>
<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>
)}
);
})()}
</Modal>
{/* Export Modal */}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
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 Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
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() {
const [filters, setFilters] = useState({ search: '', verified: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['verifayda', filters],
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
});
const columns = [
{ 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: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> },
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
];
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
{ 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: '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 (
<div className="space-y-6">
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<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 })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<option value="">All</option>
<option value="true">Verified</option>
<option value="false">Failed</option>
</select>
</div>
<div>
<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 })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<option value="">All</option>
<option value="true">Verified</option>
<option value="false">Failed</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
actions={actions}
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>
);
}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
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 Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { walletApi } from '@/lib/api';
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() {
const [filters, setFilters] = useState({ search: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['wallet', filters],
@@ -18,10 +33,25 @@ export default function WalletPage() {
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> },
];
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
<div>
<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 (
<div className="space-y-6">
@@ -35,21 +65,113 @@ export default function WalletPage() {
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
actions={actions}
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>
);
}

View File

@@ -373,3 +373,9 @@ export const reportsApi = {
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';
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
const keys = roles.map((r) => r.key ?? '');
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
if (keys.some((k) => k.includes('agent'))) return 'AGENT';
return 'SUPERVISOR';
}
interface AuthState {
user: AdminUser | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
@@ -17,6 +25,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
initialize: () => {
@@ -27,57 +36,47 @@ export const useAuthStore = create<AuthState>((set) => ({
try {
const user = JSON.parse(userStr);
set({ user, token, isAuthenticated: true });
} catch (e) {
} catch {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user');
}
}
},
login: async (email: string, password: string) => {
try {
console.log('Attempting login to:', `${API_URL}/auth/login`);
const response = await axios.post(`${API_URL}/auth/login`, { email, password });
console.log('Full response:', response.data);
// Backend wraps response in { success, data: { token, user }, timestamp }
const responseData = response.data.data || response.data;
if (!responseData || !responseData.token || !responseData.user) {
console.error('Invalid response structure:', response.data);
throw new Error('Invalid response from server');
}
const { token, user: apiUser } = responseData;
const user: AdminUser = {
id: apiUser.id,
email: apiUser.email,
fullName: apiUser.fullName,
role: apiUser.role,
active: true,
};
console.log('Login successful! User:', user);
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
set({ user, token, isAuthenticated: true });
} catch (error: any) {
console.error('Login error details:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
});
throw error;
}
// Step 1: IAM login — returns token + refreshToken only
const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
const loginData = loginRes.data?.data ?? loginRes.data;
const { token, refreshToken } = loginData;
if (!token) throw new Error('No token received from server');
// Step 2: fetch full user info with the token
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const iamUser = meRes.data?.data ?? meRes.data;
const user: AdminUser = {
id: iamUser.id,
email: iamUser.email,
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
role: mapIamRole(iamUser.roles ?? []),
active: true,
};
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user');
set({ user: null, token: null, isAuthenticated: false });
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
},
setUser: (user: AdminUser, token: string) => {

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 {
.card {
background-color: hsl(var(--card));

22066
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff