mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 18:23:40 +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
|
NODE_ENV=development
|
||||||
PORT=4000
|
PORT=4000
|
||||||
|
|
||||||
# Database (Prisma)
|
# Database (Prisma) — owns the `passenger` schema in edr_database
|
||||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
|
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
|
||||||
|
|
||||||
|
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
|
||||||
|
# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource.
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_NAME=edr_database
|
||||||
|
DATABASE_USER=edr
|
||||||
|
DATABASE_PASSWORD=edr_secret
|
||||||
|
DATABASE_SCHEMA=iam
|
||||||
|
|
||||||
|
# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications).
|
||||||
|
# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev.
|
||||||
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
|
|
||||||
|
# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these).
|
||||||
|
# Placeholders for dev; only contacted when file upload/download features are actually used.
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=edr-dev
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
FRONTEND_URL=http://localhost:5174
|
FRONTEND_URL=http://localhost:5174
|
||||||
BACK_OFFICE_URL=http://localhost:5184
|
BACK_OFFICE_URL=http://localhost:5184
|
||||||
|
|
||||||
# JWT
|
# JWT (legacy passenger auth — being replaced by IAM)
|
||||||
JWT_SECRET=edr-platform-secret-change-in-production
|
JWT_SECRET=edr-platform-secret-change-in-production
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
|
||||||
|
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
|
||||||
|
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||||
|
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||||
|
|
||||||
# SendGrid
|
# SendGrid
|
||||||
SENDGRID_API_KEY=
|
SENDGRID_API_KEY=
|
||||||
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
|
||||||
|
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate deploy",
|
"prisma:migrate": "prisma migrate deploy",
|
||||||
"prisma:migrate:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
@@ -27,26 +29,30 @@
|
|||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.19",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/event-emitter": "^2.0.4",
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
|
||||||
"@nestjs/microservices": "^11.1.24",
|
"@nestjs/microservices": "^11.1.24",
|
||||||
"@nestjs/passport": "^10.0.3",
|
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@sendgrid/mail": "^8.1.0",
|
||||||
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||||
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||||
|
"amqp-connection-manager": "^5.0.0",
|
||||||
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"passport": "^0.7.0",
|
"pg": "^8.21.0",
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typeorm": "^0.3.30",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,11 +61,9 @@
|
|||||||
"@nestjs/cli": "^11.0.21",
|
"@nestjs/cli": "^11.0.21",
|
||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.19",
|
"@nestjs/testing": "^11.1.19",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
|
|||||||
@@ -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?
|
faydaVerifiedAt DateTime?
|
||||||
faydaSub String? @unique
|
faydaSub String? @unique
|
||||||
|
|
||||||
passenger Passenger?
|
sessions Session[]
|
||||||
agent Agent?
|
|
||||||
sessions Session[]
|
|
||||||
devices Device[]
|
|
||||||
preferences UserPreferences?
|
|
||||||
auditLogs AuditLog[]
|
|
||||||
fraudAlerts FraudAlert[]
|
|
||||||
|
|
||||||
faydaVerificationSessions FaydaVerificationSession[]
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +279,12 @@ model Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Passenger {
|
model Passenger {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String? @unique
|
||||||
|
iamUserId String? @unique
|
||||||
defaultTravelerProfileId String?
|
defaultTravelerProfileId String?
|
||||||
preferredLanguage String?
|
preferredLanguage String?
|
||||||
|
blockedUntil DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
@@ -298,8 +293,9 @@ model Passenger {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
travelerProfiles TravelerProfile[]
|
travelerProfiles TravelerProfile[]
|
||||||
savedRoutes SavedRoute[]
|
savedRoutes SavedRoute[]
|
||||||
|
packageBookings PackageBooking[]
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([iamUserId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +372,8 @@ model TrainSchedule {
|
|||||||
liveStatus TripLiveStatus?
|
liveStatus TripLiveStatus?
|
||||||
menuItems MenuItem[]
|
menuItems MenuItem[]
|
||||||
journeySegments JourneySegment[]
|
journeySegments JourneySegment[]
|
||||||
|
outboundPackages TravelPackage[] @relation("PackageOutbound")
|
||||||
|
returnPackages TravelPackage[] @relation("PackageReturn")
|
||||||
|
|
||||||
@@index([departureAt, originStationId])
|
@@index([departureAt, originStationId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -895,7 +893,7 @@ model SupportMessage {
|
|||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
iamUserId String @unique
|
||||||
pushEnabled Boolean @default(true)
|
pushEnabled Boolean @default(true)
|
||||||
emailEnabled Boolean @default(true)
|
emailEnabled Boolean @default(true)
|
||||||
smsEnabled Boolean @default(false)
|
smsEnabled Boolean @default(false)
|
||||||
@@ -908,19 +906,19 @@ model UserPreferences {
|
|||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
darkMode Boolean @default(false)
|
darkMode Boolean @default(false)
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
iamUserId String
|
||||||
platform DevicePlatform
|
platform DevicePlatform
|
||||||
name String
|
name String
|
||||||
pushToken String?
|
pushToken String?
|
||||||
trusted Boolean @default(false)
|
trusted Boolean @default(false)
|
||||||
lastSeenAt DateTime @default(now())
|
lastSeenAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,16 +1061,16 @@ model SegmentFareRule {
|
|||||||
|
|
||||||
model Agent {
|
model Agent {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
iamUserId String? @unique
|
||||||
agentCode String @unique
|
agentCode String @unique
|
||||||
stationId String?
|
stationId String?
|
||||||
commissionRate Int @default(5)
|
commissionRate Int @default(5)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
bookings AgentBooking[]
|
bookings AgentBooking[]
|
||||||
shifts AgentShift[]
|
shifts AgentShift[]
|
||||||
commissions AgentCommission[]
|
commissions AgentCommission[]
|
||||||
|
@@index([iamUserId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,19 +1189,17 @@ model BaggageBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String?
|
iamUserId String?
|
||||||
action String
|
action String
|
||||||
entityType String
|
entityType String
|
||||||
entityId String?
|
entityId String?
|
||||||
oldData Json?
|
oldData Json?
|
||||||
newData Json?
|
newData Json?
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User? @relation(fields: [userId], references: [id])
|
@@index([iamUserId, createdAt])
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([entityType, entityId])
|
@@index([entityType, entityId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -1259,16 +1255,14 @@ model FraudRule {
|
|||||||
|
|
||||||
model FraudAlert {
|
model FraudAlert {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
iamUserId String
|
||||||
eventType String
|
eventType String
|
||||||
triggeredRules String[]
|
triggeredRules String[]
|
||||||
context Json
|
context Json
|
||||||
severity String @default("MEDIUM")
|
severity String @default("MEDIUM")
|
||||||
acknowledged Boolean @default(false)
|
acknowledged Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
@@index([iamUserId, createdAt])
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([acknowledged])
|
@@index([acknowledged])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -1338,14 +1332,144 @@ model FaydaVerificationSession {
|
|||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
|
||||||
userId String?
|
iamUserId String?
|
||||||
bookingId String?
|
bookingId String?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
@@index([iamUserId])
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([bookingId])
|
@@index([bookingId])
|
||||||
@@index([state])
|
@@index([state])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PackageStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
SOLD_OUT
|
||||||
|
EXPIRED
|
||||||
|
CANCELLED
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TravelPackage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
status PackageStatus @default(DRAFT)
|
||||||
|
outboundScheduleId String
|
||||||
|
returnScheduleId String
|
||||||
|
originStationId String
|
||||||
|
destinationStationId String
|
||||||
|
boardingTime DateTime
|
||||||
|
departureTime DateTime
|
||||||
|
arrivalTime DateTime
|
||||||
|
totalCapacity Int
|
||||||
|
bookedCount Int @default(0)
|
||||||
|
includedServices Json
|
||||||
|
coachConfiguration String?
|
||||||
|
busTransferIncluded Boolean @default(false)
|
||||||
|
busTransferRoute String?
|
||||||
|
validFrom DateTime
|
||||||
|
validUntil DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
|
||||||
|
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||||
|
priceTiers PackagePriceTier[]
|
||||||
|
bookings PackageBooking[]
|
||||||
|
|
||||||
|
@@index([status, validFrom])
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PackagePriceTier {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
packageId String
|
||||||
|
seatType String
|
||||||
|
label String
|
||||||
|
priceMinor Int
|
||||||
|
currency String @default("ETB")
|
||||||
|
availableSeats Int @default(0)
|
||||||
|
bookedSeats Int @default(0)
|
||||||
|
|
||||||
|
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||||
|
bookings PackageBooking[]
|
||||||
|
|
||||||
|
@@unique([packageId, seatType])
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PackageBooking {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bookingRef String @unique
|
||||||
|
packageId String
|
||||||
|
priceTierId String
|
||||||
|
passengerId String?
|
||||||
|
contactEmail String?
|
||||||
|
contactPhone String?
|
||||||
|
status BookingStatus @default(PENDING_PAYMENT)
|
||||||
|
passengerCount Int @default(1)
|
||||||
|
totalMinor Int
|
||||||
|
currency String @default("ETB")
|
||||||
|
displayCurrency Currency?
|
||||||
|
displayTotalMinor Int?
|
||||||
|
promoCode String?
|
||||||
|
source String @default("WEB")
|
||||||
|
paidAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||||
|
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||||
|
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||||
|
passengers PackageBookingPassenger[]
|
||||||
|
paymentIntent PackagePaymentIntent?
|
||||||
|
|
||||||
|
@@index([packageId, status])
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PackageBookingPassenger {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bookingId String
|
||||||
|
passengerName String
|
||||||
|
dateOfBirth DateTime?
|
||||||
|
idDocumentType IdDocumentType?
|
||||||
|
idDocumentNumber String?
|
||||||
|
passportNumber String?
|
||||||
|
passportCountry String?
|
||||||
|
seatLabel String?
|
||||||
|
|
||||||
|
booking PackageBooking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PackagePaymentIntent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
packageBookingId String @unique
|
||||||
|
amountMinor Int
|
||||||
|
currency String @default("ETB")
|
||||||
|
method PaymentMethodType
|
||||||
|
status PaymentIntentStatus @default(REQUIRES_ACTION)
|
||||||
|
providerRef String?
|
||||||
|
paidAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
packageBooking PackageBooking @relation(fields: [packageBookingId], references: [id])
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|||||||
@@ -670,6 +670,70 @@ async function seedFraudRules() {
|
|||||||
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedKulubbiPackage() {
|
||||||
|
console.log('\n🚆 Seeding Kulubbi Gabriel 2025 package...');
|
||||||
|
|
||||||
|
const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } });
|
||||||
|
const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } });
|
||||||
|
if (!addisStation || !direDawaStation) {
|
||||||
|
console.log(' ⚠️ Stations not found, skipping Kulubbi package seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first two schedules as outbound/return (or create dedicated ones)
|
||||||
|
const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } });
|
||||||
|
if (schedules.length < 2) {
|
||||||
|
console.log(' ⚠️ Not enough schedules found, skipping Kulubbi package seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [outboundSchedule, returnSchedule] = schedules;
|
||||||
|
|
||||||
|
await prisma.travelPackage.upsert({
|
||||||
|
where: { code: 'KULUBBI-2025' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'KULUBBI-2025',
|
||||||
|
name: 'Kulubbi Gabriel Pilgrimage Package',
|
||||||
|
description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.',
|
||||||
|
outboundScheduleId: outboundSchedule.id,
|
||||||
|
returnScheduleId: returnSchedule.id,
|
||||||
|
originStationId: addisStation.id,
|
||||||
|
destinationStationId: direDawaStation.id,
|
||||||
|
boardingTime: new Date('2025-07-24T07:00:00+03:00'),
|
||||||
|
departureTime: new Date('2025-07-24T09:00:00+03:00'),
|
||||||
|
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
|
||||||
|
totalCapacity: 912,
|
||||||
|
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
|
||||||
|
busTransferIncluded: true,
|
||||||
|
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
|
||||||
|
validFrom: new Date('2025-07-01'),
|
||||||
|
validUntil: new Date('2025-07-24T09:00:00+03:00'),
|
||||||
|
status: 'ACTIVE',
|
||||||
|
includedServices: [
|
||||||
|
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',
|
||||||
|
'Lunch served on board',
|
||||||
|
'Refreshments and bottled water',
|
||||||
|
'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)',
|
||||||
|
'Onboard first aid and medical support',
|
||||||
|
'Entertainment (audio/video)',
|
||||||
|
'Service briefing and pilgrimage guidance',
|
||||||
|
'Pick-up and drop-off coordination',
|
||||||
|
],
|
||||||
|
priceTiers: {
|
||||||
|
create: [
|
||||||
|
{ seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 },
|
||||||
|
{ seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 },
|
||||||
|
{ seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 },
|
||||||
|
{ seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 },
|
||||||
|
{ seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 },
|
||||||
|
{ seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(' ✅ Kulubbi Gabriel 2025 package created');
|
||||||
|
}
|
||||||
|
|
||||||
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
|
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
|
||||||
// missing record, etc.) log the error and keep going so the rest of the seed —
|
// missing record, etc.) log the error and keep going so the rest of the seed —
|
||||||
// and the API startup that follows it — are never blocked by one bad step.
|
// and the API startup that follows it — are never blocked by one bad step.
|
||||||
@@ -691,7 +755,8 @@ async function main() {
|
|||||||
['fare rules', seedFareRules],
|
['fare rules', seedFareRules],
|
||||||
['segment fares', seedSegmentFares],
|
['segment fares', seedSegmentFares],
|
||||||
['currency', seedCurrency],
|
['currency', seedCurrency],
|
||||||
['notification templates', seedNotificationTemplates]
|
['notification templates', seedNotificationTemplates],
|
||||||
|
['kulubbi package', seedKulubbiPackage],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
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 {
|
||||||
import { ConfigModule } from '@nestjs/config';
|
MiddlewareConsumer,
|
||||||
|
Module,
|
||||||
|
NestModule,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
|
||||||
|
import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder';
|
||||||
|
import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module';
|
||||||
|
import {
|
||||||
|
EDR_PASSENGER_APPLICATION,
|
||||||
|
EDR_PASSENGER_PERMISSIONS,
|
||||||
|
} from './seed/edr-passenger.seed';
|
||||||
|
import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder';
|
||||||
|
import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder';
|
||||||
import { PrismaModule } from './common/prisma.module';
|
import { PrismaModule } from './common/prisma.module';
|
||||||
import { AuditModule } from './common/audit.module';
|
import { AuditModule } from './common/audit.module';
|
||||||
import { I18nModule } from './common/i18n/i18n.module';
|
import { I18nModule } from './common/i18n/i18n.module';
|
||||||
import { IamModule } from './common/iam.module';
|
|
||||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||||
import appConfig from './config/app.config';
|
import appConfig from './config/app.config';
|
||||||
import dbConfig from './config/database.config';
|
import dbConfig from './config/database.config';
|
||||||
|
import iamDatabaseConfig from './config/iam-database.config';
|
||||||
import telebirrConfig from './config/telebirr.config';
|
import telebirrConfig from './config/telebirr.config';
|
||||||
import cbeConfig from './config/cbe.config';
|
import cbeConfig from './config/cbe.config';
|
||||||
import ebirrConfig from './config/ebirr.config';
|
import ebirrConfig from './config/ebirr.config';
|
||||||
@@ -42,6 +57,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
|||||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
import { AuditModuleFeature } from './modules/audit/audit.module';
|
import { AuditModuleFeature } from './modules/audit/audit.module';
|
||||||
import { CurrenciesModule } from './modules/currencies/currencies.module';
|
import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||||
|
import { SystemConfigModule } from './modules/system-config/system-config.module';
|
||||||
|
import { PackagesModule } from './modules/packages/packages.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -50,6 +67,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
|||||||
load: [
|
load: [
|
||||||
appConfig,
|
appConfig,
|
||||||
dbConfig,
|
dbConfig,
|
||||||
|
iamDatabaseConfig,
|
||||||
telebirrConfig,
|
telebirrConfig,
|
||||||
cbeConfig,
|
cbeConfig,
|
||||||
ebirrConfig,
|
ebirrConfig,
|
||||||
@@ -61,11 +79,20 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
|||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
|
config.get<TypeOrmModuleOptions>('iamDatabase')!,
|
||||||
|
}),
|
||||||
|
TriaIamModule.forRoot({
|
||||||
|
applications: [EDR_PASSENGER_APPLICATION],
|
||||||
|
permissions: EDR_PASSENGER_PERMISSIONS,
|
||||||
|
}),
|
||||||
|
SharedAuthModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
I18nModule,
|
I18nModule,
|
||||||
IamModule,
|
AuthModule,
|
||||||
AuthModule,
|
|
||||||
StationsModule,
|
StationsModule,
|
||||||
FleetModule,
|
FleetModule,
|
||||||
SchedulesModule,
|
SchedulesModule,
|
||||||
@@ -91,10 +118,28 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
|||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
AuditModuleFeature,
|
AuditModuleFeature,
|
||||||
CurrenciesModule,
|
CurrenciesModule,
|
||||||
|
SystemConfigModule,
|
||||||
|
PackagesModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
EdrPassengerOrgSeeder,
|
||||||
|
PassengerStaffUsersSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
constructor(
|
||||||
consumer.apply(LocaleMiddleware).forRoutes('*');
|
private readonly seeder: DataSeeder,
|
||||||
|
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||||
|
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
try {
|
||||||
|
await this.seeder.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||||
|
}
|
||||||
|
await this.edrPassengerOrgSeeder.run();
|
||||||
|
await this.passengerStaffUsersSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class AuditService {
|
|||||||
|
|
||||||
await this.prisma.auditLog.create({
|
await this.prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: input.userId,
|
iamUserId: input.userId,
|
||||||
action: input.action,
|
action: input.action,
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
@@ -62,8 +62,7 @@ export class AuditService {
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
{ iamUserId: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +76,12 @@ export class AuditService {
|
|||||||
|
|
||||||
return this.prisma.auditLog.findMany({
|
return this.prisma.auditLog.findMany({
|
||||||
where,
|
where,
|
||||||
include: { user: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 500, // Limit to last 500 logs
|
take: 500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLog(id: string) {
|
async getLog(id: string) {
|
||||||
return this.prisma.auditLog.findUnique({
|
return this.prisma.auditLog.findUnique({ where: { id } });
|
||||||
where: { id },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { HttpService } from '@nestjs/axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IAM Adapter for @tria-plc corporate identity integration
|
|
||||||
*
|
|
||||||
* This adapter wraps the corporate IAM guards and provides a bridge
|
|
||||||
* between the corporate identity system and the EDR passenger API.
|
|
||||||
*
|
|
||||||
* For back-office roles (agent, supervisor, admin, staff), this guard
|
|
||||||
* validates tokens against the corporate IAM service.
|
|
||||||
*
|
|
||||||
* For passenger-facing routes, the existing JWT guard is used.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface IamTokenPayload {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
roles: string[];
|
|
||||||
permissions: string[];
|
|
||||||
organizationId?: string;
|
|
||||||
exp: number;
|
|
||||||
iat: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IamValidationResponse {
|
|
||||||
valid: boolean;
|
|
||||||
payload?: IamTokenPayload;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IamGuard implements CanActivate {
|
|
||||||
private readonly iamApiUrl: string;
|
|
||||||
private readonly iamEnabled: boolean;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly http: HttpService,
|
|
||||||
) {
|
|
||||||
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
|
|
||||||
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
if (!this.iamEnabled) {
|
|
||||||
// IAM disabled - allow access (for development)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const token = this.extractToken(request);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('No authentication token provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = await this.validateToken(token);
|
|
||||||
|
|
||||||
if (!validation.valid || !validation.payload) {
|
|
||||||
throw new UnauthorizedException(validation.error || 'Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check required roles
|
|
||||||
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
|
|
||||||
if (requiredRoles && requiredRoles.length > 0) {
|
|
||||||
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
|
|
||||||
if (!hasRole) {
|
|
||||||
throw new ForbiddenException('Insufficient permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach user to request
|
|
||||||
request.user = {
|
|
||||||
userId: validation.payload.sub,
|
|
||||||
email: validation.payload.email,
|
|
||||||
roles: validation.payload.roles,
|
|
||||||
permissions: validation.payload.permissions,
|
|
||||||
organizationId: validation.payload.organizationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractToken(request: any): string | null {
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
if (!authHeader) return null;
|
|
||||||
|
|
||||||
const parts = authHeader.split(' ');
|
|
||||||
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
|
|
||||||
|
|
||||||
return parts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateToken(token: string): Promise<IamValidationResponse> {
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.http.post<IamValidationResponse>(
|
|
||||||
`${this.iamApiUrl}/v1/auth/validate`,
|
|
||||||
{ token },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
|
|
||||||
},
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Token validation failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to mark routes as requiring IAM authentication
|
|
||||||
*/
|
|
||||||
export const UseIamAuth = () => {
|
|
||||||
// This is a marker decorator that can be used with @UseGuards(IamGuard)
|
|
||||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
|
||||||
// Marker only - actual guard is applied via @UseGuards
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to specify required roles for IAM-protected routes
|
|
||||||
*/
|
|
||||||
export const IamRoles = (...roles: string[]) => {
|
|
||||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
|
||||||
if (descriptor) {
|
|
||||||
Reflect.defineMetadata('roles', roles, descriptor.value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
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 { Observable } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { PrismaService } from '../prisma.service';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
|||||||
private readonly inactivityMinutes: number;
|
private readonly inactivityMinutes: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
||||||
@@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
|||||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const response = context.switchToHttp().getResponse();
|
const response = context.switchToHttp().getResponse();
|
||||||
const user = request.user;
|
const sessionId: string | undefined = request.user?.sessionId;
|
||||||
|
|
||||||
if (user?.userId) {
|
if (sessionId) {
|
||||||
const session = await this.prisma.session.findFirst({
|
const rows = await this.dataSource.query<Array<{ expiry_time: Date }>>(
|
||||||
where: { userId: user.userId },
|
`SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`,
|
||||||
orderBy: { lastActivityAt: 'desc' },
|
[sessionId],
|
||||||
});
|
);
|
||||||
|
|
||||||
if (session) {
|
if (rows.length) {
|
||||||
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
|
const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000;
|
||||||
|
if (minutesLeft < this.inactivityMinutes * 0.2) {
|
||||||
if (inactiveMinutes > this.inactivityMinutes) {
|
response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString());
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
|
||||||
throw new UnauthorizedException('Session expired due to inactivity');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
|
// Extend session on every authenticated request
|
||||||
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`,
|
||||||
await this.prisma.session.update({
|
[this.inactivityMinutes, sessionId],
|
||||||
where: { id: session.id },
|
);
|
||||||
data: { lastActivityAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
// Compatibility alias while passenger auth moves to @tria-plc IAM.
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
// Existing controllers can keep importing `../../common/jwt.guard`, but the
|
||||||
|
// guard now validates IAM-issued session tokens from `iam.sessions`.
|
||||||
@Injectable()
|
export { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
export class JwtGuard extends AuthGuard('jwt') {}
|
|
||||||
|
|||||||
@@ -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 { SetMetadata } from '@nestjs/common';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
export const ROLES_KEY = 'roles';
|
||||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { ROLES_KEY } from './roles.decorator';
|
import { ROLES_KEY } from './roles.decorator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate {
|
|||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (!requiredRoles) return true;
|
if (!requiredRoles) return true;
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const { user } = context.switchToHttp().getRequest();
|
||||||
return requiredRoles.some((role) => user?.role === role);
|
// Support IAM roles array [{key, id}][] and legacy role string
|
||||||
|
return requiredRoles.some(
|
||||||
|
(role) => user?.roles?.some((r: { key: string }) => r.key === role) || user?.role === role,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 "reflect-metadata";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { ValidationPipe } from "@nestjs/common";
|
import { ValidationPipe, VersioningType } from "@nestjs/common";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||||
@@ -12,6 +16,11 @@ async function bootstrap() {
|
|||||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
|
|
||||||
|
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||||
|
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||||
|
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||||
|
app.enableVersioning({ type: VersioningType.URI });
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: [
|
origin: [
|
||||||
process.env.PORTAL_URL ?? "http://localhost:5174",
|
process.env.PORTAL_URL ?? "http://localhost:5174",
|
||||||
|
|||||||
@@ -2,39 +2,37 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AgentsService } from './agents.service';
|
import { AgentsService } from './agents.service';
|
||||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
|
||||||
import { UserRole } from '@prisma/client';
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
|
||||||
@ApiTags('Agents')
|
@ApiTags('Agents')
|
||||||
@Controller('agents')
|
@Controller('agents')
|
||||||
@UseGuards(IamGuard)
|
// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
|
||||||
|
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
|
||||||
|
@UseGuards(IamJwtGuard)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
export class AgentsController {
|
export class AgentsController {
|
||||||
constructor(private service: AgentsService) {}
|
constructor(private service: AgentsService) {}
|
||||||
|
|
||||||
@Post('bookings')
|
@Post('bookings')
|
||||||
@IamRoles('AGENT', 'ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||||
return this.service.createAgentBooking(dto);
|
return this.service.createAgentBooking(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('shifts/open')
|
@Post('shifts/open')
|
||||||
@IamRoles('AGENT', 'ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Open agent shift' })
|
@ApiOperation({ summary: 'Open agent shift' })
|
||||||
openShift(@Body() dto: OpenShiftDto) {
|
openShift(@Body() dto: OpenShiftDto) {
|
||||||
return this.service.openShift(dto);
|
return this.service.openShift(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('shifts/close')
|
@Post('shifts/close')
|
||||||
@IamRoles('AGENT', 'ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Close agent shift' })
|
@ApiOperation({ summary: 'Close agent shift' })
|
||||||
closeShift(@Body() dto: CloseShiftDto) {
|
closeShift(@Body() dto: CloseShiftDto) {
|
||||||
return this.service.closeShift(dto);
|
return this.service.closeShift(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':agentId/commissions')
|
@Get(':agentId/commissions')
|
||||||
@IamRoles('AGENT', 'ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Get agent commissions' })
|
@ApiOperation({ summary: 'Get agent commissions' })
|
||||||
getCommissions(
|
getCommissions(
|
||||||
@Param('agentId') agentId: string,
|
@Param('agentId') agentId: string,
|
||||||
@@ -49,7 +47,6 @@ export class AgentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':agentId/shifts')
|
@Get(':agentId/shifts')
|
||||||
@IamRoles('AGENT', 'ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Get agent shifts' })
|
@ApiOperation({ summary: 'Get agent shifts' })
|
||||||
getShifts(@Param('agentId') agentId: string) {
|
getShifts(@Param('agentId') agentId: string) {
|
||||||
return this.service.getShifts(agentId);
|
return this.service.getShifts(agentId);
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export class AgentsService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async createAgentBooking(dto: CreateAgentBookingDto) {
|
async createAgentBooking(dto: CreateAgentBookingDto) {
|
||||||
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
|
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
|
||||||
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
|
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
|
||||||
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
|
|
||||||
|
const passenger = agent.iamUserId
|
||||||
|
? await this.prisma.passenger.findUnique({ where: { iamUserId: agent.iamUserId } })
|
||||||
|
: null;
|
||||||
|
if (!passenger) throw new BadRequestException('Agent must have a linked passenger account');
|
||||||
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
@@ -30,7 +34,7 @@ export class AgentsService {
|
|||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: agent.user.passenger.id,
|
passengerId: passenger.id,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
|
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { IamGuard } from '../../common/iam-adapter';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Audit')
|
@ApiTags('Audit')
|
||||||
@Controller('audit')
|
@Controller('audit')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
export class AuditController {
|
export class AuditController {
|
||||||
constructor(private auditService: AuditService) {}
|
constructor(private auditService: AuditService) {}
|
||||||
|
|||||||
@@ -1,303 +1,69 @@
|
|||||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
|
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
import { PassengerAuthService } from './passenger-auth.service';
|
||||||
|
import { RegisterDto, LoginDto } from './auth.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { RolesGuard } from '../../common/roles.guard';
|
|
||||||
import { Roles } from '../../common/roles.decorator';
|
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private service: AuthService) {}
|
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({
|
@IsPublic()
|
||||||
summary: 'Register new passenger account',
|
@ApiOperation({ summary: 'Register new passenger account' })
|
||||||
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
|
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
|
||||||
})
|
|
||||||
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
|
|
||||||
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
|
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
|
||||||
@ApiBody({ type: RegisterDto })
|
@ApiBody({ type: RegisterDto })
|
||||||
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
|
register(@Request() req: any, @Body() dto: RegisterDto) {
|
||||||
|
return this.passengerAuthService.register(dto, req);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@IsPublic()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
summary: 'Login with email and password',
|
@ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' })
|
||||||
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
|
|
||||||
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
|
|
||||||
@ApiBody({ type: LoginDto })
|
@ApiBody({ type: LoginDto })
|
||||||
login(@Body() dto: LoginDto) { return this.service.login(dto); }
|
login(@Request() req: any, @Body() dto: LoginDto) {
|
||||||
|
return this.passengerAuthService.login(dto, req);
|
||||||
@Post('otp/request')
|
}
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Request OTP verification code',
|
|
||||||
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
|
|
||||||
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
|
|
||||||
@ApiBody({ type: RequestOtpDto })
|
|
||||||
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
|
|
||||||
|
|
||||||
@Post('otp/verify')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Verify OTP code',
|
|
||||||
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
|
||||||
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
|
|
||||||
@ApiBody({ type: VerifyOtpDto })
|
|
||||||
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
|
|
||||||
|
|
||||||
@Post('password/reset-request')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Request password reset link',
|
|
||||||
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Email not found' })
|
|
||||||
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
|
|
||||||
@ApiBody({ type: RequestPasswordResetDto })
|
|
||||||
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
|
|
||||||
|
|
||||||
@Post('password/reset')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Reset password with token',
|
|
||||||
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
|
|
||||||
@ApiResponse({ status: 404, description: 'User not found' })
|
|
||||||
@ApiBody({ type: ResetPasswordDto })
|
|
||||||
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
|
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Logout current user' })
|
||||||
summary: 'Logout current user',
|
@ApiResponse({ status: 200, description: 'Logout successful' })
|
||||||
description: `Logout the authenticated user and invalidate their session.
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
logout(@Request() req: any) {
|
||||||
|
if (!req.user?.id) throw new UnauthorizedException('User not authenticated');
|
||||||
|
return this.passengerAuthService.logout(req.user, req);
|
||||||
|
}
|
||||||
|
|
||||||
### What happens:
|
@Get('me')
|
||||||
- Invalidates the current session token
|
@UseGuards(JwtGuard)
|
||||||
- Records logout in audit log
|
@ApiBearerAuth('JWT-auth')
|
||||||
- Frontend should clear stored token and redirect to home
|
@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' })
|
||||||
### Authentication:
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
- **Required**: JWT Bearer Token
|
getMe(@Request() req: any) {
|
||||||
- Token will be invalidated after successful logout`
|
return { user: req.user };
|
||||||
})
|
|
||||||
@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('profile')
|
@Get('profile')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
summary: 'Get current user profile',
|
@ApiResponse({ status: 200, description: 'User profile retrieved successfully' })
|
||||||
description: `**Returns complete user profile with all connected data**
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
getProfile(@Request() req: any) {
|
||||||
---
|
const userId = req.user?.id;
|
||||||
|
if (!userId) throw new UnauthorizedException('User not authenticated');
|
||||||
### Response Includes
|
return this.passengerAuthService.getProfile(userId);
|
||||||
|
|
||||||
#### 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users')
|
// TODO: admin user management endpoints — implement when admin module is ready
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
|
|
||||||
getUsers(
|
|
||||||
@Query('search') search?: string,
|
|
||||||
@Query('role') role?: string,
|
|
||||||
@Query('status') status?: string,
|
|
||||||
@Query('page') page?: string,
|
|
||||||
@Query('pageSize') pageSize?: string,
|
|
||||||
) {
|
|
||||||
return this.service.getUsers({
|
|
||||||
search,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
page: page ? parseInt(page) : 1,
|
|
||||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('users')
|
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
|
|
||||||
createUser(@Body() dto: any) {
|
|
||||||
return this.service.createUser(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('users/:id')
|
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
|
|
||||||
updateUser(@Param('id') id: string, @Body() dto: any) {
|
|
||||||
return this.service.updateUser(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('users/:id')
|
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.ADMIN)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
|
|
||||||
deleteUser(@Param('id') id: string) {
|
|
||||||
return this.service.deleteUser(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('users/:id/reset-password')
|
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
|
|
||||||
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
|
|
||||||
return this.service.resetUserPassword(id, dto.tempPassword);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,152 +1,51 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class NameDto {
|
||||||
|
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||||
|
@IsString()
|
||||||
|
am: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Kelemu Ketsela' })
|
||||||
|
@IsString()
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'kelemu@email.com' })
|
||||||
description: 'Full name of the passenger',
|
@IsEmail()
|
||||||
example: 'Kelemu Ketsela',
|
|
||||||
minLength: 2,
|
|
||||||
maxLength: 100
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
fullName: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Email address (must be unique)',
|
|
||||||
example: 'kelemu@email.com',
|
|
||||||
format: 'email'
|
|
||||||
})
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'kelemu.ketsela' })
|
||||||
description: 'Phone number with country code',
|
@IsString()
|
||||||
example: '+251912345678',
|
username: string;
|
||||||
pattern: '^\\+[1-9]\\d{1,14}$'
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
phone: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ example: '+251912345678' })
|
||||||
description: 'Password (minimum 8 characters)',
|
@IsString()
|
||||||
example: 'SecurePass123',
|
phoneNumber: string;
|
||||||
minLength: 8,
|
|
||||||
format: 'password'
|
@ApiProperty({ type: NameDto })
|
||||||
})
|
@ValidateNested()
|
||||||
@IsString()
|
@Type(() => NameDto)
|
||||||
@MinLength(8)
|
name: NameDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiProperty({ example: 'SecurePass123', format: 'password' })
|
||||||
description: 'Nationality of the passenger',
|
@IsString()
|
||||||
example: 'Ethiopian'
|
confirmPassword: string;
|
||||||
})
|
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'kelemu@email.com' })
|
||||||
description: 'Registered email address',
|
@IsEmail()
|
||||||
example: 'kelemu@email.com',
|
|
||||||
format: 'email'
|
|
||||||
})
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'password123', format: 'password' })
|
||||||
description: 'Account password',
|
@IsString()
|
||||||
example: 'password123',
|
|
||||||
format: 'password'
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RequestOtpDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Email address to send OTP',
|
|
||||||
example: 'kelemu@email.com'
|
|
||||||
})
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
|
|
||||||
example: 'REGISTRATION',
|
|
||||||
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
purpose: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VerifyOtpDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Email address',
|
|
||||||
example: 'kelemu@email.com'
|
|
||||||
})
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '6-digit OTP code',
|
|
||||||
example: '123456',
|
|
||||||
minLength: 6,
|
|
||||||
maxLength: 6
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
code: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Purpose of OTP verification',
|
|
||||||
example: 'REGISTRATION',
|
|
||||||
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
purpose: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestPasswordResetDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Email address of the account',
|
|
||||||
example: 'kelemu@email.com'
|
|
||||||
})
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ResetPasswordDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Password reset token received via email',
|
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
token: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'New password (minimum 8 characters)',
|
|
||||||
example: 'NewSecurePass123',
|
|
||||||
minLength: 8,
|
|
||||||
format: 'password'
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { PassengerAuthService } from './passenger-auth.service';
|
||||||
import { JwtStrategy } from '../../common/jwt.strategy';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
|
||||||
PassportModule,
|
|
||||||
JwtModule.registerAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (c: ConfigService) => ({
|
|
||||||
secret: c.get('JWT_SECRET'),
|
|
||||||
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [PassengerAuthService],
|
||||||
exports: [JwtModule],
|
exports: [PassengerAuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -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 { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||||
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
|
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { IamGuard } from '../../common/iam-adapter';
|
|
||||||
|
|
||||||
@ApiTags('Booking')
|
@ApiTags('Booking')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -247,8 +246,8 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
|
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
|
||||||
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
|
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
|
||||||
createGuest(@Body() dto: CreateGuestBookingDto) {
|
createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) {
|
||||||
return this.guestService.createGuestBooking(dto);
|
return this.guestService.createGuestBooking(dto, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('saved-passengers')
|
@Get('saved-passengers')
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { GuestBookingService } from './guest-booking.service';
|
|||||||
import { SeatsModule } from '../seats/seats.module';
|
import { SeatsModule } from '../seats/seats.module';
|
||||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
|
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, GuestBookingService],
|
providers: [BookingsService, GuestBookingService],
|
||||||
exports: [BookingsService, GuestBookingService]
|
exports: [BookingsService, GuestBookingService]
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { SeatsService } from '../seats/seats.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
@@ -33,12 +35,13 @@ interface BookingFilters {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private seatsService: SeatsService,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private eventEmitter: EventEmitter2,
|
private readonly seatsService: SeatsService,
|
||||||
private verifaydaService: VerifaydaService,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private currencyService: CurrencyService,
|
private readonly verifaydaService: VerifaydaService,
|
||||||
private fareEngine: FareEngineService,
|
private readonly currencyService: CurrencyService,
|
||||||
|
private readonly fareEngine: FareEngineService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||||
@@ -111,22 +114,22 @@ export class BookingsService {
|
|||||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
// Find user with this device ID
|
// Find passenger linked to this device via iamUserId
|
||||||
const device = await this.prisma.device.findUnique({
|
const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null);
|
||||||
where: { id: deviceId },
|
const passenger = device?.iamUserId
|
||||||
include: { user: { include: { passenger: true } } },
|
? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null)
|
||||||
}).catch(() => null);
|
: null;
|
||||||
|
|
||||||
const searchConditions = search ? [
|
const searchConditions = search ? [
|
||||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
const where: any = {
|
const where: any = {
|
||||||
OR: [
|
OR: [
|
||||||
{ userAgent: deviceId },
|
{ userAgent: deviceId },
|
||||||
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
|
...(passenger ? [{ passengerId: passenger.id }] : []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,13 +196,26 @@ export class BookingsService {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT u.id FROM iam.users u
|
||||||
|
WHERE (u.name->>'en') ILIKE $1 OR (u.name->>'am') ILIKE $1
|
||||||
|
OR u.email ILIKE $1 OR u.phone_number ILIKE $1`,
|
||||||
|
[`%${search}%`],
|
||||||
|
);
|
||||||
|
const matchedPassengers = iamRows.length > 0
|
||||||
|
? await this.prisma.passenger.findMany({
|
||||||
|
where: { iamUserId: { in: iamRows.map(r => r.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||||
{ contactEmail: { contains: search, mode: 'insensitive' } },
|
{ contactEmail: { contains: search, mode: 'insensitive' } },
|
||||||
{ contactPhone: { contains: search, mode: 'insensitive' } },
|
{ contactPhone: { contains: search, mode: 'insensitive' } },
|
||||||
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } },
|
...(matchedPassengers.length > 0
|
||||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
|
||||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
: []),
|
||||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -214,7 +230,7 @@ export class BookingsService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
passenger: { include: { user: true } },
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
@@ -222,34 +238,48 @@ export class BookingsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[];
|
||||||
|
const iamRows = iamUserIds.length > 0
|
||||||
|
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||||
|
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||||
|
[iamUserIds],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(booking => ({
|
items: items.map(booking => {
|
||||||
id: booking.id,
|
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||||
bookingRef: booking.bookingRef,
|
return {
|
||||||
status: booking.status,
|
id: booking.id,
|
||||||
totalMinor: booking.totalMinor,
|
bookingRef: booking.bookingRef,
|
||||||
currency: 'ETB',
|
status: booking.status,
|
||||||
displayCurrency: booking.displayCurrency,
|
totalMinor: booking.totalMinor,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
currency: 'ETB',
|
||||||
contactEmail: booking.contactEmail,
|
displayCurrency: booking.displayCurrency,
|
||||||
contactPhone: booking.contactPhone,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
bookingType: booking.bookingType,
|
contactEmail: booking.contactEmail,
|
||||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
contactPhone: booking.contactPhone,
|
||||||
adultCount: booking.adultCount,
|
bookingType: booking.bookingType,
|
||||||
childCount: booking.childCount,
|
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||||
createdAt: booking.createdAt,
|
adultCount: booking.adultCount,
|
||||||
passenger: booking.passenger?.user,
|
childCount: booking.childCount,
|
||||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
createdAt: booking.createdAt,
|
||||||
schedule: {
|
passenger: iam
|
||||||
train: booking.schedule.train,
|
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||||
originStation: booking.schedule.originStation,
|
: null,
|
||||||
destinationStation: booking.schedule.destinationStation,
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
departureAt: booking.schedule.departureAt,
|
schedule: {
|
||||||
},
|
train: booking.schedule.train,
|
||||||
paymentIntent: booking.paymentIntent,
|
originStation: booking.schedule.originStation,
|
||||||
seatCount: booking.seats.length,
|
destinationStation: booking.schedule.destinationStation,
|
||||||
})),
|
departureAt: booking.schedule.departureAt,
|
||||||
|
},
|
||||||
|
paymentIntent: booking.paymentIntent,
|
||||||
|
seatCount: booking.seats.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
import { SeatsService } from '../seats/seats.service';
|
import { SeatsService } from '../seats/seats.service';
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
@@ -44,18 +44,19 @@ export class GuestBookingService {
|
|||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
private verifaydaService: VerifaydaService,
|
private verifaydaService: VerifaydaService,
|
||||||
private currencyService: CurrencyService,
|
private currencyService: CurrencyService,
|
||||||
|
private passengerAuthService: PassengerAuthService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createGuestBooking(dto: CreateGuestBookingDto) {
|
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto);
|
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
|
||||||
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto);
|
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
|
||||||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto);
|
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
|
||||||
return this.createGuestOneWayBooking(dto);
|
return this.createGuestOneWayBooking(dto, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
|
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
// Validate hold
|
// Validate hold
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||||
if (!hold || hold.expiresAt < new Date()) {
|
if (!hold || hold.expiresAt < new Date()) {
|
||||||
@@ -95,15 +96,12 @@ export class GuestBookingService {
|
|||||||
let verifaydaData: Record<string, any> | undefined;
|
let verifaydaData: Record<string, any> | undefined;
|
||||||
let nationality = passenger.nationality;
|
let nationality = passenger.nationality;
|
||||||
|
|
||||||
// Determine if passenger is Ethiopian
|
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||||||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
|
||||||
passenger.nationality === 'ETHIOPIAN' ||
|
passenger.nationality === 'ETHIOPIAN' ||
|
||||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||||
|
|
||||||
// Ethiopian with National ID
|
|
||||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||||
if (passenger.idDocumentNumber) {
|
if (passenger.idDocumentNumber) {
|
||||||
// Attempt Fayda verification
|
|
||||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||||
if (!verification.verified) {
|
if (!verification.verified) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -115,22 +113,14 @@ export class GuestBookingService {
|
|||||||
verifaydaData = verification.passengerData?.profileData;
|
verifaydaData = verification.passengerData?.profileData;
|
||||||
}
|
}
|
||||||
nationality = 'Ethiopian';
|
nationality = 'Ethiopian';
|
||||||
}
|
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||||
// International passenger with Passport (non-Ethiopian)
|
|
||||||
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
|
||||||
// Passport details are required for international passengers
|
|
||||||
if (!passenger.passportNumber || !passenger.passportCountry) {
|
if (!passenger.passportNumber || !passenger.passportCountry) {
|
||||||
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||||
}
|
}
|
||||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||||
}
|
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||||
// Ethiopian with Passport (manual entry without Fayda)
|
|
||||||
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
|
||||||
// Ethiopians can use passport instead of national ID
|
|
||||||
nationality = 'Ethiopian';
|
nationality = 'Ethiopian';
|
||||||
}
|
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||||
// International with National ID (e.g., Djiboutian national ID)
|
|
||||||
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
|
||||||
nationality = nationality || 'Other';
|
nationality = nationality || 'Other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,16 +169,27 @@ export class GuestBookingService {
|
|||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or get guest passenger
|
// Resolve or create the guest Passenger record
|
||||||
const firstPassenger = passengersData[0];
|
const firstPassenger = passengersData[0];
|
||||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||||
|
|
||||||
// Save passenger details for future use (if requested)
|
// Save passenger details for future use (if requested)
|
||||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||||
for (const passenger of passengersData) {
|
for (const passenger of passengersData) {
|
||||||
// Note: SavedPassengerProfile will be available after migration
|
await this.prisma.savedPassengerProfile.create({
|
||||||
// Temporarily disabled until prisma generate completes
|
data: {
|
||||||
// await this.prisma.savedPassengerProfile.create({ ... });
|
userId: iamUserId ?? undefined,
|
||||||
|
deviceId: dto.deviceId,
|
||||||
|
passengerName: passenger.passengerName,
|
||||||
|
dateOfBirth: passenger.dateOfBirth,
|
||||||
|
idDocumentType: passenger.idDocumentType,
|
||||||
|
passportNumber: passenger.passportNumber,
|
||||||
|
passportCountry: passenger.passportCountry,
|
||||||
|
nationality: passenger.nationality,
|
||||||
|
phone: passenger.phone,
|
||||||
|
email: passenger.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ export class GuestBookingService {
|
|||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassenger.id,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
@@ -237,7 +238,7 @@ export class GuestBookingService {
|
|||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
createdAccount,
|
createdAccount,
|
||||||
userId,
|
iamUserId,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
baseFareMinor,
|
baseFareMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
@@ -257,7 +258,7 @@ export class GuestBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
|
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||||
}
|
}
|
||||||
@@ -374,7 +375,7 @@ export class GuestBookingService {
|
|||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
// Create or resolve guest passenger (same as one-way)
|
// Create or resolve guest passenger (same as one-way)
|
||||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||||
|
|
||||||
// Create booking with outbound seats; return seats confirmed separately
|
// Create booking with outbound seats; return seats confirmed separately
|
||||||
const outboundSeatIds = dto.passengers.map(p => p.seatId);
|
const outboundSeatIds = dto.passengers.map(p => p.seatId);
|
||||||
@@ -383,7 +384,7 @@ export class GuestBookingService {
|
|||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassenger.id,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP',
|
bookingType: 'ROUND_TRIP',
|
||||||
@@ -451,7 +452,7 @@ export class GuestBookingService {
|
|||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
createdAccount,
|
createdAccount,
|
||||||
userId,
|
iamUserId,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
outboundBaseFareMinor: outboundBaseFare,
|
outboundBaseFareMinor: outboundBaseFare,
|
||||||
returnBaseFareMinor: returnBaseFare,
|
returnBaseFareMinor: returnBaseFare,
|
||||||
@@ -470,7 +471,7 @@ export class GuestBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto) {
|
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||||
}
|
}
|
||||||
@@ -571,13 +572,13 @@ export class GuestBookingService {
|
|||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||||
|
|
||||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassenger.id,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'TRANSIT',
|
bookingType: 'TRANSIT',
|
||||||
@@ -643,7 +644,7 @@ export class GuestBookingService {
|
|||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
createdAccount,
|
createdAccount,
|
||||||
userId,
|
iamUserId,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
leg1BaseFareMinor: leg1BaseFare,
|
leg1BaseFareMinor: leg1BaseFare,
|
||||||
leg2BaseFareMinor: leg2BaseFare,
|
leg2BaseFareMinor: leg2BaseFare,
|
||||||
@@ -657,7 +658,7 @@ export class GuestBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) {
|
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||||
@@ -764,7 +765,7 @@ export class GuestBookingService {
|
|||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||||
|
|
||||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||||
seat: { connect: { id: seatId } },
|
seat: { connect: { id: seatId } },
|
||||||
@@ -785,7 +786,7 @@ export class GuestBookingService {
|
|||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassenger.id,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||||
@@ -832,7 +833,7 @@ export class GuestBookingService {
|
|||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
createdAccount,
|
createdAccount,
|
||||||
userId,
|
iamUserId,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
outboundLeg1FareMinor: obL1Fare,
|
outboundLeg1FareMinor: obL1Fare,
|
||||||
outboundLeg2FareMinor: obL2Fare,
|
outboundLeg2FareMinor: obL2Fare,
|
||||||
@@ -851,59 +852,28 @@ export class GuestBookingService {
|
|||||||
private async resolveGuestPassenger(
|
private async resolveGuestPassenger(
|
||||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||||
firstPassenger: any,
|
firstPassenger: any,
|
||||||
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
|
req?: any,
|
||||||
|
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||||
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
|
const result = await this.passengerAuthService.register(
|
||||||
|
{
|
||||||
let accountPhone = firstPassenger.phone || null;
|
email: firstPassenger.email,
|
||||||
if (accountPhone) {
|
username: firstPassenger.email,
|
||||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
phoneNumber: firstPassenger.phone || `+251900000000`,
|
||||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
name: { en: guestName, am: guestName },
|
||||||
}
|
password: dto.password,
|
||||||
if (!accountPhone) accountPhone = generateEthiopianPhone();
|
confirmPassword: dto.password,
|
||||||
|
|
||||||
const user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
fullName: firstPassenger.passengerName,
|
|
||||||
email: firstPassenger.email,
|
|
||||||
phone: accountPhone,
|
|
||||||
passwordHash: await bcrypt.hash(dto.password, 10),
|
|
||||||
nationality: firstPassenger.nationality,
|
|
||||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
|
||||||
passportNumber: firstPassenger.passportNumber,
|
|
||||||
},
|
},
|
||||||
});
|
req,
|
||||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
);
|
||||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
|
||||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
|
||||||
return { guestPassenger, userId: user.id, createdAccount: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
const guestPassenger = await this.prisma.passenger.create({ data: {} });
|
||||||
let guestEmail = firstPassenger.email || generateGuestEmail(uniqueId);
|
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||||
if (firstPassenger.email) {
|
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||||
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
|
||||||
if (existing) guestEmail = generateGuestEmail(uniqueId);
|
|
||||||
}
|
|
||||||
let guestPhone = firstPassenger.phone || null;
|
|
||||||
if (guestPhone) {
|
|
||||||
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
|
||||||
if (existing) guestPhone = null;
|
|
||||||
}
|
|
||||||
if (!guestPhone) guestPhone = generateEthiopianPhone();
|
|
||||||
|
|
||||||
const tempUser = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
fullName: firstPassenger.passengerName,
|
|
||||||
email: guestEmail,
|
|
||||||
phone: guestPhone,
|
|
||||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
|
||||||
role: 'PASSENGER',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
|
|
||||||
return { guestPassenger, userId: null, createdAccount: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||||||
@@ -911,10 +881,6 @@ export class GuestBookingService {
|
|||||||
throw new BadRequestException('Either userId or deviceId is required');
|
throw new BadRequestException('Either userId or deviceId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporarily return empty array until Prisma client is regenerated
|
|
||||||
return [];
|
|
||||||
|
|
||||||
/* Uncomment after running migration and prisma generate
|
|
||||||
const profiles = await this.prisma.savedPassengerProfile.findMany({
|
const profiles = await this.prisma.savedPassengerProfile.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -929,14 +895,13 @@ export class GuestBookingService {
|
|||||||
passengerName: p.passengerName,
|
passengerName: p.passengerName,
|
||||||
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
|
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
idDocumentNumber: undefined, // Never return sensitive data
|
idDocumentNumber: undefined,
|
||||||
passportNumber: p.passportNumber || undefined,
|
passportNumber: p.passportNumber || undefined,
|
||||||
passportCountry: p.passportCountry || undefined,
|
passportCountry: p.passportCountry || undefined,
|
||||||
nationality: p.nationality || undefined,
|
nationality: p.nationality || undefined,
|
||||||
phone: p.phone || undefined,
|
phone: p.phone || undefined,
|
||||||
email: p.email || undefined,
|
email: p.email || undefined,
|
||||||
}));
|
}));
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getBaseFare(
|
private async getBaseFare(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { CurrenciesService } from './currencies.service';
|
import { CurrenciesService } from './currencies.service';
|
||||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Currencies')
|
@ApiTags('Currencies')
|
||||||
@Controller('currencies')
|
@Controller('currencies')
|
||||||
@@ -15,8 +16,7 @@ export class CurrenciesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||||
@IamRoles('ADMIN')
|
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
createCurrency(@Body() dto: CreateCurrencyDto) {
|
createCurrency(@Body() dto: CreateCurrencyDto) {
|
||||||
@@ -24,24 +24,21 @@ export class CurrenciesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||||
@IamRoles('ADMIN')
|
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
|
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
|
||||||
return this.currenciesService.updateCurrency(id, dto);
|
return this.currenciesService.updateCurrency(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(IamGuard)
|
@PassengerAdmin()
|
||||||
@IamRoles('ADMIN')
|
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
deleteCurrency(@Param('id') id: string) {
|
deleteCurrency(@Param('id') id: string) {
|
||||||
return this.currenciesService.deleteCurrency(id);
|
return this.currenciesService.deleteCurrency(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync-rates')
|
@Post('sync-rates')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
|
||||||
@IamRoles('ADMIN')
|
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
syncRates() {
|
syncRates() {
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getHomeDashboard(passengerId: string) {
|
async getHomeDashboard(passengerId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||||
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
|
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { loyalty: true } }),
|
||||||
this.prisma.booking.findFirst({
|
this.prisma.booking.findFirst({
|
||||||
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
|
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
|
||||||
include: {
|
include: {
|
||||||
@@ -27,7 +32,16 @@ export class DashboardService {
|
|||||||
|
|
||||||
const hour = now.getHours();
|
const hour = now.getHours();
|
||||||
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
|
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
|
||||||
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
|
|
||||||
|
let firstName = '';
|
||||||
|
if (passenger?.iamUserId) {
|
||||||
|
const iamRows = await this.dataSource.query<{ name: { en?: string; am?: string } | null }[]>(
|
||||||
|
`SELECT name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[passenger.iamUserId],
|
||||||
|
);
|
||||||
|
const name = iamRows[0]?.name;
|
||||||
|
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
|
||||||
|
}
|
||||||
const seat = upcomingBooking?.seats[0];
|
const seat = upcomingBooking?.seats[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
import { UserRole } from '@prisma/client';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Fraud Detection')
|
@ApiTags('Fraud Detection')
|
||||||
@Controller('fraud')
|
@Controller('fraud')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
export class FraudController {
|
export class FraudController {
|
||||||
private readonly logger = new Logger(FraudController.name);
|
private readonly logger = new Logger(FraudController.name);
|
||||||
@@ -17,7 +17,6 @@ export class FraudController {
|
|||||||
* Get fraud alerts
|
* Get fraud alerts
|
||||||
*/
|
*/
|
||||||
@Get('alerts')
|
@Get('alerts')
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
|
||||||
@ApiOperation({ summary: 'Get fraud alerts' })
|
@ApiOperation({ summary: 'Get fraud alerts' })
|
||||||
async getAlerts(
|
async getAlerts(
|
||||||
@Query('userId') userId?: string,
|
@Query('userId') userId?: string,
|
||||||
@@ -32,7 +31,6 @@ export class FraudController {
|
|||||||
* Get fraud rules
|
* Get fraud rules
|
||||||
*/
|
*/
|
||||||
@Get('rules')
|
@Get('rules')
|
||||||
@IamRoles('ADMIN')
|
|
||||||
@ApiOperation({ summary: 'Get fraud detection rules' })
|
@ApiOperation({ summary: 'Get fraud detection rules' })
|
||||||
async getRules() {
|
async getRules() {
|
||||||
const rules = await this.fraudService.getRules();
|
const rules = await this.fraudService.getRules();
|
||||||
@@ -43,7 +41,7 @@ export class FraudController {
|
|||||||
* Create or update fraud rule
|
* Create or update fraud rule
|
||||||
*/
|
*/
|
||||||
@Post('rules')
|
@Post('rules')
|
||||||
@IamRoles('ADMIN')
|
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiOperation({ summary: 'Create or update fraud rule' })
|
@ApiOperation({ summary: 'Create or update fraud rule' })
|
||||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||||
@@ -54,10 +52,10 @@ export class FraudController {
|
|||||||
* Block user temporarily
|
* Block user temporarily
|
||||||
*/
|
*/
|
||||||
@Post('actions/block')
|
@Post('actions/block')
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiOperation({ summary: 'Block user temporarily' })
|
@ApiOperation({ summary: 'Block user temporarily' })
|
||||||
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
|
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
|
||||||
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
|
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
|
||||||
return { message: `User blocked for ${body.durationMinutes} minutes` };
|
return { message: `User blocked for ${body.durationMinutes} minutes` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +63,10 @@ export class FraudController {
|
|||||||
* Unblock user
|
* Unblock user
|
||||||
*/
|
*/
|
||||||
@Post('actions/unblock')
|
@Post('actions/unblock')
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiOperation({ summary: 'Unblock user' })
|
@ApiOperation({ summary: 'Unblock user' })
|
||||||
async unblockUser(@Body() body: { userId: string }) {
|
async unblockUser(@Body() body: { iamUserId: string }) {
|
||||||
await this.fraudService.unblockUser(body.userId);
|
await this.fraudService.unblockUser(body.iamUserId);
|
||||||
return { message: 'User unblocked' };
|
return { message: 'User unblocked' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
|
||||||
export interface FraudRuleConfig {
|
export interface FraudRuleConfig {
|
||||||
@@ -14,47 +16,37 @@ export interface FraudRuleConfig {
|
|||||||
export class FraudService {
|
export class FraudService {
|
||||||
private readonly logger = new Logger(FraudService.name);
|
private readonly logger = new Logger(FraudService.name);
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate fraud rules and create alerts if triggered
|
* Evaluate fraud rules and create alerts if triggered
|
||||||
*/
|
*/
|
||||||
async evaluateRules(
|
async evaluateRules(
|
||||||
userId: string,
|
passengerId: string,
|
||||||
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
|
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): Promise<{ triggered: boolean; rules: string[] }> {
|
): Promise<{ triggered: boolean; rules: string[] }> {
|
||||||
const triggeredRules: string[] = [];
|
const triggeredRules: string[] = [];
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
||||||
|
|
||||||
if (!user) return { triggered: false, rules: [] };
|
|
||||||
|
|
||||||
// Check velocity rule (multiple bookings in short time)
|
|
||||||
if (eventType === 'booking.created') {
|
if (eventType === 'booking.created') {
|
||||||
const velocityTriggered = await this.checkVelocityRule(userId);
|
const velocityTriggered = await this.checkVelocityRule(passengerId);
|
||||||
if (velocityTriggered) {
|
if (velocityTriggered) triggeredRules.push('VELOCITY');
|
||||||
triggeredRules.push('VELOCITY');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check high-value booking
|
|
||||||
const amount = (context.amountMinor as number) || 0;
|
const amount = (context.amountMinor as number) || 0;
|
||||||
const highValueTriggered = await this.checkHighValueRule(amount);
|
const highValueTriggered = await this.checkHighValueRule(amount);
|
||||||
if (highValueTriggered) {
|
if (highValueTriggered) triggeredRules.push('HIGH_VALUE');
|
||||||
triggeredRules.push('HIGH_VALUE');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check repeated failed payments
|
|
||||||
if (eventType === 'payment.failed') {
|
if (eventType === 'payment.failed') {
|
||||||
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
|
const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId);
|
||||||
if (failedPaymentTriggered) {
|
if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS');
|
||||||
triggeredRules.push('FAILED_PAYMENTS');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create alert if rules triggered
|
|
||||||
if (triggeredRules.length > 0) {
|
if (triggeredRules.length > 0) {
|
||||||
await this.createFraudAlert(userId, eventType, triggeredRules, context);
|
await this.createFraudAlert(passengerId, eventType, triggeredRules, context);
|
||||||
return { triggered: true, rules: triggeredRules };
|
return { triggered: true, rules: triggeredRules };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +56,7 @@ export class FraudService {
|
|||||||
/**
|
/**
|
||||||
* Check velocity rule: X bookings in Y minutes
|
* Check velocity rule: X bookings in Y minutes
|
||||||
*/
|
*/
|
||||||
private async checkVelocityRule(userId: string): Promise<boolean> {
|
private async checkVelocityRule(passengerId: string): Promise<boolean> {
|
||||||
const rule = await this.prisma.fraudRule.findFirst({
|
const rule = await this.prisma.fraudRule.findFirst({
|
||||||
where: { type: 'VELOCITY', enabled: true },
|
where: { type: 'VELOCITY', enabled: true },
|
||||||
});
|
});
|
||||||
@@ -72,18 +64,14 @@ export class FraudService {
|
|||||||
if (!rule) return false;
|
if (!rule) return false;
|
||||||
|
|
||||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
|
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
|
||||||
const threshold = rule.threshold;
|
|
||||||
|
|
||||||
const bookingCount = await this.prisma.booking.count({
|
const bookingCount = await this.prisma.booking.count({
|
||||||
where: {
|
where: {
|
||||||
passengerId: userId,
|
passengerId,
|
||||||
createdAt: {
|
createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
|
||||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return bookingCount > threshold;
|
return bookingCount > rule.threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +92,7 @@ export class FraudService {
|
|||||||
/**
|
/**
|
||||||
* Check failed payment rule: X failed attempts in Y minutes
|
* Check failed payment rule: X failed attempts in Y minutes
|
||||||
*/
|
*/
|
||||||
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
|
private async checkFailedPaymentRule(passengerId: string): Promise<boolean> {
|
||||||
const rule = await this.prisma.fraudRule.findFirst({
|
const rule = await this.prisma.fraudRule.findFirst({
|
||||||
where: { type: 'FAILED_PAYMENTS', enabled: true },
|
where: { type: 'FAILED_PAYMENTS', enabled: true },
|
||||||
});
|
});
|
||||||
@@ -112,33 +100,33 @@ export class FraudService {
|
|||||||
if (!rule) return false;
|
if (!rule) return false;
|
||||||
|
|
||||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
|
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
|
||||||
const threshold = rule.threshold;
|
|
||||||
|
|
||||||
const failedCount = await this.prisma.paymentIntent.count({
|
const failedCount = await this.prisma.paymentIntent.count({
|
||||||
where: {
|
where: {
|
||||||
booking: { passengerId: userId },
|
booking: { passengerId },
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
updatedAt: {
|
updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) },
|
||||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return failedCount > threshold;
|
return failedCount > rule.threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a fraud alert
|
* Create a fraud alert
|
||||||
*/
|
*/
|
||||||
private async createFraudAlert(
|
private async createFraudAlert(
|
||||||
userId: string,
|
passengerId: string,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
triggeredRules: string[],
|
triggeredRules: string[],
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
|
where: { id: passengerId },
|
||||||
|
select: { iamUserId: true },
|
||||||
|
});
|
||||||
const alert = await this.prisma.fraudAlert.create({
|
const alert = await this.prisma.fraudAlert.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
iamUserId: passenger?.iamUserId ?? passengerId,
|
||||||
eventType,
|
eventType,
|
||||||
triggeredRules,
|
triggeredRules,
|
||||||
context: context as any,
|
context: context as any,
|
||||||
@@ -146,35 +134,34 @@ export class FraudService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
|
this.logger.warn(`Fraud alert created: ${alert.id} for passenger ${passengerId} - rules: ${triggeredRules.join(', ')}`);
|
||||||
|
|
||||||
// Trigger blocking if needed
|
|
||||||
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
|
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
|
||||||
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
|
if (passenger?.iamUserId) await this.blockUserTemporarily(passenger.iamUserId, 30);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block user temporarily
|
* Block user temporarily
|
||||||
*/
|
*/
|
||||||
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
|
async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise<void> {
|
||||||
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
|
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
|
||||||
await this.prisma.user.update({
|
await this.prisma.passenger.updateMany({
|
||||||
where: { id: userId },
|
where: { iamUserId },
|
||||||
data: { blockedUntil },
|
data: { blockedUntil },
|
||||||
});
|
});
|
||||||
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
|
this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unblock user
|
* Unblock user
|
||||||
*/
|
*/
|
||||||
async unblockUser(userId: string): Promise<void> {
|
async unblockUser(iamUserId: string): Promise<void> {
|
||||||
await this.prisma.user.update({
|
await this.prisma.passenger.updateMany({
|
||||||
where: { id: userId },
|
where: { iamUserId },
|
||||||
data: { blockedUntil: null },
|
data: { blockedUntil: null },
|
||||||
});
|
});
|
||||||
this.logger.log(`User ${userId} unblocked`);
|
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +169,7 @@ export class FraudService {
|
|||||||
*/
|
*/
|
||||||
async getAlerts(userId?: string, limit = 100, offset = 0) {
|
async getAlerts(userId?: string, limit = 100, offset = 0) {
|
||||||
return this.prisma.fraudAlert.findMany({
|
return this.prisma.fraudAlert.findMany({
|
||||||
where: userId ? { userId } : {},
|
where: userId ? { iamUserId: userId } : {},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit,
|
take: limit,
|
||||||
skip: offset,
|
skip: offset,
|
||||||
@@ -234,9 +221,10 @@ export class FraudService {
|
|||||||
* Event listener for payment failed
|
* Event listener for payment failed
|
||||||
*/
|
*/
|
||||||
@OnEvent('payment.failed')
|
@OnEvent('payment.failed')
|
||||||
async onPaymentFailed(payload: { intentId: string; userId: string }) {
|
async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) {
|
||||||
await this.evaluateRules(payload.userId, 'payment.failed', {
|
if (!payload.booking?.passengerId) return;
|
||||||
intentId: payload.intentId,
|
await this.evaluateRules(payload.booking.passengerId, 'payment.failed', {
|
||||||
|
bookingId: payload.booking.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,9 +232,18 @@ export class FraudService {
|
|||||||
* Event listener for auth login failed
|
* Event listener for auth login failed
|
||||||
*/
|
*/
|
||||||
@OnEvent('auth.login.failed')
|
@OnEvent('auth.login.failed')
|
||||||
async onLoginFailed(payload: { userId: string; email: string }) {
|
async onLoginFailed(payload: { email: string }) {
|
||||||
await this.evaluateRules(payload.userId, 'auth.login.failed', {
|
if (!payload.email) return;
|
||||||
email: payload.email,
|
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||||
|
[payload.email],
|
||||||
|
);
|
||||||
|
if (!iamRows.length) return;
|
||||||
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
|
where: { iamUserId: iamRows[0].id },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (!passenger) return;
|
||||||
|
await this.evaluateRules(passenger.id, 'auth.login.failed', { email: payload.email });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
import { TestNotificationDto } from './notifications.dto';
|
import { TestNotificationDto } from './notifications.dto';
|
||||||
import { EmailClientService } from './email-client.service';
|
import { EmailClientService } from './email-client.service';
|
||||||
import { SmsClientService } from './sms-client.service';
|
import { SmsClientService } from './sms-client.service';
|
||||||
@@ -39,8 +40,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('send/email')
|
@Post('send/email')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||||
@IamRoles('ADMIN', 'STAFF')
|
|
||||||
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
|
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
|
||||||
@ApiBody({ type: SendEmail })
|
@ApiBody({ type: SendEmail })
|
||||||
sendEmail(@Body() dto: SendEmail) {
|
sendEmail(@Body() dto: SendEmail) {
|
||||||
@@ -48,8 +48,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('send/sms')
|
@Post('send/sms')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||||
@IamRoles('ADMIN', 'STAFF')
|
|
||||||
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
||||||
@ApiBody({ type: SingleMessageDto })
|
@ApiBody({ type: SingleMessageDto })
|
||||||
sendSms(@Body() dto: SingleMessageDto) {
|
sendSms(@Body() dto: SingleMessageDto) {
|
||||||
@@ -57,8 +56,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('send/sms/bulk')
|
@Post('send/sms/bulk')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||||
@IamRoles('ADMIN', 'STAFF')
|
|
||||||
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
|
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
|
||||||
@ApiBody({ type: BulkMessagesDto })
|
@ApiBody({ type: BulkMessagesDto })
|
||||||
sendBulkSms(@Body() dto: BulkMessagesDto) {
|
sendBulkSms(@Body() dto: BulkMessagesDto) {
|
||||||
@@ -66,8 +64,6 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@UseGuards(IamGuard)
|
|
||||||
@IamRoles('ADMIN', 'STAFF')
|
|
||||||
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
||||||
async testNotification(@Body() dto: TestNotificationDto) {
|
async testNotification(@Body() dto: TestNotificationDto) {
|
||||||
return this.service.send(
|
return this.service.send(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||||
import { EmailClientService } from './email-client.service';
|
import { EmailClientService } from './email-client.service';
|
||||||
@@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service';
|
|||||||
|
|
||||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
@@ -14,6 +18,7 @@ export class NotificationsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private emailClient: EmailClientService,
|
private emailClient: EmailClientService,
|
||||||
private smsClient: SmsClientService,
|
private smsClient: SmsClientService,
|
||||||
private pushAdapter: PushAdapter,
|
private pushAdapter: PushAdapter,
|
||||||
@@ -112,22 +117,20 @@ export class NotificationsService {
|
|||||||
body: string,
|
body: string,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Try to find passenger by ID or email
|
|
||||||
let passengerId = recipient;
|
let passengerId = recipient;
|
||||||
|
|
||||||
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
if (!UUID_RE.test(recipient)) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const iamUserId = await this.resolveIamUserId(recipient);
|
||||||
where: {
|
if (!iamUserId) {
|
||||||
OR: [{ email: recipient }, { phone: recipient }],
|
|
||||||
},
|
|
||||||
include: { passenger: true },
|
|
||||||
});
|
|
||||||
if (user?.passenger) {
|
|
||||||
passengerId = user.passenger.id;
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } });
|
||||||
|
if (!passenger) {
|
||||||
|
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passengerId = passenger.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.notification.create({
|
await this.prisma.notification.create({
|
||||||
@@ -163,26 +166,19 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const iamUserId = await this.resolveIamUserId(recipient);
|
||||||
where: {
|
const preferences = iamUserId
|
||||||
OR: [
|
? await this.prisma.userPreferences.findUnique({ where: { iamUserId } })
|
||||||
{ id: recipient },
|
: null;
|
||||||
{ email: recipient },
|
|
||||||
{ phone: recipient },
|
|
||||||
{ passenger: { id: recipient } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: { preferences: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user?.preferences) {
|
if (!preferences) {
|
||||||
return ['IN_APP', 'EMAIL'];
|
return ['IN_APP', 'EMAIL'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels: NotificationChannelType[] = ['IN_APP'];
|
const channels: NotificationChannelType[] = ['IN_APP'];
|
||||||
if (user.preferences.emailEnabled) channels.push('EMAIL');
|
if (preferences.emailEnabled) channels.push('EMAIL');
|
||||||
if (user.preferences.smsEnabled) channels.push('SMS');
|
if (preferences.smsEnabled) channels.push('SMS');
|
||||||
if (user.preferences.pushEnabled) channels.push('PUSH');
|
if (preferences.pushEnabled) channels.push('PUSH');
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
@@ -191,32 +187,44 @@ export class NotificationsService {
|
|||||||
recipient: string,
|
recipient: string,
|
||||||
channel: NotificationChannelType,
|
channel: NotificationChannelType,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const iamUserId = await this.resolveIamUserId(recipient);
|
||||||
where: {
|
if (!iamUserId) return null;
|
||||||
OR: [
|
const contact = await this.resolveContactInfo(iamUserId);
|
||||||
{ id: recipient },
|
|
||||||
{ email: recipient },
|
|
||||||
{ phone: recipient },
|
|
||||||
{ passenger: { id: recipient } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case 'EMAIL':
|
case 'EMAIL': return contact.email;
|
||||||
return user.email;
|
case 'SMS': return contact.phone;
|
||||||
case 'SMS':
|
case 'PUSH': return iamUserId;
|
||||||
return user.phone;
|
default: return null;
|
||||||
case 'PUSH':
|
|
||||||
// Would need to fetch device push token
|
|
||||||
return user.id;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveIamUserId(recipient: string): Promise<string | null> {
|
||||||
|
if (UUID_RE.test(recipient)) {
|
||||||
|
const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } });
|
||||||
|
return passenger?.iamUserId ?? recipient;
|
||||||
|
}
|
||||||
|
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||||
|
[recipient],
|
||||||
|
);
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> {
|
||||||
|
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
|
||||||
|
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[iamUserId],
|
||||||
|
);
|
||||||
|
return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitize(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[\r\n]/g, ' ')
|
||||||
|
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||||
|
}
|
||||||
|
|
||||||
getForPassenger(passengerId: string) {
|
getForPassenger(passengerId: string) {
|
||||||
return this.prisma.notification.findMany({
|
return this.prisma.notification.findMany({
|
||||||
where: { passengerId },
|
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 { PassengersService } from './passengers.service';
|
||||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { IamGuard } from '../../common/iam-adapter';
|
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
@@ -53,25 +52,17 @@ export class PassengersController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
|
||||||
async getMe(@Request() req: any) {
|
async getMe(@Request() req: any) {
|
||||||
if (!req.user || !req.user.userId) {
|
if (!req.user || !req.user.id) {
|
||||||
throw new UnauthorizedException('User not authenticated');
|
throw new UnauthorizedException('User not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.findUnique({
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id: req.user.userId },
|
where: { iamUserId: req.user.id },
|
||||||
include: {
|
|
||||||
passenger: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!passenger) return null;
|
||||||
if (!user || !user.passenger) {
|
return this.service.getProfile(passenger.id);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.service.getProfile(user.passenger.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If profile lookup fails for any reason, return null to allow app to continue
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +242,7 @@ The API automatically detects:
|
|||||||
description: 'Invalid JWT token (only if token provided but invalid)'
|
description: 'Invalid JWT token (only if token provided but invalid)'
|
||||||
})
|
})
|
||||||
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.id;
|
||||||
return this.service.registerPassenger({ ...dto, userId });
|
return this.service.registerPassenger({ ...dto, userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
@@ -10,37 +12,68 @@ interface PassengerFilters {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IamUserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: { en: string; am: string } | null;
|
||||||
|
phone_number: string | null;
|
||||||
|
metadata: Record<string, any> | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PassengersService {
|
export class PassengersService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private verifaydaService: VerifaydaService,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly verifaydaService: VerifaydaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filters: PassengerFilters = {}) {
|
async findAll(filters: PassengerFilters = {}) {
|
||||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = { user: { role: 'PASSENGER' } };
|
let iamUserIdFilter: string[] | null = null;
|
||||||
|
|
||||||
if (search) {
|
if (search || verified !== undefined) {
|
||||||
where.user = {
|
const conditions: string[] = [];
|
||||||
...where.user,
|
const params: any[] = [];
|
||||||
OR: [
|
let idx = 1;
|
||||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
|
||||||
{ email: { contains: search, mode: 'insensitive' } },
|
if (search) {
|
||||||
{ phone: { contains: search, mode: 'insensitive' } },
|
conditions.push(`(
|
||||||
],
|
u.email ILIKE $${idx} OR
|
||||||
};
|
u.phone_number ILIKE $${idx} OR
|
||||||
|
(u.name->>'en') ILIKE $${idx} OR
|
||||||
|
(u.name->>'am') ILIKE $${idx}
|
||||||
|
)`);
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified !== undefined) {
|
||||||
|
if (verified) {
|
||||||
|
conditions.push(`u.metadata->>'faydaVerified' = 'true'`);
|
||||||
|
} else {
|
||||||
|
conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
iamUserIdFilter = rows.map(r => r.id);
|
||||||
|
|
||||||
|
if (iamUserIdFilter.length === 0) {
|
||||||
|
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verified !== undefined) {
|
const where: any = {};
|
||||||
where.user = {
|
if (iamUserIdFilter) {
|
||||||
...where.user,
|
where.iamUserId = { in: iamUserIdFilter };
|
||||||
nationalId: verified ? { not: null } : null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.passenger.findMany({
|
this.prisma.passenger.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -48,42 +81,36 @@ export class PassengersService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
|
||||||
loyalty: true,
|
loyalty: true,
|
||||||
wallet: true,
|
_count: { select: { bookings: true } },
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
bookings: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.passenger.count({ where }),
|
this.prisma.passenger.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
|
||||||
|
const iamRows = iamUserIds.length > 0
|
||||||
|
? await this.dataSource.query<IamUserRow[]>(
|
||||||
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
|
||||||
|
[iamUserIds],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(passenger => {
|
items: items.map(passenger => {
|
||||||
const user = passenger.user as any;
|
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||||
|
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||||
return {
|
return {
|
||||||
id: passenger.id,
|
id: passenger.id,
|
||||||
userId: passenger.userId,
|
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||||
fullName: user.fullName,
|
email: iam?.email ?? null,
|
||||||
email: user.email,
|
phone: iam?.phone_number ?? null,
|
||||||
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
|
verified: faydaVerified,
|
||||||
nationalId: user.nationalId,
|
|
||||||
nationality: user.nationality,
|
|
||||||
dateOfBirth: user.dateOfBirth ?? null,
|
|
||||||
gender: user.gender ?? null,
|
|
||||||
passportNumber: user.passportNumber,
|
|
||||||
passportCountry: user.passportCountry ?? null,
|
|
||||||
verified: !!user.nationalId,
|
|
||||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||||
totalBookings: passenger._count.bookings,
|
totalBookings: passenger._count.bookings,
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
loyalty: passenger.loyalty,
|
|
||||||
wallet: passenger.wallet,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
@@ -99,33 +126,42 @@ export class PassengersService {
|
|||||||
const passenger = await this.prisma.passenger.findUnique({
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id: passengerId },
|
where: { id: passengerId },
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
bookings: {
|
||||||
bookings: {
|
orderBy: { createdAt: 'desc' },
|
||||||
orderBy: { createdAt: 'desc' },
|
take: 10,
|
||||||
take: 10,
|
include: {
|
||||||
include: {
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
seats: { include: { seat: { include: { coach: true } } } }
|
},
|
||||||
}
|
|
||||||
},
|
},
|
||||||
loyalty: true,
|
loyalty: true,
|
||||||
wallet: true,
|
wallet: true,
|
||||||
travelerProfiles: true,
|
travelerProfiles: true,
|
||||||
savedRoutes: true,
|
savedRoutes: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
|
|
||||||
|
let iamUser: IamUserRow | null = null;
|
||||||
|
if (passenger.iamUserId) {
|
||||||
|
const rows = await this.dataSource.query<IamUserRow[]>(
|
||||||
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[passenger.iamUserId],
|
||||||
|
);
|
||||||
|
iamUser = rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: passenger.id,
|
id: passenger.id,
|
||||||
fullName: passenger.user.fullName,
|
fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
|
||||||
email: passenger.user.email,
|
email: iamUser?.email ?? null,
|
||||||
phone: passenger.user.phone,
|
phone: iamUser?.phone_number ?? null,
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
bookings: passenger.bookings.map((b) => ({
|
bookings: passenger.bookings.map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
bookingRef: b.bookingRef,
|
bookingRef: b.bookingRef,
|
||||||
status: b.status,
|
status: b.status,
|
||||||
totalFare: b.totalMinor / 100,
|
totalFare: b.totalMinor / 100,
|
||||||
createdAt: b.createdAt,
|
createdAt: b.createdAt,
|
||||||
trip: {
|
trip: {
|
||||||
number: b.schedule.train.number,
|
number: b.schedule.train.number,
|
||||||
@@ -143,13 +179,9 @@ export class PassengersService {
|
|||||||
},
|
},
|
||||||
departureAt: b.schedule.departureAt,
|
departureAt: b.schedule.departureAt,
|
||||||
},
|
},
|
||||||
passengers: b.seats.map((bs) => ({
|
passengers: b.seats.map((bs) => ({
|
||||||
fullName: bs.passengerName,
|
fullName: bs.passengerName,
|
||||||
seat: {
|
seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' },
|
||||||
number: bs.seat.seatNumber,
|
|
||||||
coach: bs.seat.coach.number,
|
|
||||||
class: 'N/A'
|
|
||||||
}
|
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -229,23 +261,53 @@ export class PassengersService {
|
|||||||
async updatePassenger(id: string, dto: any) {
|
async updatePassenger(id: string, dto: any) {
|
||||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
return this.prisma.passenger.update({
|
|
||||||
where: { id },
|
if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) {
|
||||||
data: {
|
const updates: string[] = [];
|
||||||
user: {
|
const params: any[] = [];
|
||||||
update: {
|
let idx = 1;
|
||||||
fullName: dto.fullName || undefined,
|
|
||||||
email: dto.email || undefined,
|
if (dto.fullName) {
|
||||||
phone: dto.phone || undefined,
|
updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`);
|
||||||
nationality: dto.nationality || undefined,
|
params.push(dto.fullName);
|
||||||
},
|
idx++;
|
||||||
},
|
}
|
||||||
},
|
if (dto.email) {
|
||||||
include: {
|
updates.push(`email = $${idx}`);
|
||||||
user: true,
|
params.push(dto.email);
|
||||||
loyalty: true,
|
idx++;
|
||||||
},
|
}
|
||||||
});
|
if (dto.phone) {
|
||||||
|
updates.push(`phone_number = $${idx}`);
|
||||||
|
params.push(dto.phone);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(passenger.iamUserId);
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated, iamRows] = await Promise.all([
|
||||||
|
this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }),
|
||||||
|
passenger.iamUserId
|
||||||
|
? this.dataSource.query<IamUserRow[]>(
|
||||||
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[passenger.iamUserId],
|
||||||
|
)
|
||||||
|
: Promise.resolve([] as IamUserRow[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const iamUser = iamRows[0] ?? null;
|
||||||
|
return {
|
||||||
|
id: updated!.id,
|
||||||
|
fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
|
||||||
|
email: iamUser?.email ?? null,
|
||||||
|
phone: iamUser?.phone_number ?? null,
|
||||||
|
loyalty: updated!.loyalty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerPassenger(dto: RegisterPassengerDto) {
|
async registerPassenger(dto: RegisterPassengerDto) {
|
||||||
@@ -274,31 +336,16 @@ export class PassengersService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const linkedPassenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id: dto.userId },
|
where: { iamUserId: dto.userId },
|
||||||
include: { passenger: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!linkedPassenger) {
|
||||||
throw new BadRequestException('User not found');
|
throw new BadRequestException('Passenger not found');
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.faydaVerified && verifiedData) {
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: dto.userId },
|
|
||||||
data: {
|
|
||||||
fullName: finalData.passengerName,
|
|
||||||
nationality: finalData.nationality,
|
|
||||||
nationalId: dto.nationalId,
|
|
||||||
passportNumber: dto.passportNumber,
|
|
||||||
faydaVerified: !!verifiedData,
|
|
||||||
faydaVerifiedAt: verifiedData ? new Date() : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.passenger?.id || user.id,
|
id: linkedPassenger.id,
|
||||||
passengerName: finalData.passengerName,
|
passengerName: finalData.passengerName,
|
||||||
dateOfBirth: finalData.dateOfBirth,
|
dateOfBirth: finalData.dateOfBirth,
|
||||||
nationality: finalData.nationality,
|
nationality: finalData.nationality,
|
||||||
@@ -336,7 +383,9 @@ export class PassengersService {
|
|||||||
async deletePassenger(id: string) {
|
async deletePassenger(id: string) {
|
||||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
return this.prisma.passenger.delete({ where: { id } });
|
|
||||||
|
await this.prisma.passenger.delete({ where: { id } });
|
||||||
|
return { deleted: true, passengerId: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPassengerUsage(id: string) {
|
async checkPassengerUsage(id: string) {
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ import {
|
|||||||
PaymentMethodTypeEnum,
|
PaymentMethodTypeEnum,
|
||||||
PaymentPlatformDto,
|
PaymentPlatformDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { PassengerStaff } from "../../common/passenger-guards";
|
||||||
import { RolesGuard } from "../../common/roles.guard";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { Roles } from "../../common/roles.decorator";
|
|
||||||
import { UserRole } from "@prisma/client";
|
|
||||||
|
|
||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
@@ -39,9 +37,8 @@ export class PaymentsController {
|
|||||||
constructor(private service: PaymentsService) {}
|
constructor(private service: PaymentsService) {}
|
||||||
|
|
||||||
@Get("all")
|
@Get("all")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiBearerAuth("JWT-auth")
|
|
||||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||||
@ApiQuery({ name: "search", required: false })
|
@ApiQuery({ name: "search", required: false })
|
||||||
@ApiQuery({ name: "status", required: false })
|
@ApiQuery({ name: "status", required: false })
|
||||||
@@ -102,18 +99,16 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("refund")
|
@Post("refund")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiBearerAuth("JWT-auth")
|
|
||||||
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
||||||
refund(@Body() dto: RefundDto) {
|
refund(@Body() dto: RefundDto) {
|
||||||
return this.service.refund(dto);
|
return this.service.refund(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("methods")
|
@Post("methods")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiBearerAuth("JWT-auth")
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Add a payment system to the platform catalog (admin only)",
|
summary: "Add a payment system to the platform catalog (admin only)",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,19 +23,7 @@ describe("Payments E2E", () => {
|
|||||||
|
|
||||||
prisma = app.get<PrismaService>(PrismaService);
|
prisma = app.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
const testUser = await prisma.user.create({
|
const passenger = await prisma.passenger.create({ data: { iamUserId: 'test-iam-payments-user' } });
|
||||||
data: {
|
|
||||||
email: "payment-test@example.com",
|
|
||||||
phone: "+251911111112",
|
|
||||||
fullName: "Payment Test User",
|
|
||||||
passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
|
|
||||||
role: "PASSENGER",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const passenger = await prisma.passenger.create({
|
|
||||||
data: { userId: testUser.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.walletAccount.create({
|
await prisma.walletAccount.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -151,7 +139,6 @@ describe("Payments E2E", () => {
|
|||||||
prisma.walletLedgerEntry.deleteMany(),
|
prisma.walletLedgerEntry.deleteMany(),
|
||||||
prisma.walletAccount.deleteMany(),
|
prisma.walletAccount.deleteMany(),
|
||||||
prisma.passenger.deleteMany(),
|
prisma.passenger.deleteMany(),
|
||||||
prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }),
|
|
||||||
]);
|
]);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
import { GenerateReportDto } from './reports.dto';
|
import { GenerateReportDto } from './reports.dto';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
import { UserRole } from '@prisma/client';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Reports')
|
@ApiTags('Reports')
|
||||||
@Controller('reports')
|
@Controller('reports')
|
||||||
@UseGuards(IamGuard)
|
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private service: ReportsService) {}
|
constructor(private service: ReportsService) {}
|
||||||
|
|
||||||
@Post('generate')
|
@Post('generate')
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
|
||||||
@ApiOperation({ summary: 'Generate operational report' })
|
@ApiOperation({ summary: 'Generate operational report' })
|
||||||
generateReport(@Body() dto: GenerateReportDto) {
|
generateReport(@Body() dto: GenerateReportDto) {
|
||||||
return this.service.generateReport(dto);
|
return this.service.generateReport(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':reportId')
|
@Get(':reportId')
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
|
||||||
@ApiOperation({ summary: 'Get report by ID' })
|
@ApiOperation({ summary: 'Get report by ID' })
|
||||||
getReport(@Param('reportId') reportId: string) {
|
getReport(@Param('reportId') reportId: string) {
|
||||||
return this.service.getReport(reportId);
|
return this.service.getReport(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
|
||||||
@ApiOperation({ summary: 'List reports' })
|
@ApiOperation({ summary: 'List reports' })
|
||||||
listReports(@Query('type') type?: string) {
|
listReports(@Query('type') type?: string) {
|
||||||
return this.service.listReports(type);
|
return this.service.listReports(type);
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
import { GenerateReportDto, ReportType } from './reports.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
async generateReport(dto: GenerateReportDto) {
|
async generateReport(dto: GenerateReportDto) {
|
||||||
const dateFrom = new Date(dto.dateFrom);
|
const dateFrom = new Date(dto.dateFrom);
|
||||||
@@ -113,13 +118,25 @@ export class ReportsService {
|
|||||||
...(agentId ? { agentId } : {})
|
...(agentId ? { agentId } : {})
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
agent: { include: { user: true } },
|
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
||||||
booking: true
|
booking: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const iamUserIds = [...new Set(
|
||||||
|
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
|
||||||
|
)];
|
||||||
|
const iamRows = iamUserIds.length > 0
|
||||||
|
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
|
||||||
|
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
|
||||||
|
[iamUserIds],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
const byAgent = agentBookings.reduce((acc, ab) => {
|
const byAgent = agentBookings.reduce((acc, ab) => {
|
||||||
const agentName = ab.agent.user.fullName;
|
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
|
||||||
|
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||||
if (!acc[agentName]) {
|
if (!acc[agentName]) {
|
||||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { HttpModule } from '@nestjs/axios';
|
|||||||
import { SeatsController } from './seats.controller';
|
import { SeatsController } from './seats.controller';
|
||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
import { SegmentsModule } from '../segments/segments.module';
|
import { SegmentsModule } from '../segments/segments.module';
|
||||||
import { IamModule } from '../../common/iam.module';
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SegmentsModule, HttpModule, IamModule],
|
imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
|
||||||
controllers: [SeatsController],
|
controllers: [SeatsController],
|
||||||
providers: [SeatsService],
|
providers: [SeatsService],
|
||||||
exports: [SeatsService],
|
exports: [SeatsService],
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
import { HoldSeatsDto } from './seats.dto';
|
import { HoldSeatsDto } from './seats.dto';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatsService {
|
export class SeatsService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private segmentsService: SegmentsService,
|
private segmentsService: SegmentsService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||||
@@ -240,7 +242,8 @@ export class SeatsService {
|
|||||||
if (new Set(seatIds).size !== seatIds.length)
|
if (new Set(seatIds).size !== seatIds.length)
|
||||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
|
||||||
|
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||||
|
|
||||||
const hold = await this.prisma.$transaction(async (tx) => {
|
const hold = await this.prisma.$transaction(async (tx) => {
|
||||||
const seats = await tx.seat.findMany({
|
const seats = await tx.seat.findMany({
|
||||||
|
|||||||
@@ -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 { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ interface OfflineValidation {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TicketsService {
|
export class TicketsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -38,50 +43,68 @@ export class TicketsService {
|
|||||||
end.setDate(end.getDate() + 1);
|
end.setDate(end.getDate() + 1);
|
||||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
||||||
}
|
}
|
||||||
const tickets = await this.prisma.ticket.findMany({
|
const [tickets, total] = await Promise.all([
|
||||||
where,
|
this.prisma.ticket.findMany({
|
||||||
include: {
|
where,
|
||||||
booking: {
|
include: {
|
||||||
include: {
|
booking: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
include: {
|
||||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||||
passenger: { include: { user: true } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||||
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
skip: filters.skip,
|
||||||
skip: filters.skip,
|
take: filters.take,
|
||||||
take: filters.take,
|
orderBy: { issuedAt: 'desc' },
|
||||||
orderBy: { issuedAt: 'desc' },
|
}),
|
||||||
});
|
this.prisma.ticket.count({ where }),
|
||||||
const total = await this.prisma.ticket.count({ where });
|
]);
|
||||||
|
|
||||||
|
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
|
||||||
|
const iamRows = iamUserIds.length > 0
|
||||||
|
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||||
|
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||||
|
[iamUserIds],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: tickets.map((t) => ({
|
items: tickets.map((t) => {
|
||||||
id: t.id,
|
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||||
ticketNumber: t.barcodePayload,
|
const passengerInfo = iam
|
||||||
bookingRef: t.bookingRef,
|
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||||
booking: {
|
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
|
||||||
bookingRef: t.booking.bookingRef,
|
return {
|
||||||
status: t.booking.status,
|
id: t.id,
|
||||||
bookingType: t.booking.bookingType,
|
ticketNumber: t.barcodePayload,
|
||||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
bookingRef: t.bookingRef,
|
||||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
booking: {
|
||||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
bookingRef: t.booking.bookingRef,
|
||||||
totalMinor: t.booking.totalMinor,
|
status: t.booking.status,
|
||||||
currency: t.booking.currency,
|
bookingType: t.booking.bookingType,
|
||||||
displayCurrency: t.booking.displayCurrency,
|
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||||
displayTotalMinor: t.booking.displayTotalMinor,
|
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||||
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||||
contactEmail: t.booking.contactEmail,
|
totalMinor: t.booking.totalMinor,
|
||||||
contactPhone: t.booking.contactPhone,
|
currency: t.booking.currency,
|
||||||
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
displayCurrency: t.booking.displayCurrency,
|
||||||
},
|
displayTotalMinor: t.booking.displayTotalMinor,
|
||||||
schedule: t.booking.schedule,
|
passenger: passengerInfo,
|
||||||
seat: t.booking.seats[0]?.seat,
|
contactEmail: t.booking.contactEmail,
|
||||||
status: t.status,
|
contactPhone: t.booking.contactPhone,
|
||||||
validatedAt: t.validatedAt,
|
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
||||||
createdAt: t.issuedAt,
|
},
|
||||||
})),
|
schedule: t.booking.schedule,
|
||||||
|
seat: t.booking.seats[0]?.seat,
|
||||||
|
status: t.status,
|
||||||
|
validatedAt: t.validatedAt,
|
||||||
|
createdAt: t.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
skip: filters.skip,
|
skip: filters.skip,
|
||||||
take: filters.take,
|
take: filters.take,
|
||||||
@@ -390,8 +413,8 @@ export class TicketsService {
|
|||||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||||
include: {
|
include: {
|
||||||
ticket: true,
|
ticket: true,
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
passenger: { include: { user: true } },
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like {@link JwtGuard}, but never rejects the request.
|
* Like the IAM JwtGuard, but never rejects the request.
|
||||||
*
|
*
|
||||||
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
|
* When a valid IAM bearer token is present, `request.user` is populated with
|
||||||
* populated from the JWT strategy (`{ userId, ... }`). When the token is
|
* the package `TCurrentUser`. Missing or invalid tokens continue as guests.
|
||||||
* missing or invalid, the request still proceeds with `request.user`
|
|
||||||
* undefined — the handler decides what to do.
|
|
||||||
*
|
|
||||||
* Used on `POST /fayda/verification/start`, which must work for both
|
|
||||||
* logged-in users (who can opt to save the verification to their account)
|
|
||||||
* and guests (anchored to a booking only).
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OptionalJwtGuard extends AuthGuard('jwt') {
|
export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
|
||||||
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
|
constructor(
|
||||||
return (user ?? null) as TUser;
|
reflector: Reflector,
|
||||||
|
@InjectDataSource() dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
super(reflector, dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await super.canActivate(context);
|
||||||
|
} catch {
|
||||||
|
context.switchToHttp().getRequest().user = undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { OptionalJwtGuard } from './optional-jwt.guard';
|
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||||
import {
|
import {
|
||||||
@@ -25,21 +26,13 @@ import {
|
|||||||
} from './verifayda.dto';
|
} from './verifayda.dto';
|
||||||
import { VerifaydaService } from './verifayda.service';
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
|
|
||||||
interface AuthedUser {
|
|
||||||
userId: string;
|
|
||||||
email?: string;
|
|
||||||
role?: string;
|
|
||||||
passengerId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal slices of the Express req we touch (avoids a hard dependency on
|
/** Minimal slices of the Express req we touch (avoids a hard dependency on
|
||||||
* `@types/express`, which isn't resolved in this package). */
|
* `@types/express`, which isn't resolved in this package). */
|
||||||
interface RequestWithOptionalUser {
|
interface RequestWithOptionalUser {
|
||||||
user?: AuthedUser;
|
user?: TCurrentUser;
|
||||||
}
|
}
|
||||||
interface RequestWithUser {
|
interface RequestWithUser {
|
||||||
user: AuthedUser;
|
user: TCurrentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Fayda Verification')
|
@ApiTags('Fayda Verification')
|
||||||
@@ -76,7 +69,7 @@ export class VerifaydaController {
|
|||||||
const authorizationUrl = await this.service.startVerification({
|
const authorizationUrl = await this.service.startVerification({
|
||||||
purpose: dto.purpose ?? 'VERIFY',
|
purpose: dto.purpose ?? 'VERIFY',
|
||||||
platform: dto.platform ?? 'WEB',
|
platform: dto.platform ?? 'WEB',
|
||||||
userId: req.user?.userId,
|
userId: req.user?.id,
|
||||||
});
|
});
|
||||||
return { authorizationUrl };
|
return { authorizationUrl };
|
||||||
}
|
}
|
||||||
@@ -105,6 +98,6 @@ export class VerifaydaController {
|
|||||||
async status(
|
async status(
|
||||||
@Req() req: RequestWithUser,
|
@Req() req: RequestWithUser,
|
||||||
): Promise<VerificationStatusDto> {
|
): Promise<VerificationStatusDto> {
|
||||||
return this.service.getVerificationStatus(req.user.userId);
|
return this.service.getVerificationStatus(req.user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { Module } from '@nestjs/common';
|
|||||||
import { VerifaydaController } from './verifayda.controller';
|
import { VerifaydaController } from './verifayda.controller';
|
||||||
import { VerifaydaService } from './verifayda.service';
|
import { VerifaydaService } from './verifayda.service';
|
||||||
import { PrismaModule } from '../../common/prisma.module';
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
|
imports: [PrismaModule],
|
||||||
// config as /auth/login) to mint tokens for the LOGIN flow.
|
|
||||||
imports: [PrismaModule, AuthModule],
|
|
||||||
controllers: [VerifaydaController],
|
controllers: [VerifaydaController],
|
||||||
providers: [VerifaydaService],
|
providers: [VerifaydaService],
|
||||||
exports: [VerifaydaService],
|
exports: [VerifaydaService],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { FaydaConfig } from '../../config/fayda.config';
|
import { FaydaConfig } from '../../config/fayda.config';
|
||||||
@@ -16,12 +15,6 @@ function buildPrismaMock() {
|
|||||||
bookingSeat: {
|
bookingSeat: {
|
||||||
updateMany: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
},
|
},
|
||||||
user: {
|
|
||||||
findUnique: jest.fn(),
|
|
||||||
findFirst: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
},
|
|
||||||
passenger: { create: jest.fn() },
|
passenger: { create: jest.fn() },
|
||||||
loyaltyAccount: { create: jest.fn() },
|
loyaltyAccount: { create: jest.fn() },
|
||||||
walletAccount: { create: jest.fn() },
|
walletAccount: { create: jest.fn() },
|
||||||
@@ -30,10 +23,8 @@ function buildPrismaMock() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildJwtMock(): jest.Mocked<JwtService> {
|
function buildDataSourceMock() {
|
||||||
return {
|
return { query: jest.fn().mockResolvedValue([]) };
|
||||||
sign: jest.fn(() => 'signed.jwt.token'),
|
|
||||||
} as unknown as jest.Mocked<JwtService>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||||
@@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
|
|||||||
|
|
||||||
describe('VerifaydaService (OIDC, client-callback)', () => {
|
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||||
let prisma: ReturnType<typeof buildPrismaMock>;
|
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||||
let jwt: jest.Mocked<JwtService>;
|
let dataSource: ReturnType<typeof buildDataSourceMock>;
|
||||||
let service: VerifaydaService;
|
let service: VerifaydaService;
|
||||||
let realPrivateJwk: JWK;
|
let realPrivateJwk: JWK;
|
||||||
|
|
||||||
@@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = buildPrismaMock();
|
prisma = buildPrismaMock();
|
||||||
jwt = buildJwtMock();
|
dataSource = buildDataSourceMock();
|
||||||
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
||||||
service = new VerifaydaService(
|
service = new VerifaydaService(
|
||||||
buildConfigService(cfg),
|
buildConfigService(cfg),
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
jwt,
|
dataSource as any,
|
||||||
);
|
);
|
||||||
(global as any).fetch = jest.fn();
|
(global as any).fetch = jest.fn();
|
||||||
});
|
});
|
||||||
@@ -135,7 +126,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
const disabledService = new VerifaydaService(
|
const disabledService = new VerifaydaService(
|
||||||
buildConfigService(buildConfig({ enabled: false })),
|
buildConfigService(buildConfig({ enabled: false })),
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
jwt,
|
buildDataSourceMock() as any,
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
disabledService.startVerification({ purpose: 'VERIFY' }),
|
disabledService.startVerification({ purpose: 'VERIFY' }),
|
||||||
@@ -154,7 +145,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorDescription: null,
|
errorDescription: null,
|
||||||
userId: null,
|
iamUserId: null,
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -215,7 +206,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
purpose: 'VERIFY',
|
purpose: 'VERIFY',
|
||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
userId: null,
|
iamUserId: null,
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -270,7 +261,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
expect(result.token).toBeUndefined();
|
expect(result.token).toBeUndefined();
|
||||||
expect(result.user).toBeUndefined();
|
expect(result.user).toBeUndefined();
|
||||||
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
||||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 502 when the token endpoint returns 4xx', async () => {
|
it('throws 502 when the token endpoint returns 4xx', async () => {
|
||||||
@@ -337,7 +327,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
purpose: 'LOGIN',
|
purpose: 'LOGIN',
|
||||||
platform: 'WEB',
|
platform: 'WEB',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
userId: null,
|
iamUserId: null,
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -363,139 +353,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
|
|
||||||
function mockUserFindUnique(bySub: any, fullUser: any) {
|
|
||||||
prisma.user.findUnique.mockImplementation(async (args: any) => {
|
|
||||||
if (args?.where?.faydaSub !== undefined) return bySub;
|
|
||||||
if (args?.where?.id !== undefined) return fullUser;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new user when no match and returns { token, user }', async () => {
|
it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => {
|
||||||
const fullUser = {
|
|
||||||
id: 'new-user',
|
|
||||||
email: 'new@example.com',
|
|
||||||
role: 'PASSENGER',
|
|
||||||
passenger: { id: 'p-new' },
|
|
||||||
agent: null,
|
|
||||||
};
|
|
||||||
mockUserFindUnique(null, fullUser);
|
|
||||||
prisma.user.findFirst.mockResolvedValue(null);
|
|
||||||
prisma.user.create.mockResolvedValue({ id: 'new-user' });
|
|
||||||
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
|
|
||||||
prisma.loyaltyAccount.create.mockResolvedValue({});
|
|
||||||
prisma.walletAccount.create.mockResolvedValue({});
|
|
||||||
prisma.userPreferences.create.mockResolvedValue({});
|
|
||||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
|
||||||
|
|
||||||
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
||||||
|
|
||||||
const result = await service.completeVerification({
|
|
||||||
code: 'c',
|
|
||||||
state: 'state-login',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
purpose: 'LOGIN',
|
|
||||||
verified: true,
|
|
||||||
token: 'signed.jwt.token',
|
|
||||||
user: { id: 'new-user', passengerId: 'p-new' },
|
|
||||||
});
|
|
||||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
faydaSub: 'login-sub-1',
|
|
||||||
faydaVerified: true,
|
|
||||||
email: 'new@example.com',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(prisma.passenger.create).toHaveBeenCalled();
|
|
||||||
expect(jwt.sign).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs in an existing user already linked by faydaSub', async () => {
|
|
||||||
const fullUser = {
|
|
||||||
id: 'known-user',
|
|
||||||
email: 'k@example.com',
|
|
||||||
role: 'PASSENGER',
|
|
||||||
passenger: { id: 'p-k' },
|
|
||||||
agent: null,
|
|
||||||
};
|
|
||||||
mockUserFindUnique({ id: 'known-user' }, fullUser);
|
|
||||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
|
||||||
|
|
||||||
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
|
|
||||||
|
|
||||||
const result = await service.completeVerification({
|
|
||||||
code: 'c',
|
|
||||||
state: 'state-login',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.user?.id).toBe('known-user');
|
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links Fayda to an existing account matched by email', async () => {
|
|
||||||
const fullUser = {
|
|
||||||
id: 'acc-1',
|
|
||||||
email: 'match@example.com',
|
|
||||||
role: 'PASSENGER',
|
|
||||||
passenger: { id: 'p-1' },
|
|
||||||
agent: null,
|
|
||||||
};
|
|
||||||
mockUserFindUnique(null, fullUser);
|
|
||||||
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
|
|
||||||
prisma.user.update.mockResolvedValue({});
|
|
||||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
|
||||||
|
|
||||||
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
|
|
||||||
|
|
||||||
const result = await service.completeVerification({
|
|
||||||
code: 'c',
|
|
||||||
state: 'state-login',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.user?.id).toBe('acc-1');
|
|
||||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'acc-1' },
|
|
||||||
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
|
|
||||||
mockUserFindUnique(null, null);
|
|
||||||
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
|
|
||||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.completeVerification({ code: 'c', state: 'state-login' }),
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
||||||
).rejects.toMatchObject({ status: 409 });
|
).rejects.toMatchObject({
|
||||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
status: 401,
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch the database for LOGIN purpose', async () => {
|
||||||
|
mockLoginFetch({ sub: 'login-sub-2', name: 'Person' });
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
expect(dataSource.query).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.passenger.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getVerificationStatus', () => {
|
describe('getVerificationStatus', () => {
|
||||||
it('returns verified=true when User row has the flag', async () => {
|
it('returns verified=true when IAM user metadata has the flag', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({
|
dataSource.query.mockResolvedValueOnce([{
|
||||||
faydaVerified: true,
|
metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' },
|
||||||
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
|
name: { en: 'Test User', am: 'ቴስት ዩዘር' },
|
||||||
fullName: 'Test User',
|
}]);
|
||||||
});
|
const result = await service.getVerificationStatus('iam-user-1');
|
||||||
const result = await service.getVerificationStatus('user-1');
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
verified: true,
|
verified: true,
|
||||||
verifiedAt: new Date('2026-01-01T00:00:00Z'),
|
verifiedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
@@ -503,9 +395,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns verified=false when User row is missing or unverified', async () => {
|
it('returns verified=false when IAM user is missing or unverified', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue(null);
|
dataSource.query.mockResolvedValueOnce([]);
|
||||||
const result = await service.getVerificationStatus('user-x');
|
const result = await service.getVerificationStatus('iam-user-x');
|
||||||
expect(result).toEqual({ verified: false });
|
expect(result).toEqual({ verified: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
import { generateClientAssertion } from './utils/client-assertion.util';
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
||||||
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
||||||
import {
|
import {
|
||||||
FaydaIdentityConflictException,
|
|
||||||
FaydaTokenExchangeException,
|
FaydaTokenExchangeException,
|
||||||
FaydaUserInfoException,
|
FaydaUserInfoException,
|
||||||
} from './verifayda.errors';
|
} from './verifayda.errors';
|
||||||
@@ -48,7 +46,7 @@ export interface VerifaydaVerificationResult {
|
|||||||
export interface StartVerificationInput {
|
export interface StartVerificationInput {
|
||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
platform?: FaydaPlatform;
|
platform?: FaydaPlatform;
|
||||||
userId?: string;
|
userId?: string; // iamUserId of the authenticated user, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FaydaUserSummary {
|
export interface FaydaUserSummary {
|
||||||
@@ -91,7 +89,7 @@ export class VerifaydaService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {
|
) {
|
||||||
const fayda = this.config.get<FaydaConfig>('fayda');
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||||
if (!fayda) {
|
if (!fayda) {
|
||||||
@@ -146,7 +144,7 @@ export class VerifaydaService {
|
|||||||
codeVerifier,
|
codeVerifier,
|
||||||
purpose: input.purpose,
|
purpose: input.purpose,
|
||||||
platform: input.platform ?? 'WEB',
|
platform: input.platform ?? 'WEB',
|
||||||
userId: input.userId ?? null,
|
iamUserId: input.userId ?? null,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -257,52 +255,25 @@ export class VerifaydaService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
|
|
||||||
private async issueLoginToken(
|
private async issueLoginToken(
|
||||||
userId: string,
|
_userId: string,
|
||||||
): Promise<{ token: string; user: FaydaUserSummary }> {
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
||||||
const user = await this.prisma.user.findUnique({
|
throw new UnauthorizedException({
|
||||||
where: { id: userId },
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
||||||
include: { passenger: true, agent: true },
|
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
|
||||||
});
|
});
|
||||||
if (!user) {
|
|
||||||
// Should not happen — we just resolved/created this user.
|
|
||||||
throw new UnauthorizedException({
|
|
||||||
code: 'FAYDA_LOGIN_FAILED',
|
|
||||||
message: 'Could not load the verified user',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary: FaydaUserSummary = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
passengerId: user.passenger?.id,
|
|
||||||
agentId: user.agent?.id,
|
|
||||||
};
|
|
||||||
const token = this.jwt.sign({
|
|
||||||
sub: summary.id,
|
|
||||||
email: summary.email,
|
|
||||||
role: summary.role,
|
|
||||||
passengerId: summary.passengerId,
|
|
||||||
agentId: summary.agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Fayda login issued token for user ${user.id}`);
|
|
||||||
return { token, user: summary };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
|
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
|
||||||
where: { id: userId },
|
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
|
[iamUserId],
|
||||||
});
|
);
|
||||||
|
const iam = rows[0] ?? null;
|
||||||
return {
|
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||||
verified: user?.faydaVerified ?? false,
|
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
|
||||||
verifiedAt: user?.faydaVerifiedAt ?? undefined,
|
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
|
||||||
fullName: user?.fullName ?? undefined,
|
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -428,106 +399,16 @@ export class VerifaydaService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
|
||||||
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
|
// This method is kept as a stub so completeVerification() still compiles;
|
||||||
* JWT via {@link issueLoginToken}). Resolution order:
|
// it throws immediately without touching the database.
|
||||||
* 1. Existing user already linked to this Fayda `sub`.
|
|
||||||
* 2. Existing account whose email/phone matches — linked to this `sub`.
|
|
||||||
* 3. Otherwise a fresh Fayda-backed account is created.
|
|
||||||
*/
|
|
||||||
private async handleLoginSuccess(
|
private async handleLoginSuccess(
|
||||||
normalized: NormalizedFaydaUserInfo,
|
_normalized: NormalizedFaydaUserInfo,
|
||||||
): Promise<{ userId: string }> {
|
): Promise<{ userId: string }> {
|
||||||
let userId: string;
|
throw new UnauthorizedException({
|
||||||
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
||||||
const bySub = await this.prisma.user.findUnique({
|
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
|
||||||
where: { faydaSub: normalized.sub },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bySub) {
|
|
||||||
userId = bySub.id;
|
|
||||||
} else {
|
|
||||||
const matchers: Array<{ email?: string; phone?: string }> = [];
|
|
||||||
if (normalized.email) matchers.push({ email: normalized.email });
|
|
||||||
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
|
|
||||||
|
|
||||||
const existing = matchers.length
|
|
||||||
? await this.prisma.user.findFirst({
|
|
||||||
where: { OR: matchers },
|
|
||||||
select: { id: true, faydaSub: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
|
|
||||||
// The matched account is already tied to a different Fayda identity.
|
|
||||||
throw new FaydaIdentityConflictException();
|
|
||||||
}
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
faydaSub: normalized.sub,
|
|
||||||
faydaVerified: true,
|
|
||||||
faydaVerifiedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
userId = existing.id;
|
|
||||||
this.logger.log(`Fayda login linked existing user ${existing.id}`);
|
|
||||||
} else {
|
|
||||||
userId = await this.createFaydaUser(normalized);
|
|
||||||
this.logger.log(`Fayda login created new user ${userId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Fayda-backed User plus the same satellite rows registration makes
|
|
||||||
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
|
|
||||||
*
|
|
||||||
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
|
|
||||||
* so password login is impossible; they authenticate only via Fayda. When
|
|
||||||
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
|
|
||||||
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
|
|
||||||
*/
|
|
||||||
private async createFaydaUser(
|
|
||||||
normalized: NormalizedFaydaUserInfo,
|
|
||||||
): Promise<string> {
|
|
||||||
const passwordHash = await bcrypt.hash(
|
|
||||||
randomBytes(32).toString('hex'),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
|
|
||||||
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
|
|
||||||
const fullName = normalized.fullName ?? 'Fayda User';
|
|
||||||
|
|
||||||
const user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
phone,
|
|
||||||
passwordHash,
|
|
||||||
faydaVerified: true,
|
|
||||||
faydaVerifiedAt: new Date(),
|
|
||||||
faydaSub: normalized.sub,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const passenger = await this.prisma.passenger.create({
|
|
||||||
data: { userId: user.id },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
await this.prisma.loyaltyAccount.create({
|
|
||||||
data: { passengerId: passenger.id },
|
|
||||||
});
|
|
||||||
await this.prisma.walletAccount.create({
|
|
||||||
data: { passengerId: passenger.id },
|
|
||||||
});
|
|
||||||
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
|
||||||
|
|
||||||
return user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markSessionFailed(
|
private async markSessionFailed(
|
||||||
@@ -548,7 +429,6 @@ export class VerifaydaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private classifyFailureReason(err: unknown): string {
|
private classifyFailureReason(err: unknown): string {
|
||||||
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
|
||||||
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||||
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
||||||
return 'verification_failed';
|
return 'verification_failed';
|
||||||
@@ -565,9 +445,8 @@ export class VerifaydaService {
|
|||||||
): Promise<VerifaydaVerificationResult> {
|
): Promise<VerifaydaVerificationResult> {
|
||||||
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
|
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
|
||||||
|
|
||||||
if (this.stubEnabled != false || this.stubEnabled) {
|
if (!this.stubEnabled) {
|
||||||
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)');
|
this.logger.warn('Verifayda not configured — returning mock data (development mode)');
|
||||||
// In development mode, return mock verified data
|
|
||||||
return {
|
return {
|
||||||
verified: true,
|
verified: true,
|
||||||
passengerData: {
|
passengerData: {
|
||||||
|
|||||||
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,
|
"incremental": true,
|
||||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||||
"paths": { "@/*": ["./src/*"] },
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": false
|
||||||
|
|||||||
@@ -2,15 +2,30 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Plus, Edit, DollarSign, Clock } from 'lucide-react';
|
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import { agentsApi } from '@/lib/api';
|
import { agentsApi } from '@/lib/api';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', active: '' });
|
const [filters, setFilters] = useState({ search: '', active: '' });
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['agents', filters],
|
queryKey: ['agents', filters],
|
||||||
@@ -30,7 +45,7 @@ export default function AgentsPage() {
|
|||||||
render: (agent: any) => (
|
render: (agent: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
|
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
|
||||||
<div className="text-sm text-gray-500">{agent.user?.email}</div>
|
<div className="text-sm text-muted-foreground">{agent.user?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -51,19 +66,21 @@ export default function AgentsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
|
{
|
||||||
|
label: 'View Details',
|
||||||
|
onClick: (agent: any) => setSelected(agent),
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'View Shifts',
|
label: 'View Shifts',
|
||||||
onClick: (agent: any) => {
|
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
|
||||||
window.location.href = `/agents/${agent.id}/shifts`;
|
|
||||||
},
|
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View Commissions',
|
label: 'View Commissions',
|
||||||
onClick: (agent: any) => {
|
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
|
||||||
window.location.href = `/agents/${agent.id}/commissions`;
|
|
||||||
},
|
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
},
|
},
|
||||||
@@ -119,6 +136,97 @@ export default function AgentsPage() {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No agents found"
|
emptyMessage="No agents found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Agent Details Modal */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
||||||
|
{selected && (() => {
|
||||||
|
const a = selected;
|
||||||
|
const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="from-emerald-600 to-emerald-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-white text-xl font-bold">{initials}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-xl font-bold truncate">{a.user?.fullName || 'N/A'}</p>
|
||||||
|
<p className="text-emerald-200 text-sm font-mono">{a.agentCode}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{a.active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Agent Code', value: a.agentCode || '—' },
|
||||||
|
{ label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` },
|
||||||
|
{ label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-emerald-200 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Agent Information" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Agent Code" value={a.agentCode} mono />
|
||||||
|
<Field label="Commission Rate" value={`${a.commissionRate ?? 0}%`} />
|
||||||
|
<Field label="Counter Location" value={a.counterLocation || a.location || 'N/A'} />
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||||
|
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{a.active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="User Account" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Full Name" value={a.user?.fullName} />
|
||||||
|
<Field label="Email" value={a.user?.email} truncate />
|
||||||
|
<Field label="Phone" value={a.user?.phone} />
|
||||||
|
<Field label="Role" value={a.user?.role || 'AGENT'} />
|
||||||
|
<Field label="User ID" value={a.userId || a.user?.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Performance" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Total Bookings" value={(a.totalBookings ?? 0).toLocaleString()} />
|
||||||
|
<Field label="Total Revenue" value={a.totalRevenue ? formatCurrency(a.totalRevenue, 'ETB') : 'N/A'} />
|
||||||
|
<Field label="Total Commission" value={a.totalCommission ? formatCurrency(a.totalCommission, 'ETB') : 'N/A'} />
|
||||||
|
<Field label="Pending Commission" value={a.pendingCommission ? formatCurrency(a.pendingCommission, 'ETB') : 'N/A'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Timestamps & IDs" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Agent Since" value={formatDateTime(a.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
|
||||||
|
<Field label="Agent ID" value={a.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
|
|||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDetailsModal}
|
isOpen={showDetailsModal}
|
||||||
onClose={() => {
|
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
|
||||||
setShowDetailsModal(false);
|
title="Audit Log Details"
|
||||||
setSelectedLog(null);
|
size="xl"
|
||||||
}}
|
|
||||||
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
|
|
||||||
size="lg"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
{selectedLog && (() => {
|
||||||
{/* Basic Info */}
|
const l = selectedLog;
|
||||||
<div className="grid grid-cols-2 gap-4">
|
const actionColor: Record<string, string> = {
|
||||||
<div>
|
CREATE: 'from-emerald-600 to-emerald-700',
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
|
UPDATE: 'from-blue-600 to-blue-700',
|
||||||
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
|
DELETE: 'from-red-600 to-red-700',
|
||||||
</div>
|
LOGIN: 'from-violet-600 to-violet-700',
|
||||||
<div>
|
LOGOUT: 'from-gray-600 to-gray-700',
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Action</label>
|
};
|
||||||
<p className="text-sm mt-1">
|
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
|
||||||
<Badge className={getActionBadgeColor(selectedLog?.action)}>
|
|
||||||
{selectedLog?.action}
|
|
||||||
</Badge>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
|
|
||||||
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
|
|
||||||
<p className="text-sm mt-1 font-mono text-muted-foreground">
|
|
||||||
{selectedLog?.entityId || 'System'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Info */}
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
{selectedLog?.user && (
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
<div className="border-t pt-4">
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
<h4 className="text-sm font-semibold mb-2">User Information</h4>
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</div>
|
||||||
<div>
|
);
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Name</label>
|
|
||||||
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
|
||||||
|
<p className="text-white text-2xl font-bold">{l.action}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
|
||||||
|
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Email</label>
|
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
|
<p className="text-white/70 text-xs">User</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-white/70 text-xs">IP Address</p>
|
||||||
|
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Network Info */}
|
<div className="space-y-6">
|
||||||
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
|
<section>
|
||||||
<div className="border-t pt-4">
|
<SectionHeader title="Event Details" />
|
||||||
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="space-y-2">
|
<Field label="Action" value={l.action} />
|
||||||
{selectedLog?.ipAddress && (
|
<Field label="Entity Type" value={l.entityType} mono />
|
||||||
<div>
|
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
|
||||||
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
|
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
|
||||||
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{l.user && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="User Information" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Full Name" value={l.user.fullName} />
|
||||||
|
<Field label="Email" value={l.user.email} truncate />
|
||||||
|
<Field label="User ID" value={l.userId} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
{selectedLog?.userAgent && (
|
|
||||||
<div>
|
{(l.ipAddress || l.userAgent) && (
|
||||||
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
|
<section>
|
||||||
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
|
<SectionHeader title="Network Information" />
|
||||||
{selectedLog?.userAgent}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
</p>
|
<Field label="IP Address" value={l.ipAddress} mono />
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
|
||||||
|
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(l.oldData || l.newData) && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Data Changes" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{l.oldData && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">← Before</p>
|
||||||
|
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||||
|
{formatJsonData(l.oldData)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{l.newData && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">→ After</p>
|
||||||
|
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||||
|
{formatJsonData(l.newData)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="System" />
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<Field label="Log ID" value={l.id} mono truncate />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{/* Changes */}
|
|
||||||
{(selectedLog?.oldData || selectedLog?.newData) && (
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{selectedLog?.oldData && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-red-600">Old Data</label>
|
|
||||||
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
|
|
||||||
{formatJsonData(selectedLog?.oldData)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedLog?.newData && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-green-600">New Data</label>
|
|
||||||
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
|
|
||||||
{formatJsonData(selectedLog?.newData)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Raw Log ID */}
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
|
|
||||||
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,37 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react';
|
import { AlertTriangle, CheckCircle, Ban, Eye } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import { fraudApi } from '@/lib/api';
|
import { fraudApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SEVERITY_GRAD: Record<string, string> = {
|
||||||
|
CRITICAL: 'from-red-700 to-red-800',
|
||||||
|
HIGH: 'from-red-600 to-red-700',
|
||||||
|
MEDIUM: 'from-amber-500 to-amber-600',
|
||||||
|
LOW: 'from-blue-500 to-blue-600',
|
||||||
|
};
|
||||||
|
|
||||||
export default function FraudDetectionPage() {
|
export default function FraudDetectionPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
|
|||||||
|
|
||||||
const handleBlockUser = async (alert: any) => {
|
const handleBlockUser = async (alert: any) => {
|
||||||
if (confirm(`Block user ${alert.user?.email}?`)) {
|
if (confirm(`Block user ${alert.user?.email}?`)) {
|
||||||
await blockUserMutation.mutateAsync({
|
await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
|
||||||
userId: alert.userId,
|
|
||||||
reason: `Fraud alert: ${alert.ruleType}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
|
|||||||
key: 'severity',
|
key: 'severity',
|
||||||
label: 'Severity',
|
label: 'Severity',
|
||||||
render: (alert: any) => (
|
render: (alert: any) => (
|
||||||
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
<Badge variant="status" status={alert.severity === 'HIGH' || alert.severity === 'CRITICAL' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
||||||
{alert.severity}
|
{alert.severity}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
|
|||||||
label: 'Rule Type',
|
label: 'Rule Type',
|
||||||
render: (alert: any) => (
|
render: (alert: any) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" />
|
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
<span>{alert.ruleType}</span>
|
<span>{alert.ruleType}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
|
|||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
label: 'Description',
|
label: 'Description',
|
||||||
render: (alert: any) => (
|
render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
|
||||||
<span className="text-sm">{alert.description || alert.details}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
|
{
|
||||||
|
label: 'View Details',
|
||||||
|
onClick: (alert: any) => setSelected(alert),
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Acknowledge',
|
label: 'Acknowledge',
|
||||||
onClick: handleAcknowledge,
|
onClick: handleAcknowledge,
|
||||||
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Search</label>
|
<label className="label">Search</label>
|
||||||
<input
|
<input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||||
type="text"
|
|
||||||
placeholder="Search alerts..."
|
|
||||||
className="input"
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Severity</label>
|
<label className="label">Severity</label>
|
||||||
<select
|
<select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
|
||||||
className="input"
|
|
||||||
value={filters.severity}
|
|
||||||
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">All Severities</option>
|
<option value="">All Severities</option>
|
||||||
<option value="LOW">Low</option>
|
<option value="LOW">Low</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Status</label>
|
<label className="label">Status</label>
|
||||||
<select
|
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||||||
className="input"
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="acknowledged">Acknowledged</option>
|
<option value="acknowledged">Acknowledged</option>
|
||||||
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No fraud alerts found"
|
emptyMessage="No fraud alerts found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Fraud Alert Details Modal */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Fraud Alert Details" size="xl">
|
||||||
|
{selected && (() => {
|
||||||
|
const al = selected;
|
||||||
|
const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Fraud Alert</p>
|
||||||
|
<p className="text-white text-xl font-bold">{al.ruleType}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 space-y-1">
|
||||||
|
<Badge variant="status" status={al.severity === 'HIGH' || al.severity === 'CRITICAL' ? 'CANCELLED' : al.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
||||||
|
{al.severity}
|
||||||
|
</Badge>
|
||||||
|
<div>
|
||||||
|
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
||||||
|
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/70 text-xs">{formatDateTime(al.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Severity', value: al.severity || '—' },
|
||||||
|
{ label: 'Rule Type', value: al.ruleType || '—' },
|
||||||
|
{ label: 'User', value: al.user?.fullName || al.user?.email || '—' },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-white/70 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Alert Details" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Rule Type" value={al.ruleType} />
|
||||||
|
<Field label="Severity" value={al.severity} />
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||||
|
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
||||||
|
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Field label="Detected At" value={formatDateTime(al.createdAt)} />
|
||||||
|
<div className="col-span-2 md:col-span-4 bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Description</p>
|
||||||
|
<p className="text-sm font-medium">{al.description || al.details || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Flagged User" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Full Name" value={al.user?.fullName} />
|
||||||
|
<Field label="Email" value={al.user?.email} truncate />
|
||||||
|
<Field label="Phone" value={al.user?.phone} />
|
||||||
|
<Field label="User ID" value={al.userId || al.user?.id} mono truncate />
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Blocked</p>
|
||||||
|
<Badge variant="status" status={al.user?.isBlocked ? 'CANCELLED' : 'CONFIRMED'}>
|
||||||
|
{al.user?.isBlocked ? 'Blocked' : 'Not Blocked'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{al.bookingId && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Related Booking" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Booking ID" value={al.bookingId} mono truncate />
|
||||||
|
<Field label="Booking Ref" value={al.booking?.bookingRef} mono />
|
||||||
|
<Field label="Amount" value={al.booking?.totalMinor ? `ETB ${(al.booking.totalMinor / 100).toFixed(2)}` : 'N/A'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{al.acknowledged && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Resolution" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Acknowledged At" value={al.acknowledgedAt ? formatDateTime(al.acknowledgedAt) : '—'} />
|
||||||
|
<Field label="Acknowledged By" value={al.acknowledgedBy?.fullName || al.acknowledgedBy?.email || '—'} />
|
||||||
|
<Field label="Notes" value={al.resolutionNotes || '—'} truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="System" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Alert ID" value={al.id} mono truncate />
|
||||||
|
<Field label="Created" value={formatDateTime(al.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(al.updatedAt)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useTheme } from '@/lib/theme-store';
|
import { useTheme } from '@/lib/theme-store';
|
||||||
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
|
import {
|
||||||
|
Eye, EyeOff, Sun, Moon, ArrowRight, Loader2,
|
||||||
|
TicketCheck, Users, TrendingUp, ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const EDR_GREEN = 'rgb(20, 113, 76)';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' },
|
||||||
|
{ icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' },
|
||||||
|
{ icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' },
|
||||||
|
{ icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const router = useRouter();
|
const [emailFocused, setEmailFocused] = useState(false);
|
||||||
const { login } = useAuthStore();
|
const [passwordFocused, setPasswordFocused] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuthStore();
|
||||||
const { isDark, toggleTheme } = useTheme();
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { setIsMounted(true); }, []);
|
||||||
setIsMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.';
|
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
|
||||||
setError(message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
|
<div className="flex min-h-screen bg-white dark:bg-gray-950">
|
||||||
{/* Full Screen Banner Background */}
|
|
||||||
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
|
|
||||||
|
|
||||||
{/* Content Overlay */}
|
{/* ── LEFT PANEL — form ── */}
|
||||||
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
|
<div className="flex-1 lg:flex-none lg:w-[42%] xl:w-[38%] flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 relative">
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
{/* Login Card with Shadow */}
|
{/* Top bar */}
|
||||||
<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">
|
<div className="flex items-center justify-between px-8 py-4 lg:px-10">
|
||||||
{/* Card Header with Logo, App Name and Theme Toggle */}
|
{/* Logo — always visible on the form panel */}
|
||||||
<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-2.5">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<Train className="h-9 w-9 text-white" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
|
||||||
<div>
|
<circle cx="9" cy="9" r="1" fill="currentColor" />
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
|
<circle cx="15" cy="9" r="1" fill="currentColor" />
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
|
||||||
<button
|
<div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
|
||||||
onClick={toggleTheme}
|
</div>
|
||||||
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
</div>
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
<button
|
||||||
{isDark ? (
|
onClick={toggleTheme}
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
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"
|
||||||
<Moon className="w-5 h-5 text-gray-700" />
|
>
|
||||||
)}
|
{isDark
|
||||||
</button>
|
? <Sun className="w-4 h-4 text-amber-400" />
|
||||||
|
: <Moon className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form area */}
|
||||||
|
<div className="flex-1 flex items-center justify-center px-8 py-10 lg:px-10 xl:px-14">
|
||||||
|
<div className="w-full max-w-xs">
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||||
|
Sign in to continue
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Enter your credentials to access the back-office.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Body */}
|
{/* Error */}
|
||||||
<div className="p-6">
|
{error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
|
||||||
<div className="mb-8">
|
<div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
|
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
||||||
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
|
<span className="text-white text-[10px] font-bold">!</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
|
|
||||||
{error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
</div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
|
{/* Email field */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||||
|
emailFocused
|
||||||
|
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
||||||
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||||
|
}`}>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
onFocus={() => setEmailFocused(true)}
|
||||||
placeholder="name@email.com"
|
onBlur={() => setEmailFocused(false)}
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||||
|
placeholder="name@edr.com"
|
||||||
required
|
required
|
||||||
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Password field */}
|
||||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
|
<div>
|
||||||
<div className="relative">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<input
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||||
type={showPassword ? 'text' : 'password'}
|
Password
|
||||||
value={password}
|
</label>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<button
|
||||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
type="button"
|
||||||
placeholder="••••••••"
|
className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
|
||||||
required
|
>
|
||||||
/>
|
Forgot password?
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
|
||||||
aria-label="Toggle password visibility"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||||
|
passwordFocused
|
||||||
|
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
||||||
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||||
|
onFocus={() => setPasswordFocused(true)}
|
||||||
|
onBlur={() => setPasswordFocused(false)}
|
||||||
|
className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||||
|
placeholder="••••••••••"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Submit */}
|
||||||
type="submit"
|
<button
|
||||||
disabled={loading}
|
type="submit"
|
||||||
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
|
disabled={loading || !email || !password}
|
||||||
>
|
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
style={{ background: loading || !email || !password
|
||||||
</button>
|
? 'rgb(20,113,76)'
|
||||||
</form>
|
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Signing in…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Sign in
|
||||||
|
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800/60 animate-fade-up" style={{ animationDelay: '160ms' }}>
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||||
|
Access is restricted to authorised EDR staff only. All sessions are logged and audited.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="px-8 py-4 lg:px-10 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
Back-office · v1.0
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── RIGHT PANEL — photo ── */}
|
||||||
|
<div className="hidden lg:flex flex-1 relative flex-col overflow-hidden">
|
||||||
|
{/* Layer 1 — base photo, desaturated */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url('/banner.jpg')",
|
||||||
|
filter: isDark
|
||||||
|
? 'saturate(0.1) brightness(1)'
|
||||||
|
: 'saturate(0.15) brightness(1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Layer 2 — brand green color wash */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(145deg, rgb(5,46,30) 0%, rgb(20,113,76) 55%, rgb(4,120,67) 100%)',
|
||||||
|
mixBlendMode: 'multiply',
|
||||||
|
opacity: isDark ? 0.8 : 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-3 py-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
<span className="text-white/80 text-xs font-medium tracking-wide">Back-office Portal v1.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero text */}
|
||||||
|
<div className="mt-auto mb-auto">
|
||||||
|
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-4">
|
||||||
|
Passenger<br />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">
|
||||||
|
Management
|
||||||
|
</span>
|
||||||
|
<br />System
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60 text-base leading-relaxed max-w-sm">
|
||||||
|
Unified platform for booking operations, passenger services, revenue analytics, and real-time train management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature grid */}
|
||||||
|
<div className="mt-auto grid grid-cols-2 gap-3">
|
||||||
|
{features.map(({ icon: Icon, label, desc }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-start gap-3 bg-white/5 hover:bg-white/10 backdrop-blur-sm border border-white/10 rounded-xl p-3.5 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[rgb(20,113,76)]/40 flex items-center justify-center">
|
||||||
|
<Icon className="w-4 h-4 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-white text-xs font-semibold">{label}</div>
|
||||||
|
<div className="text-white/40 text-xs mt-0.5">{desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom rule */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-white/10">
|
||||||
|
<span className="text-white/30 text-xs block">© 2026 Ethio-Djibouti Railway S.C. — Secure · Encrypted · Monitored</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,44 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Eye, Star } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import { loyaltyApi } from '@/lib/api';
|
import { loyaltyApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<string, string> = {
|
||||||
|
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
|
||||||
|
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
|
||||||
|
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||||
|
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_GRAD: Record<string, string> = {
|
||||||
|
BRONZE: 'from-orange-500 to-orange-600',
|
||||||
|
SILVER: 'from-gray-500 to-gray-600',
|
||||||
|
GOLD: 'from-yellow-500 to-yellow-600',
|
||||||
|
PLATINUM: 'from-indigo-600 to-indigo-700',
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoyaltyPage() {
|
export default function LoyaltyPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', tier: '' });
|
const [filters, setFilters] = useState({ search: '', tier: '' });
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['loyalty', filters],
|
queryKey: ['loyalty', filters],
|
||||||
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
|
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
|
||||||
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> },
|
<div>
|
||||||
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 },
|
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
|
||||||
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 },
|
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
|
||||||
];
|
</div>
|
||||||
|
)},
|
||||||
|
{ key: 'tier', label: 'Tier', render: (account: any) => (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${TIER_COLORS[account.tier] || TIER_COLORS.BRONZE}`}>
|
||||||
|
<Star className="w-3 h-3" />{account.tier}
|
||||||
|
</span>
|
||||||
|
)},
|
||||||
|
{ key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() },
|
||||||
|
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
<div>
|
<label className="label">Search</label>
|
||||||
<label className="label">Search</label>
|
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="label">Tier</label>
|
||||||
<label className="label">Tier</label>
|
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
|
||||||
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
|
<option value="">All Tiers</option>
|
||||||
<option value="">All Tiers</option>
|
<option value="BRONZE">Bronze</option>
|
||||||
<option value="BRONZE">Bronze</option>
|
<option value="SILVER">Silver</option>
|
||||||
<option value="SILVER">Silver</option>
|
<option value="GOLD">Gold</option>
|
||||||
<option value="GOLD">Gold</option>
|
<option value="PLATINUM">Platinum</option>
|
||||||
<option value="PLATINUM">Platinum</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
data={Array.isArray(data) ? data : (data?.items || [])}
|
data={Array.isArray(data) ? data : (data?.items || [])}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No loyalty program found"
|
emptyMessage="No loyalty accounts found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Loyalty Details Modal */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Loyalty Account Details" size="xl">
|
||||||
|
{selected && (() => {
|
||||||
|
const a = selected;
|
||||||
|
const tier = a.tier || 'BRONZE';
|
||||||
|
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
|
||||||
|
const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700';
|
||||||
|
const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||||
|
<Star className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
|
||||||
|
<p className="text-white/70 text-sm">{a.passenger?.email || a.user?.email || ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs font-bold px-3 py-1 rounded-full border ${tierColor}`}>
|
||||||
|
<Star className="w-3 h-3" />{tier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() },
|
||||||
|
{ label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() },
|
||||||
|
{ label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-white/70 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Account Overview" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Current Tier" value={tier} />
|
||||||
|
<Field label="Points Balance" value={(a.pointsBalance ?? 0).toLocaleString()} />
|
||||||
|
<Field label="Lifetime Points" value={(a.lifetimePoints ?? 0).toLocaleString()} />
|
||||||
|
<Field label="Points Redeemed" value={(a.pointsRedeemed ?? 0).toLocaleString()} />
|
||||||
|
<Field label="Points Expiring" value={a.pointsExpiring ? a.pointsExpiring.toLocaleString() : 'N/A'} />
|
||||||
|
<Field label="Expiry Date" value={a.expiryDate ? formatDateTime(a.expiryDate) : 'N/A'} />
|
||||||
|
<Field label="Tier Since" value={a.tierAchievedAt ? formatDateTime(a.tierAchievedAt) : 'N/A'} />
|
||||||
|
<Field label="Next Tier" value={a.nextTier || 'N/A'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Passenger" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Full Name" value={a.passenger?.fullName || a.user?.fullName} />
|
||||||
|
<Field label="Email" value={a.passenger?.email || a.user?.email} truncate />
|
||||||
|
<Field label="Phone" value={a.passenger?.phone || a.user?.phone} />
|
||||||
|
<Field label="Passenger ID" value={a.passengerId || a.passenger?.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Timestamps & IDs" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Account Created" value={formatDateTime(a.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
|
||||||
|
<Field label="Account ID" value={a.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Eye } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import { paymentsApi } from '@/lib/api';
|
import { paymentsApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '\u2014'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
export default function PaymentsPage() {
|
export default function PaymentsPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||||
|
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||||
const [exportDateTo, setExportDateTo] = useState('');
|
const [exportDateTo, setExportDateTo] = useState('');
|
||||||
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
|
|||||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const paymentActions = [
|
||||||
|
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
actions={[]}
|
actions={paymentActions}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No payments found"
|
emptyMessage="No payments found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Payment Details Modal */}
|
||||||
|
<Modal isOpen={!!selectedPayment} onClose={() => setSelectedPayment(null)} title="Payment Details" size="xl">
|
||||||
|
{selectedPayment && (() => {
|
||||||
|
const p = selectedPayment;
|
||||||
|
const statusGrad: Record<string, string> = {
|
||||||
|
COMPLETED: 'from-emerald-600 to-emerald-700',
|
||||||
|
FAILED: 'from-red-600 to-red-700',
|
||||||
|
PENDING: 'from-amber-500 to-amber-600',
|
||||||
|
REFUNDED: 'from-blue-600 to-blue-700',
|
||||||
|
};
|
||||||
|
const grad = statusGrad[p.status] || 'from-gray-600 to-gray-700';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Payment Reference</p>
|
||||||
|
<p className="text-white text-2xl font-mono font-bold">{p.reference || p.id?.substring(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<Badge variant="status" status={p.status}>{p.status}</Badge>
|
||||||
|
<p className="text-white/70 text-xs mt-1">{formatDateTime(p.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
|
||||||
|
{ label: 'Method', value: p.method || '—' },
|
||||||
|
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-white/70 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Transaction" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Amount</p>
|
||||||
|
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
|
||||||
|
</div>
|
||||||
|
<Field label="Method" value={p.method} />
|
||||||
|
<Field label="Status" value={p.status} />
|
||||||
|
<Field label="Reference" value={p.reference} mono truncate />
|
||||||
|
<Field label="Provider Ref" value={p.providerReference || p.externalReference} mono truncate />
|
||||||
|
<Field label="Created" value={formatDateTime(p.createdAt)} />
|
||||||
|
<Field label="Completed At" value={p.completedAt ? formatDateTime(p.completedAt) : 'N/A'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Booking" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Booking Ref" value={p.booking?.bookingRef} mono />
|
||||||
|
<Field label="Booking Status" value={p.booking?.status} />
|
||||||
|
<Field label="Passenger" value={p.booking?.passenger?.fullName || p.booking?.contactEmail} truncate />
|
||||||
|
<Field label="Booking ID" value={p.bookingId} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(p.failureReason || p.failureCode) && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Failure Information" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Failure Code" value={p.failureCode} mono />
|
||||||
|
<Field label="Failure Reason" value={p.failureReason} truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="IDs" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="Payment ID" value={p.id} mono truncate />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(p.updatedAt)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelectedPayment(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Export Modal */}
|
{/* Export Modal */}
|
||||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Save, Users } from 'lucide-react';
|
import { Save } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import { systemConfigApi } from '@/lib/api';
|
||||||
|
|
||||||
|
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general');
|
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||||
|
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [configMessage, setConfigMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'configurations') return;
|
||||||
|
setConfigLoading(true);
|
||||||
|
systemConfigApi.getAll()
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setConfigLoading(false));
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const saveConfigurations = async () => {
|
||||||
|
setConfigSaving(true);
|
||||||
|
setConfigMessage('');
|
||||||
|
try {
|
||||||
|
await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes });
|
||||||
|
setConfigMessage('Saved successfully.');
|
||||||
|
} catch {
|
||||||
|
setConfigMessage('Failed to save.');
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'payment', label: 'Payment' },
|
||||||
|
{ id: 'integrations', label: 'Integrations' },
|
||||||
|
{ id: 'configurations', label: 'Configurations' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -14,31 +51,24 @@ export default function SettingsPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||||
<p className="text-muted-foreground">Manage system settings and configurations</p>
|
<p className="text-muted-foreground">Manage system settings and configurations</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary flex items-center gap-2">
|
{activeTab !== 'configurations' && (
|
||||||
<Save className="h-4 w-4" />
|
<button className="btn btn-primary flex items-center gap-2">
|
||||||
Save Changes
|
<Save className="h-4 w-4" />
|
||||||
</button>
|
Save Changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b border-border">
|
<div className="flex gap-2 border-b border-border">
|
||||||
<button
|
{tabs.map((tab) => (
|
||||||
onClick={() => setActiveTab('general')}
|
<button
|
||||||
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
key={tab.id}
|
||||||
>
|
onClick={() => setActiveTab(tab.id)}
|
||||||
General
|
className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
||||||
</button>
|
>
|
||||||
<button
|
{tab.label}
|
||||||
onClick={() => setActiveTab('payment')}
|
</button>
|
||||||
className={`px-4 py-2 font-medium ${activeTab === 'payment' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
))}
|
||||||
>
|
|
||||||
Payment
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('integrations')}
|
|
||||||
className={`px-4 py-2 font-medium ${activeTab === 'integrations' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
|
||||||
>
|
|
||||||
Integrations
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
@@ -139,6 +169,46 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'configurations' && (
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
|
||||||
|
{configLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-sm space-y-2">
|
||||||
|
<label className="label" htmlFor="hold-duration">
|
||||||
|
Seat Hold Duration (minutes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hold-duration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
className="input"
|
||||||
|
value={seatHoldMinutes}
|
||||||
|
onChange={(e) => setSeatHoldMinutes(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary flex items-center gap-2"
|
||||||
|
onClick={saveConfigurations}
|
||||||
|
disabled={configSaving || configLoading}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{configSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
{configMessage && (
|
||||||
|
<span className="text-sm text-muted-foreground">{configMessage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ export default function TicketsPage() {
|
|||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||||
const [exportDateTo, setExportDateTo] = useState('');
|
const [exportDateTo, setExportDateTo] = useState('');
|
||||||
@@ -534,111 +547,116 @@ export default function TicketsPage() {
|
|||||||
isOpen={detailsModalOpen}
|
isOpen={detailsModalOpen}
|
||||||
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
||||||
title="Ticket Details"
|
title="Ticket Details"
|
||||||
size="lg"
|
size="xl"
|
||||||
>
|
>
|
||||||
{selectedTicket && (
|
{selectedTicket && (() => {
|
||||||
<div className="space-y-6">
|
const t = selectedTicket;
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
const b = t.booking;
|
||||||
<div>
|
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
<p className="text-sm text-muted-foreground">Ticket Number</p>
|
const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
|
||||||
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
|
return (
|
||||||
</div>
|
<div>
|
||||||
<div>
|
{/* Gradient header */}
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||||
<div className="mt-1">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
|
|
||||||
{selectedTicket.status || 'ACTIVE'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-semibold mb-3">Booking Information</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Booking Reference</p>
|
|
||||||
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Passenger</p>
|
|
||||||
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Amount</p>
|
|
||||||
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-semibold mb-3">Trip Information</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Route</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{selectedTicket.schedule?.originStation?.name || 'N/A'} → {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Departure</p>
|
|
||||||
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-semibold mb-3">Seat Information</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Coach</p>
|
|
||||||
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Seat Number</p>
|
|
||||||
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Class</p>
|
|
||||||
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTicket.validatedAt && (
|
|
||||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
|
||||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
|
||||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-right shrink-0">
|
||||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
|
||||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
|
||||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Passenger', value: passengerName },
|
||||||
|
{ label: 'Route', value: `${t.schedule?.originStation?.name || '?'} → ${t.schedule?.destinationStation?.name || '?'}` },
|
||||||
|
{ label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-emerald-200 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="space-y-6">
|
||||||
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
|
{/* Booking */}
|
||||||
Close
|
<section>
|
||||||
</ActionButton>
|
<SectionHeader title="Booking Information" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Booking Ref" value={b?.bookingRef} mono />
|
||||||
|
<Field label="Booking Type" value={(b?.bookingType || 'ONE_WAY').replace(/_/g, ' ')} />
|
||||||
|
<Field label="Payment Status" value={b?.paymentIntent?.status || 'N/A'} />
|
||||||
|
<Field label="Contact Phone" value={b?.contactPhone || b?.passenger?.phone} />
|
||||||
|
<Field label="Contact Email" value={b?.contactEmail || b?.passenger?.email} truncate />
|
||||||
|
<Field label="Adults" value={String(b?.adultCount ?? 0)} />
|
||||||
|
<Field label="Children" value={String(b?.childCount ?? 0)} />
|
||||||
|
<Field label="Booking ID" value={b?.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Trip */}
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Trip Information" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Origin" value={t.schedule?.originStation?.name} />
|
||||||
|
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
|
||||||
|
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
|
||||||
|
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
|
||||||
|
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
|
||||||
|
<Field label="Schedule ID" value={t.scheduleId} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Seat */}
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Seat Information" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2 md:col-span-1 flex flex-col items-center justify-center">
|
||||||
|
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Seat</p>
|
||||||
|
<p className="text-2xl font-mono font-bold text-emerald-800 dark:text-emerald-300">{t.seat?.seatNumber || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<Field label="Coach" value={t.seat?.coach?.number} mono />
|
||||||
|
<Field label="Class" value={t.seat?.coach?.coachType?.name || t.seat?.coach?.coachType?.type} />
|
||||||
|
<Field label="Seat ID" value={t.seatId} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Round-trip */}
|
||||||
|
{isRoundTrip && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Round-Trip Legs" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Leg Status" value={(b?.returnLegStatus || '—').replace(/_/g, ' ')} />
|
||||||
|
<Field label="Outbound Boarded" value={b?.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
|
||||||
|
<Field label="Return Boarded" value={b?.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation */}
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Validation & Timestamps" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
|
||||||
|
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
|
||||||
|
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
|
||||||
|
<Field label="Created" value={formatDateTime(t.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
|
||||||
|
<Field label="Ticket ID" value={t.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Export Modal */}
|
{/* Export Modal */}
|
||||||
|
|||||||
@@ -2,15 +2,30 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Eye, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import { verifaydaApi } from '@/lib/api';
|
import { verifaydaApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
export default function VerifaydaPage() {
|
export default function VerifaydaPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', verified: '' });
|
const [filters, setFilters] = useState({ search: '', verified: '' });
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['verifayda', filters],
|
queryKey: ['verifayda', filters],
|
||||||
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
|
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
|
||||||
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' },
|
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || ver.returnedName || 'N/A' },
|
||||||
{ key: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> },
|
{ key: 'verified', label: 'Status', render: (ver: any) => (
|
||||||
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
|
<Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
];
|
{ver.verified ? 'Verified' : 'Failed'}
|
||||||
|
</Badge>
|
||||||
|
)},
|
||||||
|
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: 'View Details', onClick: (v: any) => setSelected(v), variant: 'secondary' as const, icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
<div>
|
<label className="label">Search</label>
|
||||||
<label className="label">Search</label>
|
<input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||||
<input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="label">Status</label>
|
||||||
<label className="label">Status</label>
|
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
|
||||||
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
|
<option value="">All</option>
|
||||||
<option value="">All</option>
|
<option value="true">Verified</option>
|
||||||
<option value="true">Verified</option>
|
<option value="false">Failed</option>
|
||||||
<option value="false">Failed</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data?.items || data || []}
|
data={data?.items || data || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No verifayda integration found"
|
emptyMessage="No verification records found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Verifayda Details Modal */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Verification Details" size="xl">
|
||||||
|
{selected && (() => {
|
||||||
|
const v = selected;
|
||||||
|
const isVerified = !!v.verified;
|
||||||
|
const grad = isVerified ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700';
|
||||||
|
const name = v.fullName || v.returnedName || 'N/A';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||||
|
{isVerified
|
||||||
|
? <ShieldCheck className="w-7 h-7 text-white" />
|
||||||
|
: <ShieldOff className="w-7 h-7 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-xl font-bold truncate">{name}</p>
|
||||||
|
<p className="text-white/70 text-sm font-mono">{v.nationalId}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{isVerified ? '✓ Verified' : '✗ Failed'}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-white/70 text-xs mt-1">{formatDateTime(v.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'National ID', value: v.nationalId || '—' },
|
||||||
|
{ label: 'Date of Birth', value: v.dateOfBirth || v.returnedDob || '—' },
|
||||||
|
{ label: 'Nationality', value: v.nationality || 'Ethiopian' },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-white/70 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Verification Result" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isVerified
|
||||||
|
? <ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||||
|
: <ShieldOff className="w-4 h-4 text-red-500 shrink-0" />}
|
||||||
|
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{isVerified ? 'Verified' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label="Verified At" value={formatDateTime(v.createdAt)} />
|
||||||
|
<Field label="Failure Reason" value={v.failureReason || (isVerified ? 'N/A' : 'Verification failed')} truncate />
|
||||||
|
<Field label="Response Code" value={v.responseCode || 'N/A'} mono />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Identity Data (from Fayda)" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="National ID" value={v.nationalId} mono />
|
||||||
|
<Field label="Full Name" value={v.fullName || v.returnedName} />
|
||||||
|
<Field label="Date of Birth" value={v.dateOfBirth || v.returnedDob} />
|
||||||
|
<Field label="Gender" value={v.gender || v.returnedGender} />
|
||||||
|
<Field label="Nationality" value={v.nationality || 'Ethiopian'} />
|
||||||
|
<Field label="Phone" value={v.phone || v.returnedPhone} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Linked Passenger" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Passenger Name" value={v.passenger?.fullName || v.user?.fullName} />
|
||||||
|
<Field label="Email" value={v.passenger?.email || v.user?.email} truncate />
|
||||||
|
<Field label="Passenger ID" value={v.passengerId || v.passenger?.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="System" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Record ID" value={v.id} mono truncate />
|
||||||
|
<Field label="Created" value={formatDateTime(v.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(v.updatedAt)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,30 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Eye, Wallet } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import { walletApi } from '@/lib/api';
|
import { walletApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WalletPage() {
|
export default function WalletPage() {
|
||||||
const [filters, setFilters] = useState({ search: '' });
|
const [filters, setFilters] = useState({ search: '' });
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['wallet', filters],
|
queryKey: ['wallet', filters],
|
||||||
@@ -18,10 +33,25 @@ export default function WalletPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
|
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
|
||||||
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') },
|
<div>
|
||||||
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> },
|
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
|
||||||
];
|
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => (
|
||||||
|
<span className="font-semibold">{formatCurrency(account.balanceMinor, account.currency || 'ETB')}</span>
|
||||||
|
)},
|
||||||
|
{ key: 'status', label: 'Status', render: (account: any) => (
|
||||||
|
<Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{account.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
)},
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -35,21 +65,113 @@ export default function WalletPage() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
<div>
|
<label className="label">Search</label>
|
||||||
<label className="label">Search</label>
|
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data?.items || data || []}
|
data={data?.items || data || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No wallet management found"
|
emptyMessage="No wallet accounts found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Wallet Details Modal */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Wallet Account Details" size="xl">
|
||||||
|
{selected && (() => {
|
||||||
|
const w = selected;
|
||||||
|
const balance = w.balanceMinor ?? 0;
|
||||||
|
const passengerName = w.passenger?.fullName || w.user?.fullName || 'N/A';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="from-blue-600 to-blue-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||||
|
<Wallet className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
|
||||||
|
<p className="text-blue-200 text-sm">{w.passenger?.email || w.user?.email || ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{w.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') },
|
||||||
|
{ label: 'Currency', value: w.currency || 'ETB' },
|
||||||
|
{ label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-blue-200 text-xs">{label}</p>
|
||||||
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Balance" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3 col-span-2">
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-400 mb-1">Current Balance</p>
|
||||||
|
<p className="text-xl font-bold text-blue-800 dark:text-blue-300">{formatCurrency(balance, w.currency || 'ETB')}</p>
|
||||||
|
</div>
|
||||||
|
<Field label="Total Topped Up" value={formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB')} />
|
||||||
|
<Field label="Total Spent" value={formatCurrency(w.totalSpent ?? 0, w.currency || 'ETB')} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Account Details" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Currency" value={w.currency || 'ETB'} />
|
||||||
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||||
|
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{w.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Field label="Locked" value={w.isLocked ? 'Yes' : 'No'} />
|
||||||
|
<Field label="Lock Reason" value={w.lockReason || 'N/A'} truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Passenger" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Full Name" value={w.passenger?.fullName || w.user?.fullName} />
|
||||||
|
<Field label="Email" value={w.passenger?.email || w.user?.email} truncate />
|
||||||
|
<Field label="Phone" value={w.passenger?.phone || w.user?.phone} />
|
||||||
|
<Field label="Passenger ID" value={w.passengerId || w.passenger?.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Timestamps & IDs" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<Field label="Created" value={formatDateTime(w.createdAt)} />
|
||||||
|
<Field label="Last Updated" value={formatDateTime(w.updatedAt)} />
|
||||||
|
<Field label="Account ID" value={w.id} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,3 +373,9 @@ export const reportsApi = {
|
|||||||
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
|
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// System Config API
|
||||||
|
export const systemConfigApi = {
|
||||||
|
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
|
||||||
|
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
|
||||||
|
const keys = roles.map((r) => r.key ?? '');
|
||||||
|
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
|
||||||
|
if (keys.some((k) => k.includes('agent'))) return 'AGENT';
|
||||||
|
return 'SUPERVISOR';
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: AdminUser | null;
|
user: AdminUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -17,6 +25,7 @@ interface AuthState {
|
|||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
@@ -27,57 +36,47 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
try {
|
try {
|
||||||
const user = JSON.parse(userStr);
|
const user = JSON.parse(userStr);
|
||||||
set({ user, token, isAuthenticated: true });
|
set({ user, token, isAuthenticated: true });
|
||||||
} catch (e) {
|
} catch {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_refresh_token');
|
||||||
localStorage.removeItem('auth_user');
|
localStorage.removeItem('auth_user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
try {
|
// Step 1: IAM login — returns token + refreshToken only
|
||||||
console.log('Attempting login to:', `${API_URL}/auth/login`);
|
const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
|
||||||
const response = await axios.post(`${API_URL}/auth/login`, { email, password });
|
const loginData = loginRes.data?.data ?? loginRes.data;
|
||||||
console.log('Full response:', response.data);
|
const { token, refreshToken } = loginData;
|
||||||
|
if (!token) throw new Error('No token received from server');
|
||||||
// Backend wraps response in { success, data: { token, user }, timestamp }
|
|
||||||
const responseData = response.data.data || response.data;
|
// Step 2: fetch full user info with the token
|
||||||
|
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
|
||||||
if (!responseData || !responseData.token || !responseData.user) {
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
console.error('Invalid response structure:', response.data);
|
});
|
||||||
throw new Error('Invalid response from server');
|
const iamUser = meRes.data?.data ?? meRes.data;
|
||||||
}
|
|
||||||
|
const user: AdminUser = {
|
||||||
const { token, user: apiUser } = responseData;
|
id: iamUser.id,
|
||||||
|
email: iamUser.email,
|
||||||
const user: AdminUser = {
|
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
|
||||||
id: apiUser.id,
|
role: mapIamRole(iamUser.roles ?? []),
|
||||||
email: apiUser.email,
|
active: true,
|
||||||
fullName: apiUser.fullName,
|
};
|
||||||
role: apiUser.role,
|
|
||||||
active: true,
|
localStorage.setItem('auth_token', token);
|
||||||
};
|
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||||
|
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
|
||||||
console.log('Login successful! User:', user);
|
|
||||||
|
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
|
||||||
|
|
||||||
set({ user, token, isAuthenticated: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Login error details:', {
|
|
||||||
message: error.message,
|
|
||||||
response: error.response?.data,
|
|
||||||
status: error.response?.status,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_refresh_token');
|
||||||
localStorage.removeItem('auth_user');
|
localStorage.removeItem('auth_user');
|
||||||
set({ user: null, token: null, isAuthenticated: false });
|
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
setUser: (user: AdminUser, token: string) => {
|
setUser: (user: AdminUser, token: string) => {
|
||||||
|
|||||||
@@ -60,6 +60,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-fade-up {
|
||||||
|
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
background-color: hsl(var(--card));
|
background-color: hsl(var(--card));
|
||||||
|
|||||||
22066
pnpm-lock.yaml
generated
22066
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user