mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -2,17 +2,46 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
# Database (Prisma)
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
|
||||
# Database (Prisma) — owns the `passenger` schema in edr_database
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
|
||||
|
||||
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
|
||||
# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource.
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=edr_database
|
||||
DATABASE_USER=edr
|
||||
DATABASE_PASSWORD=edr_secret
|
||||
DATABASE_SCHEMA=iam
|
||||
|
||||
# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications).
|
||||
# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev.
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
|
||||
# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these).
|
||||
# Placeholders for dev; only contacted when file upload/download features are actually used.
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=edr-dev
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
BACK_OFFICE_URL=http://localhost:5184
|
||||
|
||||
# JWT
|
||||
# JWT (legacy passenger auth — being replaced by IAM)
|
||||
JWT_SECRET=edr-platform-secret-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
|
||||
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
|
||||
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
|
||||
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
# SendGrid
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
|
||||
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
@@ -27,26 +29,30 @@
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/microservices": "^11.1.24",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"jose": "^5.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^0.3.30",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,11 +61,9 @@
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.0",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced)
|
||||
ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT;
|
||||
|
||||
-- Unique constraint: one IAM user maps to exactly one Passenger
|
||||
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
|
||||
|
||||
-- Index for fast lookup by iamUserId on every protected request
|
||||
CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId");
|
||||
|
||||
-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users)
|
||||
ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT;
|
||||
|
||||
-- Index for Fayda callback to resolve IAM user
|
||||
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId");
|
||||
@@ -0,0 +1,30 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Passenger" DROP CONSTRAINT "Passenger_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Passenger" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketSeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"seatIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema)
|
||||
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
|
||||
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
|
||||
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
|
||||
|
||||
-- Rename columns (preserves all existing data)
|
||||
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
|
||||
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
|
||||
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
|
||||
|
||||
-- Rename indexes on FraudAlert to match new column name
|
||||
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
|
||||
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AuditLog: drop FK, rename column, update index
|
||||
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
|
||||
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
|
||||
|
||||
-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data)
|
||||
ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey";
|
||||
ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId";
|
||||
DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.*
|
||||
-- but ran when tables were still in public schema (before 20260626 moved them).
|
||||
-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run.
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. Passenger.iamUserId
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'Passenger_iamUserId_key'
|
||||
AND conrelid = 'passenger."Passenger"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. FaydaVerificationSession.iamUserId
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
|
||||
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Device: rename userId → iamUserId (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
|
||||
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
|
||||
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
|
||||
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
|
||||
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat).
|
||||
-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL.
|
||||
|
||||
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. Add iamUserId to Agent
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'Agent_iamUserId_key'
|
||||
AND conrelid = 'passenger."Agent"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. Populate iamUserId for existing agent records
|
||||
-- Match via User.email → iam.users.email
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
UPDATE passenger."Agent" a
|
||||
SET "iamUserId" = iu.id
|
||||
FROM passenger."User" u
|
||||
JOIN iam.users iu ON iu.email = u.email
|
||||
WHERE a."userId" = u.id
|
||||
AND a."iamUserId" IS NULL;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
|
||||
DROP INDEX IF EXISTS passenger."Agent_userId_key";
|
||||
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Drop Passenger.userId FK (column stays as plain nullable string)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal file
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Dev helper: run the @tria-plc/iamapi-common TypeORM migrations against the shared `iam` schema.
|
||||
*
|
||||
* The package ships its migration CLI assuming you run it from inside the package repo (it needs
|
||||
* the package's devDeps). As a consumer we instead drive the shipped (compiled) migrations with the
|
||||
* passenger app's own installed TypeORM.
|
||||
*
|
||||
* Reads the same DATABASE_* env vars as the app's IAM DataSource (see config/iam-database.config.ts).
|
||||
* Run via: pnpm --filter @edr/passenger-api iam:migrate
|
||||
* (the npm script loads .env with `node --env-file`).
|
||||
*
|
||||
* NOTE: in production the central IAM team owns/runs these migrations — this helper is for local dev.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { DataSource } = require('typeorm');
|
||||
|
||||
const iamDist = path
|
||||
.dirname(require.resolve('@tria-plc/iamapi-common'))
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
database: process.env.DATABASE_NAME,
|
||||
username: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
schema: process.env.DATABASE_SCHEMA || 'iam',
|
||||
entities: [], // migrations are raw SQL — no entities needed to run them
|
||||
migrations: [`${iamDist}/db/migrations/*.js`],
|
||||
migrationsTableName: 'typeorm_migrations',
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await ds.initialize();
|
||||
// The IAM migrations rely on uuid_generate_v4() but never CREATE the extension themselves.
|
||||
await ds.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
||||
const applied = await ds.runMigrations({ transaction: 'each' });
|
||||
console.log(`[iam-migrations] applied ${applied.length} migration(s)`);
|
||||
applied.slice(-5).forEach((m) => console.log(' +', m.name));
|
||||
await ds.destroy();
|
||||
console.log('[iam-migrations] DONE');
|
||||
})().catch((e) => {
|
||||
console.error('[iam-migrations] FAIL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal file
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Dev helper: create a dev IAM user + an ACTIVE session, and print a ready-to-use Bearer token.
|
||||
*
|
||||
* Why this exists: in prod the central IAM service issues tokens (via password login at
|
||||
* /v1/auth/login). For local dev of the passenger API (a token *consumer*), this seeds a session
|
||||
* directly and mints a matching token with the package's own `generateToken`, so you can call
|
||||
* protected routes immediately (paste the token into Swagger's Authorize box or `curl -H`).
|
||||
*
|
||||
* Run: pnpm --filter @edr/passenger-api iam:seed-dev-user
|
||||
* Reads DATABASE_* + JWT_ACCESS_TOKEN_SECRET/EXPIRES from .env (loaded via `node --env-file`).
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { DataSource } = require('typeorm');
|
||||
const { generateToken } = require('@tria-plc/api-common/utils/token');
|
||||
|
||||
const DEV_EMAIL = process.env.DEV_IAM_EMAIL || 'dev@edr.local';
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
database: process.env.DATABASE_NAME,
|
||||
username: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await ds.initialize();
|
||||
|
||||
// Upsert the dev user (users.email is UNIQUE).
|
||||
const name = { en: 'Dev User', am: 'የሙከራ ተጠቃሚ' };
|
||||
const [user] = await ds.query(
|
||||
`INSERT INTO iam.users (name, username, email, user_type, status, is_active)
|
||||
VALUES ($1::jsonb, $2, $3, 'individual', 'accepted', true)
|
||||
ON CONFLICT (email) DO UPDATE SET updated_at = now()
|
||||
RETURNING id`,
|
||||
[JSON.stringify(name), 'dev-user', DEV_EMAIL],
|
||||
);
|
||||
const userId = user.id;
|
||||
|
||||
// Fresh ACTIVE session; userInfo is the denormalized TCurrentUser the guard puts on req.user.
|
||||
const sessionId = crypto.randomUUID();
|
||||
const userInfo = {
|
||||
id: userId,
|
||||
email: DEV_EMAIL,
|
||||
name,
|
||||
username: 'dev-user',
|
||||
userType: 'individual',
|
||||
status: 'accepted',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
};
|
||||
await ds.query(
|
||||
`INSERT INTO iam.sessions (id, email, device, "userInfo", user_id, status, expiry_time)
|
||||
VALUES ($1, $2, 'dev-seeder', $3::jsonb, $4, 'ACTIVE', now() + interval '7 days')`,
|
||||
[sessionId, DEV_EMAIL, JSON.stringify(userInfo), userId],
|
||||
);
|
||||
|
||||
// The package JwtGuard looks up the session by the token's `id` claim.
|
||||
const token = generateToken({ id: sessionId });
|
||||
|
||||
console.log('\n=== IAM dev user seeded ===');
|
||||
console.log('user id :', userId);
|
||||
console.log('email :', DEV_EMAIL);
|
||||
console.log('session id:', sessionId);
|
||||
console.log('\nBearer token (valid 7 days):\n' + token);
|
||||
console.log('\nTry it: curl -H "Authorization: Bearer <token>" http://localhost:3002/v1/auth/me');
|
||||
console.log('(Run again any time for a fresh token/session.)\n');
|
||||
|
||||
await ds.destroy();
|
||||
})().catch((e) => {
|
||||
console.error('[seed-iam-dev-user] FAIL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
|
||||
import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder';
|
||||
import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module';
|
||||
import {
|
||||
EDR_PASSENGER_APPLICATION,
|
||||
EDR_PASSENGER_PERMISSIONS,
|
||||
} from './seed/edr-passenger.seed';
|
||||
import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder';
|
||||
import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder';
|
||||
import { PrismaModule } from './common/prisma.module';
|
||||
import { AuditModule } from './common/audit.module';
|
||||
import { I18nModule } from './common/i18n/i18n.module';
|
||||
import { IamModule } from './common/iam.module';
|
||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||
import appConfig from './config/app.config';
|
||||
import dbConfig from './config/database.config';
|
||||
import iamDatabaseConfig from './config/iam-database.config';
|
||||
import telebirrConfig from './config/telebirr.config';
|
||||
import cbeConfig from './config/cbe.config';
|
||||
import ebirrConfig from './config/ebirr.config';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class AuditService {
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
iamUserId: input.userId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
@@ -62,8 +62,7 @@ export class AuditService {
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ iamUserId: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,16 +76,12 @@ export class AuditService {
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500, // Limit to last 500 logs
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
return this.prisma.auditLog.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
return this.prisma.auditLog.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { IamGuard } from './iam-adapter';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
describe('IamGuard', () => {
|
||||
let guard: IamGuard;
|
||||
let httpService: HttpService;
|
||||
let configService: ConfigService;
|
||||
let reflector: Reflector;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
IAM_API_URL: 'https://iam.test.com/api',
|
||||
IAM_ENABLED: 'true',
|
||||
IAM_API_KEY: 'test-api-key',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const mockHttpService = {
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
const mockReflector = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IamGuard,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: HttpService, useValue: mockHttpService },
|
||||
{ provide: Reflector, useValue: mockReflector },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<IamGuard>(IamGuard);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
|
||||
const request = {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
user: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should allow access when IAM is disabled', async () => {
|
||||
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
|
||||
|
||||
const context = createMockContext();
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when no token provided', async () => {
|
||||
const context = createMockContext();
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should validate token and allow access', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHttpService.post).toHaveBeenCalledWith(
|
||||
'https://iam.test.com/api/v1/auth/validate',
|
||||
{ token: 'valid-token' },
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-API-Key': 'test-api-key',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid token', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: false,
|
||||
error: 'Token expired',
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
|
||||
const context = createMockContext('invalid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check required roles', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'agent@test.com',
|
||||
roles: ['AGENT'],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should allow access when user has required role', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors gracefully', async () => {
|
||||
mockHttpService.post.mockReturnValue(
|
||||
throwError(() => new Error('Network error')),
|
||||
);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should attach user to request', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
organizationId: 'org-456',
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
await guard.canActivate(context);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
expect(request.user).toEqual({
|
||||
userId: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
organizationId: 'org-456',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('token extraction', () => {
|
||||
it('should extract token from Bearer header', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'test@test.com',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('my-token-123');
|
||||
await guard.canActivate(context);
|
||||
|
||||
expect(mockHttpService.post).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ token: 'my-token-123' },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject malformed authorization header', async () => {
|
||||
const request = {
|
||||
headers: { authorization: 'InvalidFormat token' },
|
||||
};
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
} as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,144 +1 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* IAM Adapter for @tria-plc corporate identity integration
|
||||
*
|
||||
* This adapter wraps the corporate IAM guards and provides a bridge
|
||||
* between the corporate identity system and the EDR passenger API.
|
||||
*
|
||||
* For back-office roles (agent, supervisor, admin, staff), this guard
|
||||
* validates tokens against the corporate IAM service.
|
||||
*
|
||||
* For passenger-facing routes, the existing JWT guard is used.
|
||||
*/
|
||||
|
||||
export interface IamTokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
organizationId?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface IamValidationResponse {
|
||||
valid: boolean;
|
||||
payload?: IamTokenPayload;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IamGuard implements CanActivate {
|
||||
private readonly iamApiUrl: string;
|
||||
private readonly iamEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
|
||||
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!this.iamEnabled) {
|
||||
// IAM disabled - allow access (for development)
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No authentication token provided');
|
||||
}
|
||||
|
||||
const validation = await this.validateToken(token);
|
||||
|
||||
if (!validation.valid || !validation.payload) {
|
||||
throw new UnauthorizedException(validation.error || 'Invalid token');
|
||||
}
|
||||
|
||||
// Check required roles
|
||||
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: validation.payload.sub,
|
||||
email: validation.payload.email,
|
||||
roles: validation.payload.roles,
|
||||
permissions: validation.payload.permissions,
|
||||
organizationId: validation.payload.organizationId,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(request: any): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
private async validateToken(token: string): Promise<IamValidationResponse> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<IamValidationResponse>(
|
||||
`${this.iamApiUrl}/v1/auth/validate`,
|
||||
{ token },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
error: err instanceof Error ? err.message : 'Token validation failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark routes as requiring IAM authentication
|
||||
*/
|
||||
export const UseIamAuth = () => {
|
||||
// This is a marker decorator that can be used with @UseGuards(IamGuard)
|
||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
||||
// Marker only - actual guard is applied via @UseGuards
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator to specify required roles for IAM-protected routes
|
||||
*/
|
||||
export const IamRoles = (...roles: string[]) => {
|
||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
||||
if (descriptor) {
|
||||
Reflect.defineMetadata('roles', roles, descriptor.value);
|
||||
}
|
||||
};
|
||||
};
|
||||
export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal file
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* TypeORM DataSource options for the shared `iam` schema.
|
||||
*
|
||||
* Context (see docs/iam-package-understanding-guide.md):
|
||||
* - The `iam` schema is owned by `@tria-plc/iamapi-common` (TypeORM). Prisma owns the
|
||||
* `passenger` schema. Both ORMs point at the same database (`edr_database`).
|
||||
* - `@tria-plc/api-common`'s `JwtGuard` injects the *default* TypeORM `DataSource` and runs a
|
||||
* raw `SELECT ... FROM iam.sessions`, so the app must expose a DataSource that can reach it.
|
||||
*
|
||||
* Connection env vars intentionally mirror the package's own migration DataSource
|
||||
* (`@tria-plc/api-common/dist/modules/typeorm/typeorm.config.internal.js`) so the app and the
|
||||
* package CLI read the same configuration:
|
||||
* DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_SCHEMA
|
||||
*
|
||||
* This NEVER manages the schema: `synchronize: false` and `migrationsRun: false`. The `iam`
|
||||
* schema is created by the IAM package migrations (dev: self-hosted; prod: central IAM team).
|
||||
*/
|
||||
function resolvePackageDist(pkg: string): string {
|
||||
// Node honors each package's `exports` map at runtime even though TS `moduleResolution: "Node"`
|
||||
// does not — so `require.resolve` on the barrel resolves to the package's dist `index.js`.
|
||||
const resolved = require.resolve(pkg);
|
||||
// Normalize to forward slashes so the glob works on Windows too.
|
||||
return path.dirname(resolved).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function buildIamTypeOrmOptions(): TypeOrmModuleOptions {
|
||||
const iamDist = resolvePackageDist('@tria-plc/iamapi-common');
|
||||
// Some IAM entities (e.g. PositionType) relate to the notification entities that physically
|
||||
// live in @tria-plc/api-common (the IAM barrel only re-exports them), so BOTH dist trees must
|
||||
// be registered or TypeORM throws "Entity metadata ... was not found".
|
||||
const apiDist = resolvePackageDist('@tria-plc/api-common');
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT ?? 5432),
|
||||
database: process.env.DATABASE_NAME,
|
||||
username: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
schema: process.env.DATABASE_SCHEMA ?? 'iam',
|
||||
// IAM entities live in the packages; registered so the same default DataSource also serves
|
||||
// IamModule in the dev self-host phase (Phase 3). Harmless before the tables exist.
|
||||
entities: [
|
||||
`${iamDist}/entities/**/*.entity.{ts,js}`,
|
||||
`${apiDist}/entities/**/*.entity.{ts,js}`,
|
||||
],
|
||||
synchronize: false, // schema is owned by IAM migrations — never auto-sync
|
||||
migrationsRun: false, // migrations are run by the IAM package CLI (dev) / IAM team (prod)
|
||||
autoLoadEntities: false,
|
||||
migrationsTableName: 'typeorm_migrations',
|
||||
retryAttempts: 0, // fail fast in dev if the iam schema / DB is unreachable
|
||||
logging: ['error'],
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { IamGuard } from './iam-adapter';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [HttpModule.register({ timeout: 5000 })],
|
||||
providers: [IamGuard],
|
||||
exports: [IamGuard],
|
||||
})
|
||||
export class IamModule {}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { PrismaService } from '../prisma.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
@@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
||||
private readonly inactivityMinutes: number;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
||||
@@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const user = request.user;
|
||||
const sessionId: string | undefined = request.user?.sessionId;
|
||||
|
||||
if (user?.userId) {
|
||||
const session = await this.prisma.session.findFirst({
|
||||
where: { userId: user.userId },
|
||||
orderBy: { lastActivityAt: 'desc' },
|
||||
});
|
||||
if (sessionId) {
|
||||
const rows = await this.dataSource.query<Array<{ expiry_time: Date }>>(
|
||||
`SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`,
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
if (session) {
|
||||
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
|
||||
|
||||
if (inactiveMinutes > this.inactivityMinutes) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
throw new UnauthorizedException('Session expired due to inactivity');
|
||||
if (rows.length) {
|
||||
const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000;
|
||||
if (minutesLeft < this.inactivityMinutes * 0.2) {
|
||||
response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString());
|
||||
}
|
||||
|
||||
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
|
||||
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { lastActivityAt: new Date() },
|
||||
});
|
||||
// Extend session on every authenticated request
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`,
|
||||
[this.inactivityMinutes, sessionId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard('jwt') {}
|
||||
// Compatibility alias while passenger auth moves to @tria-plc IAM.
|
||||
// Existing controllers can keep importing `../../common/jwt.guard`, but the
|
||||
// guard now validates IAM-issued session tokens from `iam.sessions`.
|
||||
export { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET environment variable is not set');
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
async validate(payload: any) {
|
||||
return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId };
|
||||
}
|
||||
}
|
||||
14
apps/edr-passenger-api/src/common/passenger-guards.ts
Normal file
14
apps/edr-passenger-api/src/common/passenger-guards.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { PassengerPermissionGuard } from './passenger-permission.guard';
|
||||
import { PASSENGER_PERMS } from '../seed/passenger-permissions.registry';
|
||||
|
||||
export const PassengerStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission]),
|
||||
),
|
||||
);
|
||||
|
||||
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { hasPassengerPermission } from './passenger-permission.util';
|
||||
|
||||
export function PassengerPermissionGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class PassengerPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: any }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
|
||||
if (permissions.some((p) => hasPassengerPermission(user, p))) return true;
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return PassengerPermissionsGuard;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
const SUPER_ADMIN_ROLE = 'super_admin';
|
||||
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] }
|
||||
| { positions?: { permissions?: PermissionLike[] }[] }[]
|
||||
| null;
|
||||
};
|
||||
|
||||
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
return user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE) ?? false;
|
||||
}
|
||||
|
||||
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false;
|
||||
}
|
||||
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
|
||||
const employee = user.employee;
|
||||
if (!employee) return [...keys];
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasPassengerPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (isSuperAdmin(user) || isOrganizationAdmin(user)) return true;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertPassengerPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): void {
|
||||
if (hasPassengerPermission(user, permissionKey)) return;
|
||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) return true;
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
// Support IAM roles array [{key, id}][] and legacy role string
|
||||
return requiredRoles.some(
|
||||
(role) => user?.roles?.some((r: { key: string }) => r.key === role) || user?.role === role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal file
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { buildIamTypeOrmOptions } from '../common/iam-typeorm.config';
|
||||
|
||||
/**
|
||||
* Dedicated config namespace for the IAM **TypeORM** connection — the shared `iam` schema ONLY.
|
||||
*
|
||||
* This is intentionally separate from Prisma: Prisma remains the app's primary ORM and owns the
|
||||
* `passenger` schema via `DATABASE_URL` (see prisma.service.ts). This second connection exists
|
||||
* solely because `@tria-plc/api-common` / `@tria-plc/iamapi-common` are TypeORM-based and the
|
||||
* `JwtGuard` reads `iam.sessions` through a TypeORM `DataSource`.
|
||||
*
|
||||
* Consumed by `TypeOrmModule.forRootAsync` in app.module.ts.
|
||||
*/
|
||||
export default registerAs(
|
||||
'iamDatabase',
|
||||
(): TypeOrmModuleOptions => buildIamTypeOrmOptions(),
|
||||
);
|
||||
@@ -1,6 +1,10 @@
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
|
||||
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
@@ -12,6 +16,11 @@ async function bootstrap() {
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
process.env.PORTAL_URL ?? "http://localhost:5174",
|
||||
|
||||
@@ -2,39 +2,37 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
@ApiTags('Agents')
|
||||
@Controller('agents')
|
||||
@UseGuards(IamGuard)
|
||||
// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
|
||||
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
|
||||
@UseGuards(IamJwtGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AgentsController {
|
||||
constructor(private service: AgentsService) {}
|
||||
|
||||
@Post('bookings')
|
||||
@IamRoles('AGENT', 'ADMIN')
|
||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||
return this.service.createAgentBooking(dto);
|
||||
}
|
||||
|
||||
@Post('shifts/open')
|
||||
@IamRoles('AGENT', 'ADMIN')
|
||||
@ApiOperation({ summary: 'Open agent shift' })
|
||||
openShift(@Body() dto: OpenShiftDto) {
|
||||
return this.service.openShift(dto);
|
||||
}
|
||||
|
||||
@Post('shifts/close')
|
||||
@IamRoles('AGENT', 'ADMIN')
|
||||
@ApiOperation({ summary: 'Close agent shift' })
|
||||
closeShift(@Body() dto: CloseShiftDto) {
|
||||
return this.service.closeShift(dto);
|
||||
}
|
||||
|
||||
@Get(':agentId/commissions')
|
||||
@IamRoles('AGENT', 'ADMIN')
|
||||
@ApiOperation({ summary: 'Get agent commissions' })
|
||||
getCommissions(
|
||||
@Param('agentId') agentId: string,
|
||||
@@ -49,7 +47,6 @@ export class AgentsController {
|
||||
}
|
||||
|
||||
@Get(':agentId/shifts')
|
||||
@IamRoles('AGENT', 'ADMIN')
|
||||
@ApiOperation({ summary: 'Get agent shifts' })
|
||||
getShifts(@Param('agentId') agentId: string) {
|
||||
return this.service.getShifts(agentId);
|
||||
|
||||
@@ -13,9 +13,13 @@ export class AgentsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createAgentBooking(dto: CreateAgentBookingDto) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
|
||||
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
|
||||
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
|
||||
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
|
||||
|
||||
const passenger = agent.iamUserId
|
||||
? await this.prisma.passenger.findUnique({ where: { iamUserId: agent.iamUserId } })
|
||||
: null;
|
||||
if (!passenger) throw new BadRequestException('Agent must have a linked passenger account');
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
@@ -30,7 +34,7 @@ export class AgentsService {
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: agent.user.passenger.id,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AuditController {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
@@ -1,303 +1,69 @@
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PassengerAuthService } from './passenger-auth.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { RolesGuard } from '../../common/roles.guard';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private service: AuthService) {}
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({
|
||||
summary: 'Register new passenger account',
|
||||
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Register new passenger account' })
|
||||
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
|
||||
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
|
||||
@ApiBody({ type: RegisterDto })
|
||||
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
|
||||
register(@Request() req: any, @Body() dto: RegisterDto) {
|
||||
return this.passengerAuthService.register(dto, req);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Login with email and password',
|
||||
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
|
||||
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
login(@Body() dto: LoginDto) { return this.service.login(dto); }
|
||||
|
||||
@Post('otp/request')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Request OTP verification code',
|
||||
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
|
||||
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
|
||||
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
|
||||
@ApiBody({ type: RequestOtpDto })
|
||||
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
|
||||
|
||||
@Post('otp/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Verify OTP code',
|
||||
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
||||
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
|
||||
@ApiBody({ type: VerifyOtpDto })
|
||||
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
|
||||
|
||||
@Post('password/reset-request')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Request password reset link',
|
||||
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Email not found' })
|
||||
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
|
||||
@ApiBody({ type: RequestPasswordResetDto })
|
||||
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
|
||||
|
||||
@Post('password/reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Reset password with token',
|
||||
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
|
||||
@ApiResponse({ status: 404, description: 'User not found' })
|
||||
@ApiBody({ type: ResetPasswordDto })
|
||||
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
|
||||
login(@Request() req: any, @Body() dto: LoginDto) {
|
||||
return this.passengerAuthService.login(dto, req);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Logout current user',
|
||||
description: `Logout the authenticated user and invalidate their session.
|
||||
@ApiOperation({ summary: 'Logout current user' })
|
||||
@ApiResponse({ status: 200, description: 'Logout successful' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
logout(@Request() req: any) {
|
||||
if (!req.user?.id) throw new UnauthorizedException('User not authenticated');
|
||||
return this.passengerAuthService.logout(req.user, req);
|
||||
}
|
||||
|
||||
### What happens:
|
||||
- Invalidates the current session token
|
||||
- Records logout in audit log
|
||||
- Frontend should clear stored token and redirect to home
|
||||
|
||||
### Authentication:
|
||||
- **Required**: JWT Bearer Token
|
||||
- Token will be invalidated after successful logout`
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Logout successful',
|
||||
schema: {
|
||||
example: {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||
logout(@Request() req: any) {
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
return this.service.logout(req.user.userId);
|
||||
@Get('me')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard' })
|
||||
@ApiResponse({ status: 200, description: 'Returns the full req.user object set by JwtGuard' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
getMe(@Request() req: any) {
|
||||
return { user: req.user };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user profile',
|
||||
description: `**Returns complete user profile with all connected data**
|
||||
|
||||
---
|
||||
|
||||
### Response Includes
|
||||
|
||||
#### User Information
|
||||
- Basic details (id, email, phone, fullName, role)
|
||||
- Nationality and document information
|
||||
- Fayda verification status
|
||||
- Account timestamps (created, last login)
|
||||
|
||||
#### Passenger Data (if role=PASSENGER)
|
||||
- Passenger ID and preferences
|
||||
- **Loyalty Account**: Tier, points balance, lifetime points
|
||||
- **Wallet Account**: Balance (minor units), currency
|
||||
|
||||
#### Devices
|
||||
- List of registered devices with platform, name, push token, and last seen time
|
||||
|
||||
#### User Preferences
|
||||
- Language, notification settings, etc.
|
||||
|
||||
---
|
||||
|
||||
### Use Cases
|
||||
|
||||
1. **App Initialization**: Fetch on app load to get user context
|
||||
|
||||
2. **Profile Pre-fill**: Use data to auto-fill booking forms
|
||||
|
||||
3. **Verification Check**: Check \`faydaVerified\` before registration
|
||||
|
||||
4. **Loyalty Display**: Show tier and points in UI
|
||||
|
||||
5. **Wallet Balance**: Display available balance
|
||||
|
||||
6. **Device Management**: Get list of user's registered devices
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
- **Required**: JWT Bearer Token
|
||||
- Token must be valid and not expired
|
||||
- Returns profile for authenticated user only`,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'user-uuid-123',
|
||||
email: 'kelemu@email.com',
|
||||
phone: '+251911234567',
|
||||
fullName: 'Kelemu Abebe',
|
||||
role: 'PASSENGER',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: 'ET',
|
||||
nationalId: null,
|
||||
passportNumber: null,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
|
||||
lastLoginAt: '2024-01-20T14:22:00.000Z',
|
||||
createdAt: '2023-12-01T08:00:00.000Z',
|
||||
passenger: {
|
||||
id: 'passenger-uuid-456',
|
||||
preferredLanguage: 'am',
|
||||
loyalty: {
|
||||
tier: 'SILVER',
|
||||
pointsBalance: 1500,
|
||||
lifetimePoints: 3000
|
||||
},
|
||||
wallet: {
|
||||
balanceMinor: 50000,
|
||||
currency: 'ETB'
|
||||
}
|
||||
},
|
||||
preferences: {
|
||||
emailNotifications: true,
|
||||
smsNotifications: true,
|
||||
language: 'am'
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
id: 'device-uuid-1',
|
||||
platform: 'WEB',
|
||||
name: 'Chrome on Windows',
|
||||
pushToken: 'token-abc123',
|
||||
trusted: true,
|
||||
lastSeenAt: '2024-01-20T14:22:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'device-uuid-2',
|
||||
platform: 'IOS',
|
||||
name: 'iPhone 14',
|
||||
pushToken: 'token-xyz789',
|
||||
trusted: false,
|
||||
lastSeenAt: '2024-01-19T10:15:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - Invalid or missing JWT token',
|
||||
schema: {
|
||||
example: {
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
})
|
||||
getProfile(@Request() req: any) {
|
||||
console.log('Profile request - User from JWT:', req.user);
|
||||
if (!req.user || !req.user.userId) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
return this.service.getProfile(req.user.userId);
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiResponse({ status: 200, description: 'User profile retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
getProfile(@Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) throw new UnauthorizedException('User not authenticated');
|
||||
return this.passengerAuthService.getProfile(userId);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
|
||||
getUsers(
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getUsers({
|
||||
search,
|
||||
role,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
|
||||
createUser(@Body() dto: any) {
|
||||
return this.service.createUser(dto);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
|
||||
updateUser(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
|
||||
deleteUser(@Param('id') id: string) {
|
||||
return this.service.deleteUser(id);
|
||||
}
|
||||
|
||||
@Post('users/:id/reset-password')
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
|
||||
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
|
||||
return this.service.resetUserPassword(id, dto.tempPassword);
|
||||
}
|
||||
// TODO: admin user management endpoints — implement when admin module is ready
|
||||
}
|
||||
|
||||
@@ -1,152 +1,51 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class NameDto {
|
||||
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||
@IsString()
|
||||
am: string;
|
||||
|
||||
@ApiProperty({ example: 'Kelemu Ketsela' })
|
||||
@IsString()
|
||||
en: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
description: 'Full name of the passenger',
|
||||
example: 'Kelemu Ketsela',
|
||||
minLength: 2,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString()
|
||||
fullName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Email address (must be unique)',
|
||||
example: 'kelemu@email.com',
|
||||
format: 'email'
|
||||
})
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'kelemu@email.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Phone number with country code',
|
||||
example: '+251912345678',
|
||||
pattern: '^\\+[1-9]\\d{1,14}$'
|
||||
})
|
||||
@IsString()
|
||||
phone: string;
|
||||
@ApiProperty({ example: 'kelemu.ketsela' })
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Password (minimum 8 characters)',
|
||||
example: 'SecurePass123',
|
||||
minLength: 8,
|
||||
format: 'password'
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@ApiProperty({ example: '+251912345678' })
|
||||
@IsString()
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ type: NameDto })
|
||||
@ValidateNested()
|
||||
@Type(() => NameDto)
|
||||
name: NameDto;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nationality of the passenger',
|
||||
example: 'Ethiopian'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'National ID number',
|
||||
example: 'ET123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Passport number for international travelers',
|
||||
example: 'P1234567'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportNumber?: string;
|
||||
@ApiProperty({ example: 'SecurePass123', format: 'password' })
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: 'Registered email address',
|
||||
example: 'kelemu@email.com',
|
||||
format: 'email'
|
||||
})
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'kelemu@email.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Account password',
|
||||
example: 'password123',
|
||||
format: 'password'
|
||||
})
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'password123', format: 'password' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RequestOtpDto {
|
||||
@ApiProperty({
|
||||
description: 'Email address to send OTP',
|
||||
example: 'kelemu@email.com'
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
|
||||
example: 'REGISTRATION',
|
||||
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
|
||||
})
|
||||
@IsString()
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({
|
||||
description: 'Email address',
|
||||
example: 'kelemu@email.com'
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '6-digit OTP code',
|
||||
example: '123456',
|
||||
minLength: 6,
|
||||
maxLength: 6
|
||||
})
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Purpose of OTP verification',
|
||||
example: 'REGISTRATION',
|
||||
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
|
||||
})
|
||||
@IsString()
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export class RequestPasswordResetDto {
|
||||
@ApiProperty({
|
||||
description: 'Email address of the account',
|
||||
example: 'kelemu@email.com'
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({
|
||||
description: 'Password reset token received via email',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'New password (minimum 8 characters)',
|
||||
example: 'NewSecurePass123',
|
||||
minLength: 8,
|
||||
format: 'password'
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from '../../common/jwt.strategy';
|
||||
import { PassengerAuthService } from './passenger-auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (c: ConfigService) => ({
|
||||
secret: c.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [JwtModule],
|
||||
providers: [PassengerAuthService],
|
||||
exports: [PassengerAuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private prisma: PrismaService, private jwt: JwtService) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const exists = await this.prisma.user.findFirst({
|
||||
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
|
||||
});
|
||||
if (exists) throw new ConflictException('Email or phone already registered');
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: dto.fullName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
passwordHash,
|
||||
nationality: dto.nationality,
|
||||
nationalId: dto.nationalId,
|
||||
passportNumber: dto.passportNumber
|
||||
},
|
||||
});
|
||||
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
||||
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
||||
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
|
||||
return await this.signToken(user.id, user.email, user.role, passenger.id);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
include: { passenger: true, agent: true },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
|
||||
}
|
||||
|
||||
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: { increment: 1 },
|
||||
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
|
||||
}
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
|
||||
});
|
||||
|
||||
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
||||
|
||||
// Ensure passenger exists and get its ID
|
||||
let passengerId = user.passenger?.id;
|
||||
if (!passengerId) {
|
||||
// If passenger doesn't exist, create it
|
||||
const passenger = await this.prisma.passenger.create({
|
||||
data: { userId: user.id }
|
||||
});
|
||||
passengerId = passenger.id;
|
||||
// Also create loyalty and wallet accounts
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
||||
}
|
||||
|
||||
return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id);
|
||||
}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
await this.prisma.otpCode.create({
|
||||
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
|
||||
});
|
||||
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
|
||||
return { sent: true, expiresIn: 600 };
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto) {
|
||||
const otp = await this.prisma.otpCode.findFirst({
|
||||
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
if (!otp) throw new BadRequestException('Invalid or expired OTP');
|
||||
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
|
||||
return { verified: true };
|
||||
}
|
||||
|
||||
async requestPasswordReset(dto: RequestPasswordResetDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (!user) return { sent: true };
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await this.prisma.passwordResetToken.create({
|
||||
data: { userId: user.id, token, expiresAt }
|
||||
});
|
||||
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async resetPassword(dto: ResetPasswordDto) {
|
||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { token: dto.token }
|
||||
});
|
||||
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
|
||||
});
|
||||
await this.prisma.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { used: true }
|
||||
});
|
||||
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
|
||||
return { reset: true };
|
||||
}
|
||||
|
||||
async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const { search, role, status, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {
|
||||
role: { not: 'PASSENGER' }, // Exclude passenger accounts
|
||||
};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (role) {
|
||||
where.role = role;
|
||||
}
|
||||
|
||||
// For status filtering, we check if user is active (no lock/block) or inactive
|
||||
if (status === 'ACTIVE') {
|
||||
where.AND = [
|
||||
{ blockedUntil: { lte: new Date() } },
|
||||
{ lockedUntil: { lte: new Date() } }
|
||||
];
|
||||
} else if (status === 'INACTIVE') {
|
||||
where.OR = [
|
||||
{ blockedUntil: { gt: new Date() } },
|
||||
{ lockedUntil: { gt: new Date() } }
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
role: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
blockedUntil: true,
|
||||
lockedUntil: true,
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
lastLogin: user.lastLoginAt,
|
||||
status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
|
||||
(!user.lockedUntil || user.lockedUntil <= new Date())
|
||||
? 'ACTIVE'
|
||||
: 'INACTIVE',
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
|
||||
const exists = await this.prisma.user.findFirst({
|
||||
where: { OR: [{ email: dto.email }] },
|
||||
});
|
||||
if (exists) throw new ConflictException('Email already registered');
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
fullName: dto.fullName,
|
||||
role: dto.role as any,
|
||||
phone: dto.email, // Use email as phone temporarily for unique constraint
|
||||
passwordHash,
|
||||
blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
role: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.fullName) updateData.fullName = dto.fullName;
|
||||
if (dto.role) updateData.role = dto.role;
|
||||
if (dto.status === 'ACTIVE') {
|
||||
updateData.blockedUntil = null;
|
||||
updateData.lockedUntil = null;
|
||||
} else if (dto.status === 'INACTIVE') {
|
||||
updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
role: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
// Don't actually delete, just deactivate
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { blockedUntil: new Date(), lockedUntil: new Date() },
|
||||
});
|
||||
|
||||
await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwordHash,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
|
||||
|
||||
return { reset: true, tempPassword };
|
||||
}
|
||||
|
||||
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||
// Get the full user data to include fullName
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, fullName: true, role: true }
|
||||
});
|
||||
|
||||
const payload = { sub: userId, email, role, passengerId, agentId };
|
||||
console.log('[AUTH] Creating JWT with payload:', payload);
|
||||
|
||||
const token = this.jwt.sign(payload);
|
||||
console.log('[AUTH] JWT created, token length:', token.length);
|
||||
|
||||
const response = {
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
email,
|
||||
fullName: user?.fullName || email,
|
||||
role,
|
||||
passengerId,
|
||||
agentId
|
||||
}
|
||||
};
|
||||
console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action, entityType, entityId, oldData, newData }
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('User ID not found in token');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
passenger: {
|
||||
include: {
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
},
|
||||
},
|
||||
preferences: true,
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
nationality: user.nationality,
|
||||
nationalityCode: user.nationalityCode,
|
||||
nationalId: user.nationalId,
|
||||
passportNumber: user.passportNumber,
|
||||
faydaVerified: user.faydaVerified,
|
||||
faydaVerifiedAt: user.faydaVerifiedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
passenger: user.passenger ? {
|
||||
id: user.passenger.id,
|
||||
preferredLanguage: user.passenger.preferredLanguage,
|
||||
loyalty: user.passenger.loyalty ? {
|
||||
tier: user.passenger.loyalty.tier,
|
||||
pointsBalance: user.passenger.loyalty.pointsBalance,
|
||||
lifetimePoints: user.passenger.loyalty.lifetimePoints,
|
||||
} : null,
|
||||
wallet: user.passenger.wallet ? {
|
||||
balanceMinor: user.passenger.wallet.balanceMinor,
|
||||
currency: user.passenger.wallet.currency,
|
||||
} : null,
|
||||
} : null,
|
||||
preferences: user.preferences,
|
||||
devices: user.devices.map(device => ({
|
||||
id: device.id,
|
||||
platform: device.platform,
|
||||
name: device.name,
|
||||
pushToken: device.pushToken,
|
||||
trusted: device.trusted,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
// Invalidate all active sessions for this user
|
||||
await this.prisma.session.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
// Log the logout action
|
||||
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service';
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
phone_number: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PassengerAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
private async resolveIamAuthService(req: any): Promise<IamAuthService> {
|
||||
const contextId = ContextIdFactory.getByRequest(req);
|
||||
this.moduleRef.registerRequestByContextId(req, contextId);
|
||||
return this.moduleRef.resolve(IamAuthService, contextId, { strict: false });
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto, req: any) {
|
||||
const existing = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
|
||||
[dto.email, dto.phoneNumber],
|
||||
);
|
||||
if (existing.length) throw new ConflictException('Email or phone already registered');
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
const { token, refreshToken } = await iamAuthService.signupWithPassword({
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
name: dto.name,
|
||||
password: dto.password,
|
||||
confirmPassword: dto.confirmPassword,
|
||||
});
|
||||
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
if (!iamRows.length) {
|
||||
await this.compensateIamSignup(dto.email);
|
||||
throw new InternalServerErrorException('Account creation failed. Please try again.');
|
||||
}
|
||||
const iamUserId = iamRows[0].id;
|
||||
|
||||
let passengerId: string;
|
||||
try {
|
||||
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
|
||||
passengerId = result.passengerId;
|
||||
} catch {
|
||||
await this.compensateIamSignup(dto.email);
|
||||
throw new InternalServerErrorException('Account creation failed. Please try again.');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId },
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||
try {
|
||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
||||
} catch {
|
||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if ('mfaRequired' in iamResult && iamResult.mfaRequired) {
|
||||
return iamResult;
|
||||
}
|
||||
|
||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
const iamUser = iamRows[0];
|
||||
if (!iamUser) {
|
||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||
}
|
||||
|
||||
// Find existing Passenger record or lazy-provision one on first login
|
||||
let passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: iamUser.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!passenger) {
|
||||
const result = await this.provisionPassengerSatellite({
|
||||
iamUserId: iamUser.id,
|
||||
auditAction: 'USER_AUTO_PROVISIONED',
|
||||
});
|
||||
passenger = { id: result.passengerId };
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: iamUser.id, iamUserId: iamUser.id, email: dto.email, passengerId: passenger.id },
|
||||
};
|
||||
}
|
||||
|
||||
private async provisionPassengerSatellite(data: {
|
||||
iamUserId: string;
|
||||
auditAction: string;
|
||||
}): Promise<{ passengerId: string }> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const passenger = await tx.passenger.create({
|
||||
data: { iamUserId: data.iamUserId },
|
||||
});
|
||||
await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
||||
await tx.walletAccount.create({ data: { passengerId: passenger.id } });
|
||||
await tx.userPreferences.create({ data: { iamUserId: data.iamUserId } });
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
iamUserId: data.iamUserId,
|
||||
action: data.auditAction,
|
||||
entityType: 'User',
|
||||
entityId: data.iamUserId,
|
||||
newData: { iamUserId: data.iamUserId },
|
||||
},
|
||||
});
|
||||
return { passengerId: passenger.id };
|
||||
});
|
||||
}
|
||||
|
||||
async logout(user: any, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
await iamAuthService.logout(user);
|
||||
return { success: true, message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
async getProfile(iamUserId: string) {
|
||||
const [passenger, iamRows] = await Promise.all([
|
||||
this.prisma.passenger.findUnique({
|
||||
where: { iamUserId },
|
||||
include: { loyalty: true, wallet: true },
|
||||
}),
|
||||
this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
),
|
||||
]);
|
||||
|
||||
if (!passenger) throw new Error('Passenger not found');
|
||||
const iam = iamRows[0];
|
||||
|
||||
return {
|
||||
iamUserId,
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
faydaVerified: iam?.metadata?.faydaVerified ?? false,
|
||||
createdAt: passenger.createdAt,
|
||||
passenger: {
|
||||
id: passenger.id,
|
||||
preferredLanguage: passenger.preferredLanguage,
|
||||
loyalty: passenger.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: passenger.loyalty.lifetimePoints }
|
||||
: null,
|
||||
wallet: passenger.wallet
|
||||
? { balanceMinor: passenger.wallet.balanceMinor, currency: passenger.wallet.currency }
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async compensateIamSignup(email: string): Promise<void> {
|
||||
try {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
const iamUserId = rows[0].id;
|
||||
|
||||
// Discover every table in the iam schema that has a FK pointing at iam.users.id
|
||||
const fkDeps = await this.dataSource.query<{ table_name: string; column_name: string }[]>(`
|
||||
SELECT kcu.table_name, kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_name = rc.constraint_name
|
||||
JOIN information_schema.key_column_usage ccu
|
||||
ON rc.unique_constraint_name = ccu.constraint_name
|
||||
WHERE ccu.table_schema = 'iam' AND ccu.table_name = 'users' AND ccu.column_name = 'id'
|
||||
AND tc.table_schema = 'iam' AND tc.constraint_type = 'FOREIGN KEY'
|
||||
`);
|
||||
|
||||
for (const { table_name, column_name } of fkDeps) {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM iam.${table_name} WHERE ${column_name} = $1`,
|
||||
[iamUserId],
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]);
|
||||
} catch (err) {
|
||||
console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -7,12 +7,13 @@ import { GuestBookingService } from './guest-booking.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
@@ -33,12 +35,13 @@ interface BookingFilters {
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly seatsService: SeatsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
@@ -111,22 +114,22 @@ export class BookingsService {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// Find user with this device ID
|
||||
const device = await this.prisma.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
include: { user: { include: { passenger: true } } },
|
||||
}).catch(() => null);
|
||||
|
||||
// Find passenger linked to this device via iamUserId
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null);
|
||||
const passenger = device?.iamUserId
|
||||
? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const searchConditions = search ? [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
] : [];
|
||||
|
||||
|
||||
const where: any = {
|
||||
OR: [
|
||||
{ userAgent: deviceId },
|
||||
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
|
||||
...(passenger ? [{ passengerId: passenger.id }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -193,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Currencies')
|
||||
@Controller('currencies')
|
||||
@@ -15,8 +16,7 @@ export class CurrenciesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@HttpCode(201)
|
||||
createCurrency(@Body() dto: CreateCurrencyDto) {
|
||||
@@ -24,24 +24,21 @@ export class CurrenciesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
|
||||
return this.currenciesService.updateCurrency(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
deleteCurrency(@Param('id') id: string) {
|
||||
return this.currenciesService.deleteCurrency(id);
|
||||
}
|
||||
|
||||
@Post('sync-rates')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN')
|
||||
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@HttpCode(200)
|
||||
syncRates() {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getHomeDashboard(passengerId: string) {
|
||||
const now = new Date();
|
||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
|
||||
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { loyalty: true } }),
|
||||
this.prisma.booking.findFirst({
|
||||
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
|
||||
include: {
|
||||
@@ -27,7 +32,16 @@ export class DashboardService {
|
||||
|
||||
const hour = now.getHours();
|
||||
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
|
||||
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
|
||||
|
||||
let firstName = '';
|
||||
if (passenger?.iamUserId) {
|
||||
const iamRows = await this.dataSource.query<{ name: { en?: string; am?: string } | null }[]>(
|
||||
`SELECT name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[passenger.iamUserId],
|
||||
);
|
||||
const name = iamRows[0]?.name;
|
||||
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
|
||||
}
|
||||
const seat = upcomingBooking?.seats[0];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Fraud Detection')
|
||||
@Controller('fraud')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class FraudController {
|
||||
private readonly logger = new Logger(FraudController.name);
|
||||
@@ -17,7 +17,6 @@ export class FraudController {
|
||||
* Get fraud alerts
|
||||
*/
|
||||
@Get('alerts')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Get fraud alerts' })
|
||||
async getAlerts(
|
||||
@Query('userId') userId?: string,
|
||||
@@ -32,7 +31,6 @@ export class FraudController {
|
||||
* Get fraud rules
|
||||
*/
|
||||
@Get('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@ApiOperation({ summary: 'Get fraud detection rules' })
|
||||
async getRules() {
|
||||
const rules = await this.fraudService.getRules();
|
||||
@@ -43,7 +41,7 @@ export class FraudController {
|
||||
* Create or update fraud rule
|
||||
*/
|
||||
@Post('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create or update fraud rule' })
|
||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||
@@ -54,10 +52,10 @@ export class FraudController {
|
||||
* Block user temporarily
|
||||
*/
|
||||
@Post('actions/block')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Block user temporarily' })
|
||||
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
|
||||
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
|
||||
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
|
||||
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
|
||||
return { message: `User blocked for ${body.durationMinutes} minutes` };
|
||||
}
|
||||
|
||||
@@ -65,10 +63,10 @@ export class FraudController {
|
||||
* Unblock user
|
||||
*/
|
||||
@Post('actions/unblock')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Unblock user' })
|
||||
async unblockUser(@Body() body: { userId: string }) {
|
||||
await this.fraudService.unblockUser(body.userId);
|
||||
async unblockUser(@Body() body: { iamUserId: string }) {
|
||||
await this.fraudService.unblockUser(body.iamUserId);
|
||||
return { message: 'User unblocked' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
export interface FraudRuleConfig {
|
||||
@@ -14,47 +16,37 @@ export interface FraudRuleConfig {
|
||||
export class FraudService {
|
||||
private readonly logger = new Logger(FraudService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evaluate fraud rules and create alerts if triggered
|
||||
*/
|
||||
async evaluateRules(
|
||||
userId: string,
|
||||
passengerId: string,
|
||||
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
|
||||
context: Record<string, unknown>,
|
||||
): Promise<{ triggered: boolean; rules: string[] }> {
|
||||
const triggeredRules: string[] = [];
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) return { triggered: false, rules: [] };
|
||||
|
||||
// Check velocity rule (multiple bookings in short time)
|
||||
if (eventType === 'booking.created') {
|
||||
const velocityTriggered = await this.checkVelocityRule(userId);
|
||||
if (velocityTriggered) {
|
||||
triggeredRules.push('VELOCITY');
|
||||
}
|
||||
const velocityTriggered = await this.checkVelocityRule(passengerId);
|
||||
if (velocityTriggered) triggeredRules.push('VELOCITY');
|
||||
|
||||
// Check high-value booking
|
||||
const amount = (context.amountMinor as number) || 0;
|
||||
const highValueTriggered = await this.checkHighValueRule(amount);
|
||||
if (highValueTriggered) {
|
||||
triggeredRules.push('HIGH_VALUE');
|
||||
}
|
||||
if (highValueTriggered) triggeredRules.push('HIGH_VALUE');
|
||||
}
|
||||
|
||||
// Check repeated failed payments
|
||||
if (eventType === 'payment.failed') {
|
||||
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
|
||||
if (failedPaymentTriggered) {
|
||||
triggeredRules.push('FAILED_PAYMENTS');
|
||||
}
|
||||
const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId);
|
||||
if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS');
|
||||
}
|
||||
|
||||
// Create alert if rules triggered
|
||||
if (triggeredRules.length > 0) {
|
||||
await this.createFraudAlert(userId, eventType, triggeredRules, context);
|
||||
await this.createFraudAlert(passengerId, eventType, triggeredRules, context);
|
||||
return { triggered: true, rules: triggeredRules };
|
||||
}
|
||||
|
||||
@@ -64,7 +56,7 @@ export class FraudService {
|
||||
/**
|
||||
* Check velocity rule: X bookings in Y minutes
|
||||
*/
|
||||
private async checkVelocityRule(userId: string): Promise<boolean> {
|
||||
private async checkVelocityRule(passengerId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'VELOCITY', enabled: true },
|
||||
});
|
||||
@@ -72,18 +64,14 @@ export class FraudService {
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const bookingCount = await this.prisma.booking.count({
|
||||
where: {
|
||||
passengerId: userId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
passengerId,
|
||||
createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
|
||||
return bookingCount > threshold;
|
||||
return bookingCount > rule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +92,7 @@ export class FraudService {
|
||||
/**
|
||||
* Check failed payment rule: X failed attempts in Y minutes
|
||||
*/
|
||||
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
|
||||
private async checkFailedPaymentRule(passengerId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'FAILED_PAYMENTS', enabled: true },
|
||||
});
|
||||
@@ -112,33 +100,33 @@ export class FraudService {
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const failedCount = await this.prisma.paymentIntent.count({
|
||||
where: {
|
||||
booking: { passengerId: userId },
|
||||
booking: { passengerId },
|
||||
status: 'FAILED',
|
||||
updatedAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
|
||||
return failedCount > threshold;
|
||||
return failedCount > rule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fraud alert
|
||||
*/
|
||||
private async createFraudAlert(
|
||||
userId: string,
|
||||
passengerId: string,
|
||||
eventType: string,
|
||||
triggeredRules: string[],
|
||||
context: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
select: { iamUserId: true },
|
||||
});
|
||||
const alert = await this.prisma.fraudAlert.create({
|
||||
data: {
|
||||
userId,
|
||||
iamUserId: passenger?.iamUserId ?? passengerId,
|
||||
eventType,
|
||||
triggeredRules,
|
||||
context: context as any,
|
||||
@@ -146,35 +134,34 @@ export class FraudService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
|
||||
this.logger.warn(`Fraud alert created: ${alert.id} for passenger ${passengerId} - rules: ${triggeredRules.join(', ')}`);
|
||||
|
||||
// Trigger blocking if needed
|
||||
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
|
||||
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
|
||||
if (passenger?.iamUserId) await this.blockUserTemporarily(passenger.iamUserId, 30);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user temporarily
|
||||
*/
|
||||
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
|
||||
async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise<void> {
|
||||
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
await this.prisma.passenger.updateMany({
|
||||
where: { iamUserId },
|
||||
data: { blockedUntil },
|
||||
});
|
||||
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
|
||||
this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
async unblockUser(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
async unblockUser(iamUserId: string): Promise<void> {
|
||||
await this.prisma.passenger.updateMany({
|
||||
where: { iamUserId },
|
||||
data: { blockedUntil: null },
|
||||
});
|
||||
this.logger.log(`User ${userId} unblocked`);
|
||||
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +169,7 @@ export class FraudService {
|
||||
*/
|
||||
async getAlerts(userId?: string, limit = 100, offset = 0) {
|
||||
return this.prisma.fraudAlert.findMany({
|
||||
where: userId ? { userId } : {},
|
||||
where: userId ? { iamUserId: userId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
@@ -234,9 +221,10 @@ export class FraudService {
|
||||
* Event listener for payment failed
|
||||
*/
|
||||
@OnEvent('payment.failed')
|
||||
async onPaymentFailed(payload: { intentId: string; userId: string }) {
|
||||
await this.evaluateRules(payload.userId, 'payment.failed', {
|
||||
intentId: payload.intentId,
|
||||
async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) {
|
||||
if (!payload.booking?.passengerId) return;
|
||||
await this.evaluateRules(payload.booking.passengerId, 'payment.failed', {
|
||||
bookingId: payload.booking.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,9 +232,18 @@ export class FraudService {
|
||||
* Event listener for auth login failed
|
||||
*/
|
||||
@OnEvent('auth.login.failed')
|
||||
async onLoginFailed(payload: { userId: string; email: string }) {
|
||||
await this.evaluateRules(payload.userId, 'auth.login.failed', {
|
||||
email: payload.email,
|
||||
async onLoginFailed(payload: { email: string }) {
|
||||
if (!payload.email) return;
|
||||
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[payload.email],
|
||||
);
|
||||
if (!iamRows.length) return;
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: iamRows[0].id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!passenger) return;
|
||||
await this.evaluateRules(passenger.id, 'auth.login.failed', { email: payload.email });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { TestNotificationDto } from './notifications.dto';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
@@ -39,8 +40,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Post('send/email')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
|
||||
@ApiBody({ type: SendEmail })
|
||||
sendEmail(@Body() dto: SendEmail) {
|
||||
@@ -48,8 +48,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Post('send/sms')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
||||
@ApiBody({ type: SingleMessageDto })
|
||||
sendSms(@Body() dto: SingleMessageDto) {
|
||||
@@ -57,8 +56,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Post('send/sms/bulk')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
|
||||
@ApiBody({ type: BulkMessagesDto })
|
||||
sendBulkSms(@Body() dto: BulkMessagesDto) {
|
||||
@@ -66,8 +64,6 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
||||
async testNotification(@Body() dto: TestNotificationDto) {
|
||||
return this.service.send(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
@@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
@@ -14,6 +18,7 @@ export class NotificationsService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private emailClient: EmailClientService,
|
||||
private smsClient: SmsClientService,
|
||||
private pushAdapter: PushAdapter,
|
||||
@@ -112,22 +117,20 @@ export class NotificationsService {
|
||||
body: string,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Try to find passenger by ID or email
|
||||
let passengerId = recipient;
|
||||
|
||||
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: recipient }, { phone: recipient }],
|
||||
},
|
||||
include: { passenger: true },
|
||||
});
|
||||
if (user?.passenger) {
|
||||
passengerId = user.passenger.id;
|
||||
} else {
|
||||
if (!UUID_RE.test(recipient)) {
|
||||
const iamUserId = await this.resolveIamUserId(recipient);
|
||||
if (!iamUserId) {
|
||||
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
||||
return;
|
||||
}
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } });
|
||||
if (!passenger) {
|
||||
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
||||
return;
|
||||
}
|
||||
passengerId = passenger.id;
|
||||
}
|
||||
|
||||
await this.prisma.notification.create({
|
||||
@@ -163,26 +166,19 @@ export class NotificationsService {
|
||||
}
|
||||
|
||||
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
include: { preferences: true },
|
||||
});
|
||||
const iamUserId = await this.resolveIamUserId(recipient);
|
||||
const preferences = iamUserId
|
||||
? await this.prisma.userPreferences.findUnique({ where: { iamUserId } })
|
||||
: null;
|
||||
|
||||
if (!user?.preferences) {
|
||||
if (!preferences) {
|
||||
return ['IN_APP', 'EMAIL'];
|
||||
}
|
||||
|
||||
const channels: NotificationChannelType[] = ['IN_APP'];
|
||||
if (user.preferences.emailEnabled) channels.push('EMAIL');
|
||||
if (user.preferences.smsEnabled) channels.push('SMS');
|
||||
if (user.preferences.pushEnabled) channels.push('PUSH');
|
||||
if (preferences.emailEnabled) channels.push('EMAIL');
|
||||
if (preferences.smsEnabled) channels.push('SMS');
|
||||
if (preferences.pushEnabled) channels.push('PUSH');
|
||||
|
||||
return channels;
|
||||
}
|
||||
@@ -191,32 +187,44 @@ export class NotificationsService {
|
||||
recipient: string,
|
||||
channel: NotificationChannelType,
|
||||
): Promise<string | null> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
const iamUserId = await this.resolveIamUserId(recipient);
|
||||
if (!iamUserId) return null;
|
||||
const contact = await this.resolveContactInfo(iamUserId);
|
||||
|
||||
switch (channel) {
|
||||
case 'EMAIL':
|
||||
return user.email;
|
||||
case 'SMS':
|
||||
return user.phone;
|
||||
case 'PUSH':
|
||||
// Would need to fetch device push token
|
||||
return user.id;
|
||||
default:
|
||||
return null;
|
||||
case 'EMAIL': return contact.email;
|
||||
case 'SMS': return contact.phone;
|
||||
case 'PUSH': return iamUserId;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveIamUserId(recipient: string): Promise<string | null> {
|
||||
if (UUID_RE.test(recipient)) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } });
|
||||
return passenger?.iamUserId ?? recipient;
|
||||
}
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[recipient],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> {
|
||||
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
|
||||
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null };
|
||||
}
|
||||
|
||||
private sanitize(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||
}
|
||||
|
||||
getForPassenger(passengerId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { passengerId },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal file
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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 {}
|
||||
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal file
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@ne
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -53,25 +52,17 @@ export class PassengersController {
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||
async getMe(@Request() req: any) {
|
||||
if (!req.user || !req.user.userId) {
|
||||
if (!req.user || !req.user.id) {
|
||||
throw new UnauthorizedException('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: req.user.userId },
|
||||
include: {
|
||||
passenger: true,
|
||||
},
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: req.user.id },
|
||||
});
|
||||
|
||||
if (!user || !user.passenger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.service.getProfile(user.passenger.id);
|
||||
if (!passenger) return null;
|
||||
return this.service.getProfile(passenger.id);
|
||||
} catch (error) {
|
||||
// If profile lookup fails for any reason, return null to allow app to continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -251,7 +242,7 @@ The API automatically detects:
|
||||
description: 'Invalid JWT token (only if token provided but invalid)'
|
||||
})
|
||||
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
||||
const userId = req.user?.userId;
|
||||
const userId = req.user?.id;
|
||||
return this.service.registerPassenger({ ...dto, userId });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -10,37 +12,68 @@ interface PassengerFilters {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
phone_number: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PassengersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = { user: { role: 'PASSENGER' } };
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
...where.user,
|
||||
OR: [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
|
||||
let iamUserIdFilter: string[] | null = null;
|
||||
|
||||
if (search || verified !== undefined) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(
|
||||
u.email ILIKE $${idx} OR
|
||||
u.phone_number ILIKE $${idx} OR
|
||||
(u.name->>'en') ILIKE $${idx} OR
|
||||
(u.name->>'am') ILIKE $${idx}
|
||||
)`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
if (verified) {
|
||||
conditions.push(`u.metadata->>'faydaVerified' = 'true'`);
|
||||
} else {
|
||||
conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
iamUserIdFilter = rows.map(r => r.id);
|
||||
|
||||
if (iamUserIdFilter.length === 0) {
|
||||
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
where.user = {
|
||||
...where.user,
|
||||
nationalId: verified ? { not: null } : null,
|
||||
};
|
||||
|
||||
const where: any = {};
|
||||
if (iamUserIdFilter) {
|
||||
where.iamUserId = { in: iamUserIdFilter };
|
||||
}
|
||||
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
where,
|
||||
@@ -48,42 +81,36 @@ export class PassengersService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { bookings: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
]);
|
||||
|
||||
|
||||
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
|
||||
[iamUserIds],
|
||||
)
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const user = passenger.user as any;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||
return {
|
||||
id: passenger.id,
|
||||
userId: passenger.userId,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
|
||||
nationalId: user.nationalId,
|
||||
nationality: user.nationality,
|
||||
dateOfBirth: user.dateOfBirth ?? null,
|
||||
gender: user.gender ?? null,
|
||||
passportNumber: user.passportNumber,
|
||||
passportCountry: user.passportCountry ?? null,
|
||||
verified: !!user.nationalId,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
verified: faydaVerified,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
loyalty: passenger.loyalty,
|
||||
wallet: passenger.wallet,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
@@ -99,33 +126,42 @@ export class PassengersService {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: true,
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
}
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
},
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
travelerProfiles: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
travelerProfiles: true,
|
||||
savedRoutes: true,
|
||||
},
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
let iamUser: IamUserRow | null = null;
|
||||
if (passenger.iamUserId) {
|
||||
const rows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[passenger.iamUserId],
|
||||
);
|
||||
iamUser = rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: passenger.user.fullName,
|
||||
email: passenger.user.email,
|
||||
phone: passenger.user.phone,
|
||||
fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
|
||||
email: iamUser?.email ?? null,
|
||||
phone: iamUser?.phone_number ?? null,
|
||||
createdAt: passenger.createdAt,
|
||||
bookings: passenger.bookings.map((b) => ({
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalFare: b.totalMinor / 100,
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalFare: b.totalMinor / 100,
|
||||
createdAt: b.createdAt,
|
||||
trip: {
|
||||
number: b.schedule.train.number,
|
||||
@@ -143,13 +179,9 @@ export class PassengersService {
|
||||
},
|
||||
departureAt: b.schedule.departureAt,
|
||||
},
|
||||
passengers: b.seats.map((bs) => ({
|
||||
fullName: bs.passengerName,
|
||||
seat: {
|
||||
number: bs.seat.seatNumber,
|
||||
coach: bs.seat.coach.number,
|
||||
class: 'N/A'
|
||||
}
|
||||
passengers: b.seats.map((bs) => ({
|
||||
fullName: bs.passengerName,
|
||||
seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' },
|
||||
})),
|
||||
})),
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)",
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { GenerateReportDto } from './reports.dto';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@UseGuards(IamGuard)
|
||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Generate operational report' })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'List reports' })
|
||||
listReports(@Query('type') type?: string) {
|
||||
return this.service.listReports(type);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
@@ -113,13 +118,25 @@ export class ReportsService {
|
||||
...(agentId ? { agentId } : {})
|
||||
},
|
||||
include: {
|
||||
agent: { include: { user: true } },
|
||||
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
||||
booking: true
|
||||
}
|
||||
});
|
||||
|
||||
const iamUserIds = [...new Set(
|
||||
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
|
||||
)];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
|
||||
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
|
||||
[iamUserIds],
|
||||
)
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
const byAgent = agentBookings.reduce((acc, ab) => {
|
||||
const agentName = ab.agent.user.fullName;
|
||||
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
|
||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Like {@link JwtGuard}, but never rejects the request.
|
||||
* Like the IAM JwtGuard, but never rejects the request.
|
||||
*
|
||||
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
|
||||
* populated from the JWT strategy (`{ userId, ... }`). When the token is
|
||||
* missing or invalid, the request still proceeds with `request.user`
|
||||
* undefined — the handler decides what to do.
|
||||
*
|
||||
* Used on `POST /fayda/verification/start`, which must work for both
|
||||
* logged-in users (who can opt to save the verification to their account)
|
||||
* and guests (anchored to a booking only).
|
||||
* When a valid IAM bearer token is present, `request.user` is populated with
|
||||
* the package `TCurrentUser`. Missing or invalid tokens continue as guests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalJwtGuard extends AuthGuard('jwt') {
|
||||
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
|
||||
return (user ?? null) as TUser;
|
||||
export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
constructor(
|
||||
reflector: Reflector,
|
||||
@InjectDataSource() dataSource: DataSource,
|
||||
) {
|
||||
super(reflector, dataSource);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
await super.canActivate(context);
|
||||
} catch {
|
||||
context.switchToHttp().getRequest().user = undefined;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||
import {
|
||||
@@ -25,21 +26,13 @@ import {
|
||||
} from './verifayda.dto';
|
||||
import { VerifaydaService } from './verifayda.service';
|
||||
|
||||
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
|
||||
interface AuthedUser {
|
||||
userId: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
passengerId?: string;
|
||||
}
|
||||
|
||||
/** Minimal slices of the Express req we touch (avoids a hard dependency on
|
||||
* `@types/express`, which isn't resolved in this package). */
|
||||
interface RequestWithOptionalUser {
|
||||
user?: AuthedUser;
|
||||
user?: TCurrentUser;
|
||||
}
|
||||
interface RequestWithUser {
|
||||
user: AuthedUser;
|
||||
user: TCurrentUser;
|
||||
}
|
||||
|
||||
@ApiTags('Fayda Verification')
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
import { VerifaydaController } from './verifayda.controller';
|
||||
import { VerifaydaService } from './verifayda.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
|
||||
// config as /auth/login) to mint tokens for the LOGIN flow.
|
||||
imports: [PrismaModule, AuthModule],
|
||||
imports: [PrismaModule],
|
||||
controllers: [VerifaydaController],
|
||||
providers: [VerifaydaService],
|
||||
exports: [VerifaydaService],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { FaydaConfig } from '../../config/fayda.config';
|
||||
@@ -16,12 +15,6 @@ function buildPrismaMock() {
|
||||
bookingSeat: {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
passenger: { create: jest.fn() },
|
||||
loyaltyAccount: { create: jest.fn() },
|
||||
walletAccount: { create: jest.fn() },
|
||||
@@ -30,10 +23,8 @@ function buildPrismaMock() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildJwtMock(): jest.Mocked<JwtService> {
|
||||
return {
|
||||
sign: jest.fn(() => 'signed.jwt.token'),
|
||||
} as unknown as jest.Mocked<JwtService>;
|
||||
function buildDataSourceMock() {
|
||||
return { query: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||
@@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
|
||||
|
||||
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||
let jwt: jest.Mocked<JwtService>;
|
||||
let dataSource: ReturnType<typeof buildDataSourceMock>;
|
||||
let service: VerifaydaService;
|
||||
let realPrivateJwk: JWK;
|
||||
|
||||
@@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = buildPrismaMock();
|
||||
jwt = buildJwtMock();
|
||||
dataSource = buildDataSourceMock();
|
||||
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
||||
service = new VerifaydaService(
|
||||
buildConfigService(cfg),
|
||||
prisma as unknown as PrismaService,
|
||||
jwt,
|
||||
dataSource as any,
|
||||
);
|
||||
(global as any).fetch = jest.fn();
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
165
apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts
Normal file
165
apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Application,
|
||||
Organization,
|
||||
OrganizationConfiguration,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
} from '@tria-plc/iamapi-common';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { ERoleKey } from '@tria-plc/api-common/utils/enums/seed.enum';
|
||||
import {
|
||||
PASSENGER_PERMISSIONS,
|
||||
PASSENGER_PERMISSION_KEYS,
|
||||
} from './passenger-permissions.registry';
|
||||
import { EDR_PASSENGER_APPLICATION, EDR_PASSENGER_ROLES, type PassengerSeedRole } from './edr-passenger.seed';
|
||||
|
||||
const EDR_ORG_KEY = 'edr';
|
||||
const EDR_ORG_NAME = { am: 'EDR', en: 'EDR' };
|
||||
const SEED_FLAG = 'SEED_EDR_PASSENGER_ORG';
|
||||
|
||||
type SeedOrganization = { id: string; key: string };
|
||||
|
||||
@Injectable()
|
||||
export class EdrPassengerOrgSeeder {
|
||||
private readonly logger = new Logger(EdrPassengerOrgSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping passenger org seed because ${SEED_FLAG} is not enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.ensureApplication(manager);
|
||||
await this.ensurePermissions(manager);
|
||||
const organization = await this.ensureOrganization(manager);
|
||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||
await this.ensureRoles(manager, EDR_PASSENGER_ROLES);
|
||||
await this.ensureRolePermissions(manager, EDR_PASSENGER_ROLES);
|
||||
await this.ensureSuperAdminPermissions(manager);
|
||||
});
|
||||
|
||||
this.logger.log(`Ensured EDR passenger organization seed for '${EDR_ORG_KEY}'`);
|
||||
}
|
||||
|
||||
private async ensureApplication(manager: EntityManager) {
|
||||
await manager.getRepository(Application).upsert(
|
||||
{
|
||||
id: EDR_PASSENGER_APPLICATION.id,
|
||||
key: EDR_PASSENGER_APPLICATION.key,
|
||||
name: EDR_PASSENGER_APPLICATION.name,
|
||||
},
|
||||
{ conflictPaths: { key: true } },
|
||||
);
|
||||
this.logger.log(`Ensured application '${EDR_PASSENGER_APPLICATION.key}'`);
|
||||
}
|
||||
|
||||
private async ensurePermissions(manager: EntityManager) {
|
||||
await manager.getRepository(Permission).upsert(
|
||||
PASSENGER_PERMISSIONS.map((p) => ({
|
||||
id: p.id,
|
||||
key: p.key,
|
||||
name: p.name,
|
||||
applicationId: EDR_PASSENGER_APPLICATION.id,
|
||||
})),
|
||||
{ conflictPaths: { key: true } },
|
||||
);
|
||||
this.logger.log(`Ensured ${PASSENGER_PERMISSIONS.length} passenger permissions`);
|
||||
}
|
||||
|
||||
private async ensureOrganization(manager: EntityManager): Promise<SeedOrganization> {
|
||||
const repo = manager.getRepository(Organization);
|
||||
let org = await repo.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true } });
|
||||
|
||||
if (!org) {
|
||||
const result = await repo.insert({
|
||||
key: EDR_ORG_KEY,
|
||||
name: EDR_ORG_NAME,
|
||||
isGovernmentOrganization: true,
|
||||
});
|
||||
this.logger.log(`Seeded EDR passenger organization '${EDR_ORG_KEY}'`);
|
||||
return { id: result.identifiers[0]?.id as string, key: EDR_ORG_KEY };
|
||||
}
|
||||
|
||||
this.logger.log(`Ensured EDR passenger organization '${EDR_ORG_KEY}'`);
|
||||
return { id: org.id as string, key: EDR_ORG_KEY };
|
||||
}
|
||||
|
||||
private async ensureOrganizationConfiguration(manager: EntityManager, organizationId: string) {
|
||||
await manager.getRepository(OrganizationConfiguration).upsert(
|
||||
{ organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true },
|
||||
{ conflictPaths: { organizationId: true } },
|
||||
);
|
||||
this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`);
|
||||
}
|
||||
|
||||
private async ensureRoles(manager: EntityManager, seedRoles: PassengerSeedRole[]) {
|
||||
await manager.getRepository(Role).upsert(
|
||||
seedRoles.map(({ key, name }) => ({ key, name })),
|
||||
{ conflictPaths: { key: true } },
|
||||
);
|
||||
this.logger.log(`Ensured passenger roles: ${seedRoles.map((r) => r.key).join(', ')}`);
|
||||
}
|
||||
|
||||
private async ensureRolePermissions(manager: EntityManager, seedRoles: PassengerSeedRole[]) {
|
||||
const allPermissionKeys = [...new Set(seedRoles.flatMap((r) => r.permissionKeys))];
|
||||
if (!allPermissionKeys.length) return;
|
||||
|
||||
const roles = await manager.getRepository(Role).find({
|
||||
where: { key: In(seedRoles.map((r) => r.key)) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const permissions = await manager.getRepository(Permission).find({
|
||||
where: { key: In(allPermissionKeys) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
const roleByKey = new Map(roles.map((r) => [r.key, r]));
|
||||
const permByKey = new Map(permissions.map((p) => [p.key, p]));
|
||||
|
||||
const links = seedRoles.flatMap((seedRole) => {
|
||||
const role = roleByKey.get(seedRole.key);
|
||||
if (!role) throw new Error(`missing_role:${seedRole.key}`);
|
||||
|
||||
return seedRole.permissionKeys.map((key) => {
|
||||
const perm = permByKey.get(key);
|
||||
if (!perm) throw new Error(`missing_permission:${key}`);
|
||||
return { roleId: role.id, permissionId: perm.id };
|
||||
});
|
||||
});
|
||||
|
||||
await manager.getRepository(RolePermission).upsert(links, {
|
||||
conflictPaths: { roleId: true, permissionId: true },
|
||||
});
|
||||
this.logger.log(`Ensured ${links.length} passenger role-permission links`);
|
||||
}
|
||||
|
||||
private async ensureSuperAdminPermissions(manager: EntityManager) {
|
||||
const role = await manager.getRepository(Role).findOne({
|
||||
where: { key: ERoleKey.SUPER_ADMIN },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
this.logger.warn(`Role ${ERoleKey.SUPER_ADMIN} not found; skipping super_admin permission links`);
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = await manager.getRepository(Permission).find({
|
||||
where: { key: In(PASSENGER_PERMISSION_KEYS) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!permissions.length) return;
|
||||
|
||||
await manager.getRepository(RolePermission).upsert(
|
||||
permissions.map((p) => ({ roleId: role.id, permissionId: p.id })),
|
||||
{ conflictPaths: { roleId: true, permissionId: true } },
|
||||
);
|
||||
this.logger.log(`Ensured ${permissions.length} passenger permissions on super_admin`);
|
||||
}
|
||||
}
|
||||
47
apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
Normal file
47
apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
PASSENGER_PERMISSIONS,
|
||||
PASSENGER_PERMISSION_KEYS,
|
||||
ROLE_PERMISSION_PRESETS,
|
||||
} from './passenger-permissions.registry';
|
||||
|
||||
export type PassengerSeedRole = {
|
||||
key: string;
|
||||
name: { en: string };
|
||||
permissionKeys: string[];
|
||||
};
|
||||
|
||||
export const EDR_PASSENGER_APPLICATION = {
|
||||
id: 'd2000001-0001-4000-8000-000000000001',
|
||||
key: 'edr_passenger_app',
|
||||
name: {
|
||||
am: 'EDR Passenger App',
|
||||
en: 'EDR Passenger App',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EDR_PASSENGER_PERMISSIONS = [...PASSENGER_PERMISSIONS];
|
||||
|
||||
export { PASSENGER_PERMISSION_KEYS } from './passenger-permissions.registry';
|
||||
|
||||
export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
|
||||
{
|
||||
key: 'edr_passenger_backoffice_admin',
|
||||
name: { en: 'EDR Passenger Backoffice Admin' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeAdmin],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_backoffice_staff',
|
||||
name: { en: 'EDR Passenger Backoffice Staff' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_agent',
|
||||
name: { en: 'EDR Passenger Agent' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.agent],
|
||||
},
|
||||
{
|
||||
key: 'edr_passenger_finance',
|
||||
name: { en: 'EDR Passenger Finance' },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,121 @@
|
||||
const APP_KEY = 'edr_passenger_app';
|
||||
|
||||
export type PassengerPermissionSeed = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: { am: string; en: string };
|
||||
applicationKey: string;
|
||||
};
|
||||
|
||||
const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ({
|
||||
id,
|
||||
key,
|
||||
name: { am: en, en },
|
||||
applicationKey: APP_KEY,
|
||||
});
|
||||
|
||||
export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
||||
perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'),
|
||||
perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
|
||||
perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
|
||||
perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'),
|
||||
perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
|
||||
perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'),
|
||||
perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
|
||||
perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'),
|
||||
perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'),
|
||||
perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
|
||||
perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'),
|
||||
perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'),
|
||||
perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'),
|
||||
perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'),
|
||||
perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'),
|
||||
perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'),
|
||||
perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
|
||||
perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'),
|
||||
perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'),
|
||||
perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'),
|
||||
];
|
||||
|
||||
export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key);
|
||||
|
||||
export const PASSENGER_PERMS = {
|
||||
bookings: {
|
||||
view: 'edr_passenger_app:bookings:view',
|
||||
manage: 'edr_passenger_app:bookings:manage',
|
||||
cancel: 'edr_passenger_app:bookings:cancel',
|
||||
},
|
||||
passengers: {
|
||||
view: 'edr_passenger_app:passengers:view',
|
||||
manage: 'edr_passenger_app:passengers:manage',
|
||||
},
|
||||
tickets: {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
},
|
||||
payments: {
|
||||
viewAll: 'edr_passenger_app:payments:view_all',
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||
},
|
||||
reports: {
|
||||
view: 'edr_passenger_app:reports:view',
|
||||
},
|
||||
fraud: {
|
||||
view: 'edr_passenger_app:fraud:view',
|
||||
manage: 'edr_passenger_app:fraud:manage',
|
||||
},
|
||||
audit: {
|
||||
view: 'edr_passenger_app:audit:view',
|
||||
},
|
||||
agents: {
|
||||
view: 'edr_passenger_app:agents:view',
|
||||
manage: 'edr_passenger_app:agents:manage',
|
||||
},
|
||||
currencies: {
|
||||
manage: 'edr_passenger_app:currencies:manage',
|
||||
},
|
||||
notifications: {
|
||||
send: 'edr_passenger_app:notifications:send',
|
||||
},
|
||||
dashboard: {
|
||||
view: 'edr_passenger_app:dashboard:view',
|
||||
},
|
||||
admin: 'edr_passenger_app:admin',
|
||||
} as const;
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
backofficeAdmin: [...PASSENGER_PERMISSION_KEYS],
|
||||
|
||||
backofficeStaff: [
|
||||
PASSENGER_PERMS.bookings.view,
|
||||
PASSENGER_PERMS.bookings.manage,
|
||||
PASSENGER_PERMS.bookings.cancel,
|
||||
PASSENGER_PERMS.passengers.view,
|
||||
PASSENGER_PERMS.passengers.manage,
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.tickets.manage,
|
||||
PASSENGER_PERMS.payments.viewAll,
|
||||
PASSENGER_PERMS.reports.view,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
PASSENGER_PERMS.notifications.send,
|
||||
PASSENGER_PERMS.agents.view,
|
||||
PASSENGER_PERMS.fraud.view,
|
||||
PASSENGER_PERMS.audit.view,
|
||||
],
|
||||
|
||||
agent: [
|
||||
PASSENGER_PERMS.bookings.view,
|
||||
PASSENGER_PERMS.bookings.manage,
|
||||
PASSENGER_PERMS.passengers.view,
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.payments.refund,
|
||||
],
|
||||
|
||||
finance: [
|
||||
PASSENGER_PERMS.payments.viewAll,
|
||||
PASSENGER_PERMS.payments.refund,
|
||||
PASSENGER_PERMS.reports.view,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
],
|
||||
} as const;
|
||||
106
apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts
Normal file
106
apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import {
|
||||
Employee,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
UserCredential,
|
||||
UserRole,
|
||||
} from '@tria-plc/iamapi-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const SEED_FLAG = 'SEED_PASSENGER_STAFF';
|
||||
const EDR_ORG_KEY = 'edr';
|
||||
|
||||
const STAFF_USERS = [
|
||||
{ email: 'passenger.admin@edr.local', username: 'passenger_admin', roleKey: 'edr_passenger_backoffice_admin' },
|
||||
{ email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' },
|
||||
{ email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' },
|
||||
{ email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class PassengerStaffUsersSeeder {
|
||||
private readonly logger = new Logger(PassengerStaffUsersSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping passenger staff seed because ${SEED_FLAG} is not enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const organization = await manager.getRepository(Organization).findOne({
|
||||
where: { key: EDR_ORG_KEY },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!organization) throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
for (const staff of STAFF_USERS) {
|
||||
const role = await manager.getRepository(Role).findOne({
|
||||
where: { key: staff.roleKey },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
if (!role) throw new Error(`missing_role:${staff.roleKey}`);
|
||||
|
||||
let user = await manager.getRepository(User).findOne({
|
||||
where: { email: staff.email },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await manager.getRepository(User).save(
|
||||
manager.getRepository(User).create({
|
||||
email: staff.email,
|
||||
username: staff.username,
|
||||
name: { en: staff.username },
|
||||
isActive: true,
|
||||
hasSetPassword: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Seeded passenger staff user ${staff.email}`);
|
||||
}
|
||||
|
||||
const credentialExists = await manager.getRepository(UserCredential).exists({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
if (!credentialExists) {
|
||||
await manager.getRepository(UserCredential).insert({
|
||||
userId: user.id,
|
||||
password: hashedPassword,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
await manager.getRepository(UserRole).upsert(
|
||||
{ userId: user.id, roleId: role.id, organizationId: organization.id },
|
||||
{ conflictPaths: { userId: true, roleId: true } },
|
||||
);
|
||||
|
||||
const employeeExists = await manager.getRepository(Employee).exists({
|
||||
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
|
||||
});
|
||||
if (!employeeExists) {
|
||||
await manager.getRepository(Employee).insert({
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
isCurrent: true,
|
||||
name: { en: staff.username },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Ensured passenger staff users');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
|
||||
@@ -2,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
22066
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user