From 86760933e8d849e3f9925a6851aded8546e2066f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 24 Jun 2026 14:02:51 +0300 Subject: [PATCH] IAM, package, luggage, app health, rate limit, and more --- apps/edr-passenger-api/package.json | 3 + .../migration.sql | 40 +- .../migration.sql | 21 +- .../20260623073543_config/migration.sql | 12 +- .../migration.sql | 153 +++++ .../migration.sql | 12 + .../migration.sql | 42 ++ apps/edr-passenger-api/prisma/schema.prisma | 32 +- apps/edr-passenger-api/prisma/seed.ts | 8 +- .../scripts/run-iam-migrations.cjs | 1 + apps/edr-passenger-api/src/app.module.ts | 24 +- .../src/common/iam-typeorm.config.ts | 6 +- apps/edr-passenger-api/src/main.ts | 36 +- .../src/modules/auth/auth.controller.ts | 54 +- .../modules/auth/passenger-auth.service.ts | 203 +++++++ .../modules/bookings/bookings.controller.ts | 2 + .../excess-baggage.controller.ts | 80 +++ .../excess-baggage/excess-baggage.dto.ts | 22 + .../excess-baggage/excess-baggage.module.ts | 17 + .../excess-baggage/excess-baggage.service.ts | 252 ++++++++ .../src/modules/health/health.controller.ts | 59 ++ .../src/modules/health/health.module.ts | 9 + .../notifications/notifications.service.ts | 120 ++++ .../modules/packages/packages.controller.ts | 36 +- .../src/modules/packages/packages.dto.ts | 7 + .../src/modules/packages/packages.service.ts | 49 +- .../passengers/passengers.controller.ts | 2 + .../payments/internal-payments.controller.ts | 2 + .../modules/payments/payments.controller.ts | 2 + .../src/modules/payments/payments.module.ts | 1 + .../src/modules/seats/seats.module.ts | 2 +- .../src/modules/seats/seats.service.ts | 19 +- .../system-config/system-config.service.ts | 2 + .../src/modules/tickets/tickets.module.ts | 2 + .../src/modules/tickets/tickets.service.ts | 30 + .../modules/verifayda/verifayda.controller.ts | 2 + .../src/modules/wallet/wallet.controller.ts | 2 + .../backoffice/src/app/bookings/page.tsx | 10 +- .../src/app/excess-baggage/layout.tsx | 7 + .../src/app/excess-baggage/page.tsx | 204 +++++++ .../backoffice/src/app/health/layout.tsx | 44 ++ .../backoffice/src/app/health/page.tsx | 361 ++++++++++++ .../backoffice/src/app/live/page.tsx | 194 ++++++- .../backoffice/src/app/notifications/page.tsx | 250 +++++--- .../backoffice/src/app/packages/layout.tsx | 7 + .../backoffice/src/app/packages/page.tsx | 536 ++++++++++++++++++ .../backoffice/src/app/passengers/page.tsx | 17 +- .../backoffice/src/app/settings/page.tsx | 60 +- .../src/app/settings/users/page.tsx | 32 +- .../backoffice/src/app/tickets/page.tsx | 131 ++++- .../src/components/layout/Sidebar.tsx | 43 +- .../src/components/ui/ActionButton.tsx | 4 +- .../src/components/ui/ConfirmDialog.tsx | 113 +++- .../backoffice/src/components/ui/Modal.tsx | 59 +- .../backoffice/src/lib/api/index.ts | 37 ++ .../backoffice/src/lib/api/users.ts | 15 +- .../backoffice/src/styles/globals.css | 27 +- .../portal/src/app/booking/review/page.tsx | 4 +- checkpoint.md | 6 + pnpm-lock.yaml | 25 + tmp-iam-inspect/package/README.md | 59 ++ tmp-iam-inspect/package/package.json | 124 ++++ .../package/scripts/typeorm-cli.cjs | 20 + 63 files changed, 3475 insertions(+), 280 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts create mode 100644 apps/edr-passenger-api/src/modules/health/health.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/health/health.module.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/health/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/health/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/packages/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/packages/page.tsx create mode 100644 tmp-iam-inspect/package/README.md create mode 100644 tmp-iam-inspect/package/package.json create mode 100644 tmp-iam-inspect/package/scripts/typeorm-cli.cjs diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 5ab08b399..665292a68 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -33,14 +33,17 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@types/bcrypt": "^6.0.0", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.7.7", + "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^17.4.2", diff --git a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql index a9fa8190b..f2250e52b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql @@ -1,11 +1,11 @@ -- DropForeignKey -ALTER TABLE "Passenger" DROP CONSTRAINT "Passenger_userId_fkey"; +ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; -- AlterTable -ALTER TABLE "Passenger" ALTER COLUMN "userId" DROP NOT NULL; +ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL; -- CreateTable -CREATE TABLE "TicketSeat" ( +CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" ( "id" TEXT NOT NULL, "ticketId" TEXT NOT NULL, "seatId" TEXT NOT NULL, @@ -15,16 +15,40 @@ CREATE TABLE "TicketSeat" ( ); -- CreateIndex -CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId"); +CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId"); -- CreateIndex -CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId"); +CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId"); -- AddForeignKey -ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey' + AND conrelid = 'passenger."Passenger"'::regclass + ) THEN + ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; -- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey' + AND conrelid = 'passenger."TicketSeat"'::regclass + ) THEN + ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" + FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; -- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey' + AND conrelid = 'passenger."TicketSeat"'::regclass + ) THEN + ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" + FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql index dcd55c066..fb2e47592 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql @@ -17,14 +17,21 @@ 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 +-- Match via User.email → iam.users.email (skip if iam schema absent) -- ──────────────────────────────────────────────────────────── -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; +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'iam' AND table_name = 'users' + ) THEN + 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; + END IF; +END $$; -- ──────────────────────────────────────────────────────────── -- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql index 7e086e649..a20893705 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql @@ -128,7 +128,7 @@ ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; -- DropIndex -DROP INDEX "Journey_bookingId_idx"; +DROP INDEX IF EXISTS "Journey_bookingId_idx"; -- AlterTable ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY'; @@ -240,7 +240,15 @@ ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" 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; +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId' + ) THEN + ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; -- AddForeignKey ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql new file mode 100644 index 000000000..2dff947d2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql @@ -0,0 +1,153 @@ +-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema) +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"); + +-- Drop old Agent.userId FK and column if they still exist +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"; + +-- Drop old Passenger.userId FK (column stays as plain nullable string) +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; + +-- TravelPackage +CREATE TABLE IF NOT EXISTS passenger."TravelPackage" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "outboundScheduleId" TEXT NOT NULL, + "returnScheduleId" TEXT NOT NULL, + "originStationId" TEXT NOT NULL, + "destinationStationId" TEXT NOT NULL, + "boardingTime" TIMESTAMP(3) NOT NULL, + "departureTime" TIMESTAMP(3) NOT NULL, + "arrivalTime" TIMESTAMP(3) NOT NULL, + "totalCapacity" INTEGER NOT NULL, + "bookedCount" INTEGER NOT NULL DEFAULT 0, + "includedServices" JSONB NOT NULL, + "coachConfiguration" TEXT, + "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, + "busTransferRoute" TEXT, + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code"); +CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom"); + +-- PackagePriceTier +CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "seatType" TEXT NOT NULL, + "label" TEXT NOT NULL, + "priceMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "availableSeats" INTEGER NOT NULL DEFAULT 0, + "bookedSeats" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType"); + +-- PackageBooking +CREATE TABLE IF NOT EXISTS passenger."PackageBooking" ( + "id" TEXT NOT NULL, + "bookingRef" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT NOT NULL, + "passengerId" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "passengerCount" INTEGER NOT NULL DEFAULT 1, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "displayCurrency" TEXT, + "displayTotalMinor" INTEGER, + "promoCode" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef"); +CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status"); + +-- PackageBookingPassenger +CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3), + "idDocumentType" TEXT, + "idDocumentNumber" TEXT, + "passportNumber" TEXT, + "passportCountry" TEXT, + "seatLabel" TEXT, + CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") +); + +-- PackagePaymentIntent +CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" ( + "id" TEXT NOT NULL, + "packageBookingId" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "method" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION', + "providerRef" TEXT, + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId"); + +-- Foreign keys +ALTER TABLE passenger."TravelPackage" + ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" + FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."TravelPackage" + ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" + FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackagePriceTier" + ADD CONSTRAINT "PackagePriceTier_packageId_fkey" + FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_packageId_fkey" + FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_priceTierId_fkey" + FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_passengerId_fkey" + FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBookingPassenger" + ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackagePaymentIntent" + ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" + FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql new file mode 100644 index 000000000..545b6a5b4 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql @@ -0,0 +1,12 @@ +-- Create PackageStatus enum +DO $$ BEGIN + CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Drop default, cast column to enum, restore default +ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE passenger."TravelPackage" + ALTER COLUMN "status" TYPE passenger."PackageStatus" + USING "status"::passenger."PackageStatus"; +ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql new file mode 100644 index 000000000..04e2a7610 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "passenger"."ExcessBaggageCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "excessWeightKg" INTEGER NOT NULL, + "feePerKgMinor" INTEGER NOT NULL, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + "waivedBy" TEXT, + "waivedReason" TEXT, + "contactPhone" TEXT, + "contactEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status"); + +-- AddForeignKey +ALTER TABLE "passenger"."ExcessBaggageCharge" + ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Seed default paymentToken using gen_random_uuid() for any rows that may exist +UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = ''; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index d8b995745..7e07dd7ce 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -262,6 +262,8 @@ model User { sessions Session[] + passenger Passenger? + @@schema("passenger") } @@ -286,14 +288,14 @@ model Passenger { preferredLanguage String? blockedUntil DateTime? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id]) bookings Booking[] loyalty LoyaltyAccount? wallet WalletAccount? notifications Notification[] travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] -packageBookings PackageBooking[] + packageBookings PackageBooking[] @@index([userId]) @@index([iamUserId]) @@schema("passenger") @@ -546,6 +548,7 @@ model Booking { modifications BookingModification[] cancellation BookingCancellation? baggage BaggageBooking[] + excessBaggageCharges ExcessBaggageCharge[] journey Journey? @@index([passengerId, status]) @@ -1188,6 +1191,31 @@ model BaggageBooking { @@schema("passenger") } +model ExcessBaggageCharge { + id String @id @default(uuid()) + bookingId String + agentId String + excessWeightKg Int + feePerKgMinor Int + totalMinor Int + currency String @default("ETB") + status String @default("PENDING") // PENDING | PAID | EXPIRED | WAIVED | CASH_COLLECTED + paymentToken String @unique @default(uuid()) + expiresAt DateTime + paidAt DateTime? + waivedBy String? + waivedReason String? + contactPhone String? + contactEmail String? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId]) + @@index([paymentToken]) + @@index([status]) + @@schema("passenger") +} + model AuditLog { id String @id @default(uuid()) iamUserId String? diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 50e05fa6d..a21a009ec 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -59,9 +59,9 @@ async function seedSystemUsers() { }); } await prisma.userPreferences.upsert({ - where: { userId: passenger.id }, + where: { iamUserId: passenger.id }, update: {}, - create: { userId: passenger.id, language: 'en' }, + create: { iamUserId: passenger.id, language: 'en' }, }); console.log(' ✅ Passenger: kelemu@email.com / password123'); @@ -79,9 +79,9 @@ async function seedSystemUsers() { }, }); await prisma.agent.upsert({ - where: { userId: agent.id }, + where: { agentCode: 'AG0001' }, update: {}, - create: { userId: agent.id, agentCode: 'AG0001', commissionRate: 5 }, + create: { agentCode: 'AG0001', commissionRate: 5 }, }); console.log(' ✅ Agent: agent@edr-platform.com / agent123'); diff --git a/apps/edr-passenger-api/scripts/run-iam-migrations.cjs b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs index 1a448070b..1d7537e42 100644 --- a/apps/edr-passenger-api/scripts/run-iam-migrations.cjs +++ b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs @@ -33,6 +33,7 @@ const ds = new DataSource({ (async () => { await ds.initialize(); + await ds.query('CREATE SCHEMA IF NOT EXISTS iam'); // 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' }); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 98f6a2a6e..a4bce0d14 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -4,6 +4,8 @@ import { NestModule, OnApplicationBootstrap, } from '@nestjs/common'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; @@ -59,9 +61,16 @@ import { AuditModuleFeature } from './modules/audit/audit.module'; import { CurrenciesModule } from './modules/currencies/currencies.module'; import { SystemConfigModule } from './modules/system-config/system-config.module'; import { PackagesModule } from './modules/packages/packages.module'; +import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; +import { HealthModule } from './modules/health/health.module'; @Module({ imports: [ + ThrottlerModule.forRoot([ + { name: 'auth', ttl: 60_000, limit: 5 }, + { name: 'strict', ttl: 60_000, limit: 20 }, + { name: 'default', ttl: 60_000, limit: 100 }, + ]), ConfigModule.forRoot({ isGlobal: true, load: [ @@ -120,8 +129,11 @@ import { PackagesModule } from './modules/packages/packages.module'; CurrenciesModule, SystemConfigModule, PackagesModule, + ExcessBaggageModule, + HealthModule, ], providers: [ + { provide: APP_GUARD, useClass: ThrottlerGuard }, EdrPassengerOrgSeeder, PassengerStaffUsersSeeder, ], @@ -139,7 +151,15 @@ export class AppModule implements OnApplicationBootstrap { } catch (err) { console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); } - await this.edrPassengerOrgSeeder.run(); - await this.passengerStaffUsersSeeder.run(); + try { + await this.edrPassengerOrgSeeder.run(); + } catch (err) { + console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + } + try { + await this.passengerStaffUsersSeeder.run(); + } catch (err) { + console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + } } } diff --git a/apps/edr-passenger-api/src/common/iam-typeorm.config.ts b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts index 42ce89c9e..ed19da12b 100644 --- a/apps/edr-passenger-api/src/common/iam-typeorm.config.ts +++ b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts @@ -46,11 +46,11 @@ export function buildIamTypeOrmOptions(): TypeOrmModuleOptions { `${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) + synchronize: false, + migrationsRun: false, autoLoadEntities: false, migrationsTableName: 'typeorm_migrations', - retryAttempts: 0, // fail fast in dev if the iam schema / DB is unreachable + retryAttempts: process.env.IAM_ENABLED === 'true' ? 3 : 0, logging: ['error'], }; } diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index dae085bc4..707a65fa4 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -44,6 +44,9 @@ async function bootstrap() { Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. ## Latest Updates +- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting. +- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt. +- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers. - **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets). - **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this. - **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest). @@ -120,9 +123,10 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Gate validation with audit logs - Offline validation support - Multi-passenger tickets -- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) -- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets -- NEW: Complete audit trail per leg for compliance and reporting +- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) +- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets +- Complete audit trail per leg for compliance and reporting +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -262,9 +266,14 @@ Choose the right endpoint and bookingType: \`GET /payments/{paymentId}/status\` to confirm payment and retrieve tickets with QR codes ## Rate Limiting -- Auth endpoints: 5 requests/minute -- General endpoints: 100 requests/minute -- Webhook endpoints: No limit + +| Tier | Limit | Applied to | +|---|---|---| +| auth | 5 req/min | \`/auth\` (all), \`/fayda/verification\` (all) | +| strict | 20 req/min | \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\` | +| default | 100 req/min | All other endpoints | + +Exempt from rate limiting: \`/health/*\`, \`/internal/payments/*\`, payment webhook handlers. ## Error Handling All errors follow standard format: @@ -312,13 +321,14 @@ Payment providers send notifications to: .addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API") .addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations") .addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking") - .addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management") + .addTag("Health", "Liveness (GET /health), readiness with DB check (GET /health/ready), and app info (GET /health/info). All probes are public and exempt from rate limiting.") + .addTag("Internal Payments", "Service-to-service payment event handler (mark-paid). Requires service auth token. Exempt from rate limiting.") .addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals") .addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards") - .addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management") - .addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles") - .addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds") - .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") + .addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management. Boarding pass email+SMS sent automatically on gate validation.") + .addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles. Rate limited: 20 req/min.") + .addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds. Rate limited: 20 req/min.") + .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation. Exempt from rate limiting.") .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") @@ -329,9 +339,9 @@ Payment providers send notifications to: .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") - .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails") + .addTag("Tickets", "QR/barcode generation, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), audit trails. Boarding pass email+SMS sent automatically on every successful validation.") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings") - .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") + .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger. Rate limited: 20 req/min.") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 8d3e0e4ca..80e67d3de 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common'; +import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Patch, Delete, Param, Request, Query, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; import { RegisterDto, LoginDto } from './auth.dto'; @@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Auth') @Controller('auth') +@Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class AuthController { constructor(private passengerAuthService: PassengerAuthService) {} @@ -66,4 +68,54 @@ export class AuthController { } // TODO: admin user management endpoints — implement when admin module is ready + + @Get('users') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all users (admin)' }) + listUsers( + @Query('search') search?: string, + @Query('role') role?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.passengerAuthService.listUsers({ + search, role, status, + page: page ? +page : 1, + pageSize: pageSize ? +pageSize : 20, + }); + } + + @Post('users') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create user (admin)' }) + createUser(@Body() body: any) { + return this.passengerAuthService.createUser(body); + } + + @Patch('users/:id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update user (admin)' }) + updateUser(@Param('id') id: string, @Body() body: any) { + return this.passengerAuthService.updateUser(id, body); + } + + @Delete('users/:id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete user (admin)' }) + deleteUser(@Param('id') id: string) { + return this.passengerAuthService.deleteUser(id); + } + + @Post('users/:id/reset-password') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reset user password (admin)' }) + resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) { + return this.passengerAuthService.resetUserPassword(id, body.tempPassword); + } } diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index dab714ef4..4192091dd 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -193,6 +193,209 @@ export class PassengerAuthService { }; } + async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) { + const page = filters.page ?? 1; + const pageSize = filters.pageSize ?? 20; + const offset = (page - 1) * pageSize; + + const params: any[] = []; + const conditions: string[] = []; + + if (filters.search) { + params.push(`%${filters.search}%`); + conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`); + } + if (filters.role) { + params.push(`%${filters.role}%`); + conditions.push(`r.key ILIKE $${params.length}`); + } + if (filters.status) { + const active = filters.status === 'ACTIVE'; + params.push(active); + conditions.push(`u.is_active = $${params.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const baseQuery = ` + FROM iam.users u + LEFT JOIN iam.user_roles ur ON ur.user_id = u.id + LEFT JOIN iam.roles r ON r.id = ur.role_id + ${where} + `; + + const countParams = [...params]; + const [rows, countRows] = await Promise.all([ + this.dataSource.query( + `SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at, + r.key as role_key, r.name as role_name + ${baseQuery} + ORDER BY u.created_at DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, pageSize, offset], + ), + this.dataSource.query( + `SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`, + countParams, + ), + ]); + + const items = rows.map((u: any) => ({ + id: u.id, + email: u.email, + fullName: u.name?.en ?? u.name?.am ?? '', + role: u.role_key ?? '', + status: u.is_active ? 'ACTIVE' : 'INACTIVE', + lastLogin: u.metadata?.lastLogin ?? null, + createdAt: u.created_at, + })); + + return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize }; + } + + async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [data.email], + ); + if (existing.length) throw new ConflictException('Email already registered'); + + // Derive username from email local-part; ensure uniqueness by appending a short suffix if taken + const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, ''); + const taken = await this.dataSource.query<{ username: string }[]>( + `SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`, + [`${baseUsername}%`], + ); + const takenSet = new Set(taken.map((r) => r.username)); + let username = baseUsername; + let suffix = 1; + while (takenSet.has(username)) { + username = `${baseUsername}${suffix++}`; + } + + // Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js) + const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); + const passwordHash = await hashPassword(data.password); + + await this.dataSource.query( + `INSERT INTO iam.users (email, username, name, user_type, status, is_active) + VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`, + [ + data.email, + username, + JSON.stringify({ en: data.fullName, am: data.fullName }), + data.status === 'INACTIVE' ? 'pending' : 'accepted', + data.status !== 'INACTIVE', + ], + ); + + // Insert credential with correct column `password` and is_active = true + // so the IAM login SQL (find-user-for-login.sql) can find and verify it + const newUser = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email], + ); + if (newUser.length) { + await this.dataSource.query( + `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, + [newUser[0].id], + ); + await this.dataSource.query( + `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, + [newUser[0].id, passwordHash], + ); + } + + // Assign the selected role in iam.user_roles + const rows = await this.dataSource.query( + `SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`, + [data.email], + ); + const u = rows[0]; + + if (data.role && u) { + try { + const roleRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, + [data.role], + ); + if (roleRows.length) { + await this.dataSource.query( + `INSERT INTO iam.user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [u.id, roleRows[0].id], + ); + } + } catch { + // non-fatal — role assignment failure should not block user creation + } + } + + return { + id: u.id, email: u.email, + fullName: data.fullName, role: data.role, + status: u.is_active ? 'ACTIVE' : 'INACTIVE', + createdAt: u.created_at, + }; + } + + async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) { + const rows = await this.dataSource.query( + `SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`, + [id], + ); + if (!rows.length) throw new ConflictException('User not found'); + const existing = rows[0]; + const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name; + const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active; + await this.dataSource.query( + `UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`, + [JSON.stringify(name), isActive, id], + ); + + // Update role: remove existing user_roles then assign the new one + if (data.role) { + try { + const roleRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, + [data.role], + ); + if (roleRows.length) { + await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]); + await this.dataSource.query( + `INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [id, roleRows[0].id], + ); + } + } catch { + // non-fatal + } + } + + return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' }; + } + + async deleteUser(id: string) { + await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]); + return { success: true }; + } + + async resetUserPassword(id: string, tempPassword: string) { + const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); + const passwordHash = await hashPassword(tempPassword); + // Deactivate existing credentials first (IAM keeps history, only one active at a time) + await this.dataSource.query( + `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, + [id], + ); + // Insert new active credential + await this.dataSource.query( + `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, + [id, passwordHash], + ); + return { success: true, message: 'Password reset successfully' }; + } + private async compensateIamSignup(email: string): Promise { try { const rows = await this.dataSource.query<{ id: string }[]>( diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 2a8e237e6..62590ce2f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; @@ -8,6 +9,7 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Booking') @Controller('bookings') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class BookingsController { constructor( private service: BookingsService, diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts new file mode 100644 index 000000000..6bbb190d0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -0,0 +1,80 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { + LogExcessBaggageDto, + WaiveChargeDto, + InitiateExcessPaymentDto, +} from './excess-baggage.dto'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +// ── IAM-protected agent/supervisor routes ──────────────────────────────────── +@ApiTags('Excess Baggage') +@Controller('agents/excess-baggage') +@UseGuards(IamJwtGuard) +@ApiBearerAuth('IAM-auth') +export class ExcessBaggageAgentController { + constructor(private service: ExcessBaggageService) {} + + @Post() + @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) + logCharge(@Body() dto: LogExcessBaggageDto) { + return this.service.logCharge(dto); + } + + @Get() + @ApiOperation({ summary: 'List all excess baggage charges (admin/supervisor)' }) + getAll( + @Query('status') status?: string, + @Query('bookingRef') bookingRef?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.getAll({ + status, + bookingRef, + page: page ? parseInt(page) : undefined, + pageSize: pageSize ? parseInt(pageSize) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) + getCharge(@Param('id') id: string) { + return this.service.getCharge(id); + } + + @Post(':id/resend') + @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) + resendLink(@Param('id') id: string) { + return this.service.resendLink(id); + } + + @Patch(':id/waive') + @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) + waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { + return this.service.waiveCharge(id, dto); + } +} + +// ── Public pay-by-token routes (passenger self-service) ────────────────────── +@ApiTags('Excess Baggage') +@Controller('excess-baggage') +export class ExcessBaggagePublicController { + constructor(private service: ExcessBaggageService) {} + + @Get('pay/:token') + @ApiOperation({ summary: 'Retrieve charge details by payment token (public)' }) + getByToken(@Param('token') token: string) { + return this.service.getByToken(token); + } + + @Post('pay/:token/initiate') + @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) + initiatePayment( + @Param('token') token: string, + @Body() dto: InitiateExcessPaymentDto, + ) { + return this.service.initiatePayment(token, dto); + } +} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts new file mode 100644 index 000000000..58bf2bf84 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LogExcessBaggageDto { + @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string; + @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) + @IsInt() @IsPositive() excessWeightKg: number; + @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) + @IsOptional() collectCash?: boolean; +} + +export class WaiveChargeDto { + @ApiProperty() @IsString() waivedBy: string; + @ApiPropertyOptional() @IsOptional() @IsString() waivedReason?: string; +} + +export class InitiateExcessPaymentDto { + @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @IsString() method: string; + @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; +} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts new file mode 100644 index 000000000..e0545d44f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { + ExcessBaggageAgentController, + ExcessBaggagePublicController, +} from './excess-baggage.controller'; +import { PaymentsModule } from '../payments/payments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [HttpModule, PaymentsModule, NotificationsModule], + controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], + providers: [ExcessBaggageService], + exports: [ExcessBaggageService], +}) +export class ExcessBaggageModule {} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts new file mode 100644 index 000000000..e868c0092 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -0,0 +1,252 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { PaymentClientService } from '../payments/payment-client.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { + LogExcessBaggageDto, + WaiveChargeDto, + InitiateExcessPaymentDto, +} from './excess-baggage.dto'; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + ProviderMethod, + ProviderPaymentStatus, +} from '@edr/types'; +import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; + +const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +@Injectable() +export class ExcessBaggageService { + private readonly logger = new Logger(ExcessBaggageService.name); + + constructor( + private prisma: PrismaService, + private paymentClient: PaymentClientService, + private notifications: NotificationsService, + ) {} + + async logCharge(dto: LogExcessBaggageDto) { + const booking = await this.prisma.booking.findUnique({ + where: { id: dto.bookingId }, + include: { + seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { include: { user: true } }, + }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { + throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); + } + + // Resolve fee per kg from BaggageAllowance via seat class + const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId; + let feePerKgMinor = 5000; // 50 ETB default fallback (in minor) + if (coachTypeId) { + const seatClass = await this.prisma.seatClass.findFirst({ + where: { coachTypeId }, + }); + if (seatClass) { + const allowance = await this.prisma.baggageAllowance.findFirst({ + where: { seatClassId: seatClass.id }, + }); + if (allowance) feePerKgMinor = allowance.excessFeePerKg; + } + } + + const totalMinor = feePerKgMinor * dto.excessWeightKg; + const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; + const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + + const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING'; + const paidAt = dto.collectCash ? new Date() : null; + + const charge = await this.prisma.excessBaggageCharge.create({ + data: { + bookingId: dto.bookingId, + agentId: dto.agentId, + excessWeightKg: dto.excessWeightKg, + feePerKgMinor, + totalMinor, + status, + expiresAt, + paidAt, + contactPhone, + contactEmail, + }, + }); + + if (!dto.collectCash) { + await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); + } + + return charge; + } + + private async sendPaymentLink( + charge: any, + booking: any, + phone: string | null, + email: string | null, + ) { + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`; + const amountStr = (charge.totalMinor / 100).toFixed(2); + const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`; + + const recipient = phone ?? email ?? booking.passengerId; + try { + await this.notifications['deliverSms'](recipient, msg); + } catch (err) { + this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`); + } + if (email) { + try { + await this.notifications['deliverEmail']( + recipient, + `EDR — Excess baggage payment required (${booking.bookingRef})`, + msg, + ); + } catch (err) { + this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`); + } + } + } + + async getCharge(id: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, status: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + return charge; + } + + async getByToken(token: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + include: { booking: { select: { bookingRef: true, scheduleId: true } } }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) { + if (charge.status === 'PENDING') { + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { status: 'EXPIRED' }, + }); + } + throw new BadRequestException('This payment link has expired'); + } + if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { + throw new BadRequestException('This charge has already been paid'); + } + if (charge.status === 'WAIVED') { + throw new BadRequestException('This charge has been waived'); + } + return charge; + } + + async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { + const charge = await this.getByToken(token); + + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, + referenceId: charge.id, + orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, + amountMinor: charge.totalMinor / 100, + currency: charge.currency, + provider: dto.method as unknown as ProviderMethod, + platform: dto.platform as any, + returnUrl, + failureUrl: returnUrl, + }); + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, snapshot.providerTxnId); + } + + return { + chargeId: charge.id, + status: snapshot.status, + clientAction: snapshot.clientAction, + merchantOrderId: snapshot.merchantOrderId, + }; + } + + async markPaid(chargeId: string, providerTxnId?: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') return charge; + return this.prisma.excessBaggageCharge.update({ + where: { id: chargeId }, + data: { status: 'PAID', paidAt: new Date() }, + }); + } + + async waiveCharge(id: string, dto: WaiveChargeDto) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) { + throw new BadRequestException('Cannot waive a charge that has already been paid'); + } + return this.prisma.excessBaggageCharge.update({ + where: { id }, + data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason }, + }); + } + + async resendLink(id: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, passengerId: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status !== 'PENDING') { + throw new BadRequestException('Can only resend link for PENDING charges'); + } + // Extend expiry by 30 minutes from now + const updatedCharge = await this.prisma.excessBaggageCharge.update({ + where: { id }, + data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, + }); + await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail); + return { sent: true }; + } + + async getAll(filters: { + status?: string; + bookingRef?: string; + page?: number; + pageSize?: number; + }) { + const { status, bookingRef, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + const where: any = {}; + if (status) where.status = status; + if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + + const [items, total] = await Promise.all([ + this.prisma.excessBaggageCharge.findMany({ + where, + include: { booking: { select: { bookingRef: true, status: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.excessBaggageCharge.count({ where }), + ]); + + return { items, total, page, pageSize }; + } +} diff --git a/apps/edr-passenger-api/src/modules/health/health.controller.ts b/apps/edr-passenger-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..6cc50e24e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/health/health.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { PrismaService } from '../../common/prisma.service'; + +@ApiTags('Health') +@Controller('health') +@SkipThrottle() +export class HealthController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @IsPublic() + @ApiOperation({ summary: 'Liveness probe' }) + liveness() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } + + @Get('ready') + @IsPublic() + @ApiOperation({ summary: 'Readiness probe — checks database connectivity' }) + async readiness() { + const start = Date.now(); + try { + await this.prisma.$queryRaw`SELECT 1`; + return { + status: 'ok', + timestamp: new Date().toISOString(), + checks: { database: { status: 'ok', latencyMs: Date.now() - start } }, + }; + } catch (err) { + return { + status: 'error', + timestamp: new Date().toISOString(), + checks: { + database: { + status: 'error', + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : 'Unknown error', + }, + }, + }; + } + } + + @Get('info') + @IsPublic() + @ApiOperation({ summary: 'App info — version, environment, uptime' }) + info() { + return { + name: 'edr-passenger-api', + version: process.env.npm_package_version ?? '1.0.0', + environment: process.env.NODE_ENV ?? 'development', + uptimeSeconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/health/health.module.ts b/apps/edr-passenger-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..375e4d84d --- /dev/null +++ b/apps/edr-passenger-api/src/modules/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index db09ab402..c7ed031cd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -439,6 +439,126 @@ export class NotificationsService { `; } + async sendBoardingPassNotification(params: { + passengerId: string | null; + contactEmail: string | null; + contactPhone: string | null; + bookingRef: string; + leg: string | null; + booking: any; + ticket: any; + }): Promise { + const { passengerId, contactEmail, contactPhone, bookingRef, leg, booking, ticket } = params; + + // Resolve contact — prefer IAM user record, fall back to booking contact fields + let email: string | null = contactEmail ?? null; + let phone: string | null = contactPhone ?? null; + if (passengerId) { + const resolved = await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null); + const resolvedPhone = await this.getRecipientAddress(passengerId, 'SMS').catch(() => null); + if (resolved) email = resolved; + if (resolvedPhone) phone = resolvedPhone; + } + + const s = booking.schedule ?? {}; + const fmt = (d: any) => + d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; + const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; + const origin = s.originStation?.name ?? ''; + const dest = s.destinationStation?.name ?? ''; + const train = s.train?.name ?? s.train?.number ?? ''; + const dep = fmt(s.departureAt); + const arr = fmt(s.arrivalAt); + + const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({ + name: bs.passengerName ?? '', + coach: bs.seat?.coach?.number ?? '-', + seat: bs.seat?.seatNumber ?? '-', + cls: bs.seat?.coach?.coachType?.name ?? '-', + })); + + const seatLines = seats.map(s => ` ${s.name} — Coach ${s.coach}, Seat ${s.seat} (${s.cls})`).join('\n'); + + const smsText = + `EDR Boarding Pass${legLabel}\n` + + `Ref: ${bookingRef}\n` + + `${origin} → ${dest}\n` + + `Train: ${train} | Dep: ${dep}\n` + + (seatLines ? `${seatLines}\n` : '') + + `Barcode: ${ticket.barcodePayload}`; + + if (phone) { + await this.smsClient.sendSms({ to: phone, message: smsText }).catch((e) => + this.logger.error(`Boarding pass SMS failed for ${bookingRef}: ${e?.message}`), + ); + } + + if (email) { + const seatRows = seats + .map( + (s) => + ` + ${s.name} + ${s.coach} + ${s.seat} + ${s.cls} + `, + ) + .join(''); + + const html = ` + + + +
+
+

Ethio-Djibouti Railway

+

Boarding Pass${legLabel}

+
+
+

Booking reference: ${bookingRef}

+ + + + + + +
From${origin}
To${dest}
Train${train}
Departs${dep}
Arrives${arr}
+

Passengers

+ + + + + + + + ${seatRows} +
NameCoachSeatClass
+
+

QR code for gate scanning

+ Boarding pass QR +

Barcode: ${ticket.barcodePayload}

+
+
+
+

© Ethio-Djibouti Railway. All rights reserved.

+
+
+ +`; + + const textFallback = + `EDR Boarding Pass${legLabel}\nRef: ${bookingRef}\n${origin} → ${dest}\n` + + `Train: ${train} | Departs: ${dep} | Arrives: ${arr}\n${seatLines}\n` + + `Barcode: ${ticket.barcodePayload}`; + + await this.emailClient + .sendEmail({ to: email, subject: `EDR Boarding Pass — ${bookingRef}${legLabel}`, text: textFallback, html }) + .catch((e) => this.logger.error(`Boarding pass email failed for ${bookingRef}: ${e?.message}`)); + } + } + @OnEvent('payment.failed') async onPaymentFailed(payload: any) { const booking = payload.booking; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index c2bc883b6..6808f77ef 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Patch, Delete, 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 { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -52,6 +52,14 @@ export class PackagesController { return this.service.create(dto); } + @Patch(':id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update package (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + @Patch(':id/activate') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -60,6 +68,30 @@ export class PackagesController { return this.service.activate(id); } + @Post(':id/tiers') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Add price tier to package (admin)' }) + addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { + return this.service.addTier(id, dto); + } + + @Patch('tiers/:tierId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update price tier (admin)' }) + updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { + return this.service.updateTier(tierId, dto); + } + + @Delete('tiers/:tierId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete price tier (admin)' }) + deleteTier(@Param('tierId') tierId: string) { + return this.service.deleteTier(tierId); + } + @Post('book') @UseGuards(OptionalJwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index ad7ed8265..ec378f325 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -16,6 +16,13 @@ export class CreatePriceTierDto { @IsInt() @Min(0) availableSeats: number; } +export class UpdatePriceTierDto { + @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; + @ApiPropertyOptional() @IsOptional() @IsString() label?: string; + @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; + @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) availableSeats?: number; +} + export class CreatePackageDto { @ApiProperty({ example: 'KULUBBI-2025' }) @IsString() code: string; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 95aaf013a..671e8b1c5 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -1,7 +1,7 @@ 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 { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto'; import { Currency } from '@prisma/client'; function generateRef(): string { @@ -70,6 +70,53 @@ export class PackagesService { }); } + async update(id: string, dto: Partial) { + 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: { + ...(dto.code && { code: dto.code }), + ...(dto.name && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }), + ...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }), + ...(dto.originStationId && { originStationId: dto.originStationId }), + ...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }), + ...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }), + ...(dto.departureTime && { departureTime: new Date(dto.departureTime) }), + ...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }), + ...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }), + ...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }), + ...(dto.includedServices && { includedServices: dto.includedServices }), + ...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }), + ...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }), + ...(dto.validFrom && { validFrom: new Date(dto.validFrom) }), + ...(dto.validUntil && { validUntil: new Date(dto.validUntil) }), + }, + include: { priceTiers: true }, + }); + } + + async addTier(packageId: string, dto: CreatePriceTierDto) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } }); + if (!pkg) throw new NotFoundException('Package not found'); + return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } }); + } + + async updateTier(tierId: string, dto: UpdatePriceTierDto) { + const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!tier) throw new NotFoundException('Price tier not found'); + return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto }); + } + + async deleteTier(tierId: string) { + const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!tier) throw new NotFoundException('Price tier not found'); + if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings'); + return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); + } + async activate(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index a03750965..7cd119d42 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,6 +10,7 @@ import { PrismaService } from '../../common/prisma.service'; @ApiTags('Passengers') @Controller('passengers') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PassengersController { constructor( private service: PassengersService, diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index 98262c4c3..04b721f0c 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -7,6 +7,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { SkipThrottle } from "@nestjs/throttler"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentsService } from "./payments.service"; @@ -20,6 +21,7 @@ import { PaymentsService } from "./payments.service"; @ApiTags("Internal Payments") @UseGuards(ServiceAuthGuard) @Controller("internal/payments") +@SkipThrottle() export class InternalPaymentsController { constructor(private readonly paymentsService: PaymentsService) {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 4cb79a89f..a7fb4230e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -17,6 +17,7 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; +import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; import { @@ -33,6 +34,7 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Payment") @Controller("payments") +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { constructor(private service: PaymentsService) {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 8b68b637d..7e387bb31 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -61,5 +61,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentEventsConsumer, ServiceAuthGuard, ], + exports: [PaymentClientService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 97c2268c7..425f517c6 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -6,7 +6,7 @@ import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 00167b52f..f0b408f96 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -171,9 +171,26 @@ export class SeatsService { if (new Set(seatIds).size !== seatIds.length) throw new BadRequestException('Duplicate seatId in passengers list'); - const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES); + const [holdMinutes, cutoffHours] = await Promise.all([ + this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES), + this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE), + ]); const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000); + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { departureAt: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const msUntilDeparture = schedule.departureAt.getTime() - Date.now(); + const cutoffMs = cutoffHours * 60 * 60 * 1000; + if (msUntilDeparture <= cutoffMs) { + throw new BadRequestException( + `Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`, + ); + } + const hold = await this.prisma.$transaction(async (tx) => { const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 918ac7d3b..e038bc0f2 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -3,10 +3,12 @@ import { PrismaService } from '../../common/prisma.service'; export const CONFIG_KEYS = { SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', + HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', } as const; const DEFAULTS: Record = { [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', + [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', }; @Injectable() diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index cd02974d3..972668e18 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [TicketsController], providers: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard], diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 7bbe580fc..8e04d1a87 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -16,6 +17,7 @@ interface OfflineValidation { export class TicketsService { constructor( private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -308,6 +310,7 @@ export class TicketsService { } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); + this.fireBoardingPassNotification(booking, ticket, null); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -325,6 +328,7 @@ export class TicketsService { } if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -359,6 +363,7 @@ export class TicketsService { else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -389,6 +394,7 @@ export class TicketsService { if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -398,9 +404,33 @@ export class TicketsService { } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); + this.fireBoardingPassNotification(booking, ticket, null); return { validated: true, ticketId: ticket.id, validatedAt: now }; } + /** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */ + private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void { + this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { select: { id: true, iamUserId: true } }, + }, + }).then((enriched) => { + if (!enriched) return; + this.notifications.sendBoardingPassNotification({ + passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null, + contactEmail: (enriched as any).contactEmail ?? null, + contactPhone: (enriched as any).contactPhone ?? null, + bookingRef: enriched.bookingRef, + leg, + booking: enriched, + ticket, + }).catch(() => null); + }).catch(() => null); + } + async getValidationLogs(ticketId: string) { return this.prisma.gateValidationLog.findMany({ where: { ticketId }, diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index 059c6ec1f..b5358589c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -15,6 +15,7 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; @@ -37,6 +38,7 @@ interface RequestWithUser { @ApiTags('Fayda Verification') @Controller('fayda/verification') +@Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class VerifaydaController { constructor(private readonly service: VerifaydaService) {} diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 8d100863c..1ecb2edea 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard'; @Controller('wallet') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index e420c4e07..81581db44 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -31,6 +31,7 @@ export default function BookingsPage() { const [selectedBooking, setSelectedBooking] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportDateFrom, setExportDateFrom] = useState(''); @@ -63,12 +64,12 @@ export default function BookingsPage() { queryClient.invalidateQueries({ queryKey: ['bookings'] }); setDeleteConfirmOpen(false); setBookingToDelete(null); + setDeleteError(null); setSuccessMessage('Booking deleted successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, onError: (error: any) => { - setDeleteConfirmOpen(false); - alert(`Error: ${error.message || 'Failed to delete booking'}`); + setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking'); }, }); @@ -182,7 +183,7 @@ export default function BookingsPage() { label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle, show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED', }, - { label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -369,11 +370,12 @@ export default function BookingsPage() { { setDeleteConfirmOpen(false); setBookingToDelete(null); }} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} title="Delete Booking" message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger + error={deleteError ?? undefined} /> setExportModalOpen(false)} title="Export Bookings" size="md"> diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx new file mode 100644 index 000000000..86e532cc2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx @@ -0,0 +1,7 @@ +'use client'; + +import DashboardLayout from '../dashboard/layout'; + +export default function ExcessBaggageLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx new file mode 100644 index 000000000..75ba7d6c4 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw, Send } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import { excessBaggageApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +const STATUS_VARIANT: Record = { + PENDING: 'PENDING', + PAID: 'CONFIRMED', + CASH_COLLECTED: 'CONFIRMED', + EXPIRED: 'CANCELLED', + WAIVED: 'CANCELLED', +}; + +export default function ExcessBaggagePage() { + const queryClient = useQueryClient(); + const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' }); + const [waiveModal, setWaiveModal] = useState(null); + const [waiveReason, setWaiveReason] = useState(''); + const [waiveError, setWaiveError] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['excess-baggage', filters], + queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }), + }); + + const waiveMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setWaiveModal(null); + setWaiveReason(''); + setWaiveError(null); + }, + onError: (e: any) => setWaiveError(e?.response?.data?.message || e?.message || 'Failed to waive'), + }); + + const resendMutation = useMutation({ + mutationFn: (id: string) => excessBaggageApi.resendLink(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + }); + + const columns = [ + { + key: 'booking', label: 'Booking', + render: (c: any) => ( +
+
{c.booking?.bookingRef ?? '—'}
+
{formatDateTime(c.createdAt)}
+
+ ), + }, + { + key: 'weight', label: 'Excess / Charge', + render: (c: any) => ( +
+
{c.excessWeightKg} kg
+
{formatCurrency(c.feePerKgMinor, c.currency)}/kg
+
+ ), + }, + { + key: 'total', label: 'Total', + render: (c: any) => {formatCurrency(c.totalMinor, c.currency)}, + }, + { + key: 'status', label: 'Status', + render: (c: any) => ( + + {c.status.replace('_', ' ')} + + ), + }, + { + key: 'contact', label: 'Contact', + render: (c: any) => ( +
+ {c.contactPhone &&
{c.contactPhone}
} + {c.contactEmail &&
{c.contactEmail}
} + {!c.contactPhone && !c.contactEmail && '—'} +
+ ), + }, + { + key: 'expires', label: 'Expires', + render: (c: any) => ( + + {formatDateTime(c.expiresAt)} + + ), + }, + ]; + + const actions = [ + { + label: 'Resend Link', + icon: Send, + variant: 'secondary' as const, + onClick: (c: any) => resendMutation.mutate(c.id), + hidden: (c: any) => c.status !== 'PENDING', + }, + { + label: 'Waive', + icon: RefreshCw, + variant: 'secondary' as const, + onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); }, + hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), + }, + ]; + + return ( +
+
+
+

Excess Baggage

+

Track and manage excess baggage charges at boarding

+
+
+ +
+
+
+ + setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} + /> +
+
+ + +
+
+
+ + + + {/* Waive Modal */} + setWaiveModal(null)} + title="Waive Charge" + size="sm" + > + {waiveModal && ( +
+

+ Waiving charge of{' '} + + {formatCurrency(waiveModal.totalMinor, waiveModal.currency)} + {' '} + for booking {waiveModal.booking?.bookingRef}. +

+
+ + setWaiveReason(e.target.value)} + /> +
+ {waiveError &&

{waiveError}

} +
+ setWaiveModal(null)}>Cancel + waiveMutation.mutate({ id: waiveModal.id, reason: waiveReason })} + > + Confirm Waive + +
+
+ )} +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx new file mode 100644 index 000000000..cd6d8b909 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function HealthLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => setIsLoading(false), 100); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) router.push('/login'); + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) return null; + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/health/page.tsx b/apps/edr-passenger-web/backoffice/src/app/health/page.tsx new file mode 100644 index 000000000..c5b737727 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/health/page.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { + Activity, + Database, + Info, + RefreshCw, + CheckCircle2, + XCircle, + Clock, + Server, + Cpu, + Globe, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; + +// Dedicated bare client — no auth token, no 401 redirect interceptor. +// Health probes are public. We unwrap the { success, data } envelope explicitly. +const healthClient = axios.create({ baseURL: API_URL }); + +async function fetchHealth(path: string) { + const res = await healthClient.get<{ success: boolean; data: any }>(path); + return res.data?.data ?? res.data; +} + +function StatusDot({ ok }: { ok: boolean | null }) { + if (ok === null) + return ; + return ok ? ( + + ) : ( + + ); +} + +function StatusBadge({ ok }: { ok: boolean | null }) { + if (ok === null) + return Checking…; + return ok ? ( + Healthy + ) : ( + Degraded + ); +} + +function MetricRow({ label, value, icon: Icon }: { label: string; value: string; icon: any }) { + return ( +
+
+ + {label} +
+ {value} +
+ ); +} + +export default function HealthPage() { + const { data: liveness, isFetching: l1, dataUpdatedAt: t1, refetch: r1, error: e1 } = useQuery({ + queryKey: ['health-liveness'], + queryFn: () => fetchHealth('/health'), + refetchInterval: 30_000, + retry: 1, + }); + + const { data: readiness, isFetching: l2, dataUpdatedAt: t2, refetch: r2, error: e2 } = useQuery({ + queryKey: ['health-readiness'], + queryFn: () => fetchHealth('/health/ready'), + refetchInterval: 30_000, + retry: 1, + }); + + const { data: info, isFetching: l3, dataUpdatedAt: t3, refetch: r3 } = useQuery({ + queryKey: ['health-info'], + queryFn: () => fetchHealth('/health/info'), + refetchInterval: 60_000, + retry: 1, + }); + + const livenessOk = e1 ? false : liveness ? liveness.status === 'ok' : null; + const readinessOk = e2 ? false : readiness ? readiness.status === 'ok' : null; + const dbOk = readiness?.checks?.database?.status === 'ok'; + const overallOk = + livenessOk === null || readinessOk === null ? null : livenessOk && readinessOk; + + const fmt = (ms: number) => new Date(ms).toLocaleTimeString(); + const fmtUptime = (s: number) => { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${h}h ${m}m ${sec}s`; + }; + + function refetchAll() { r1(); r2(); r3(); } + + return ( +
+ {/* Header */} +
+
+

System Health

+

+ Live status of the EDR Passenger API — auto-refreshes every 30 s +

+
+ +
+ + {/* Overall banner */} +
+ {overallOk === null ? ( + + ) : overallOk ? ( + + ) : ( + + )} +
+

+ {overallOk === null + ? 'Checking system status…' + : overallOk + ? 'All systems operational' + : 'Service degraded'} +

+

+ EDR Passenger API · {API_URL} +

+
+
+ + {/* Probe cards */} +
+ {/* Liveness */} +
+
+
+
+ +
+
+

Liveness

+

GET /health

+
+
+ +
+
+ + {t1 > 0 && ( + + {fmt(t1)} + + )} +
+ {e1 && ( +

+ {(e1 as any)?.message ?? 'Request failed'} +

+ )} +

+ Confirms the process is alive and accepting connections. Checked every 30 s. +

+
+ + {/* Readiness */} +
+
+
+
+ +
+
+

Readiness

+

GET /health/ready

+
+
+ +
+
+ + {t2 > 0 && ( + + {fmt(t2)} + + )} +
+ {e2 && ( +

+ {(e2 as any)?.message ?? 'Request failed'} +

+ )} +

+ Runs a live database ping. Latency:{' '} + + {readiness?.checks?.database?.latencyMs != null + ? `${readiness.checks.database.latencyMs} ms` + : '—'} + +

+
+ + {/* App Info */} +
+
+
+
+ +
+
+

App Info

+

GET /health/info

+
+
+ +
+
+ + {t3 > 0 && ( + + {fmt(t3)} + + )} +
+

+ Version, environment, and uptime. Refreshed every 60 s. +

+
+
+ + {/* Detailed panels */} +
+ {/* Database detail */} +
+
+ +

Database

+
+ +
+
+ + + {readiness?.checks?.database?.error && ( +
+

+ {readiness.checks.database.error} +

+
+ )} +
+ + {/* App info detail */} +
+
+ +

Application

+
+ + + + + 0 ? new Date(t3).toLocaleString() : '—'} + icon={Clock} + /> +
+
+ + {/* Rate limits reference */} +
+
+ +

Rate Limits

+
+
+ + + + + + + + + + {[ + { tier: 'auth', limit: '5 req / min', scope: '/auth, /fayda/verification' }, + { tier: 'strict', limit: '20 req / min', scope: '/bookings, /passengers, /payments, /wallet' }, + { tier: 'default', limit: '100 req / min', scope: 'All other endpoints' }, + { tier: 'exempt', limit: '—', scope: '/health/*, /internal/payments/*, payment webhooks' }, + ].map((row) => ( + + + + + + ))} + +
TierLimitApplied to
+ + {row.tier} + + {row.limit}{row.scope}
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/live/page.tsx b/apps/edr-passenger-web/backoffice/src/app/live/page.tsx index 9091e1d78..034731686 100644 --- a/apps/edr-passenger-web/backoffice/src/app/live/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/live/page.tsx @@ -1,44 +1,186 @@ 'use client'; -import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, MapPin } from 'lucide-react'; +import { Train, MapPin, Users, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; -import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { liveApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; -export default function Page() { - const [filters, setFilters] = useState({ search: '' }); +export default function LiveTrackingPage() { + const { data: trips, isLoading } = useQuery({ + queryKey: ['live-trips'], + queryFn: liveApi.getTrips, + refetchInterval: 30000, + }); + + const { data: crowdSignals } = useQuery({ + queryKey: ['crowd-signals'], + queryFn: liveApi.getCrowdSignals, + refetchInterval: 60000, + }); + + const tripsArray = Array.isArray(trips) ? trips : (trips as any)?.items || []; + const signalsArray = Array.isArray(crowdSignals) ? crowdSignals : (crowdSignals as any)?.items || []; + + const columns = [ + { + key: 'train', + label: 'Train', + render: (trip: any) => ( +
+ +
+
{trip.schedule?.train?.name || trip.trainName || 'N/A'}
+
{trip.schedule?.train?.number || trip.trainNumber || ''}
+
+
+ ), + }, + { + key: 'route', + label: 'Route', + render: (trip: any) => ( +
+
{trip.schedule?.originStation?.name || trip.origin || 'N/A'}
+
→ {trip.schedule?.destinationStation?.name || trip.destination || 'N/A'}
+
+ ), + }, + { + key: 'location', + label: 'Location', + render: (trip: any) => ( +
+ + {trip.currentStation?.name || trip.lastKnownStation || 'En route'} +
+ ), + }, + { + key: 'departure', + label: 'Departure', + render: (trip: any) => ( + + {trip.schedule?.departureAt ? formatDateTime(trip.schedule.departureAt) : 'N/A'} + + ), + }, + { + key: 'passengers', + label: 'Passengers', + render: (trip: any) => ( +
+ + {trip.passengerCount ?? trip.bookedSeats ?? '—'} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (trip: any) => ( + + {(trip.status || 'SCHEDULED').replace(/_/g, ' ')} + + ), + }, + { + key: 'delay', + label: 'Delay', + render: (trip: any) => { + const delay = trip.delayMinutes ?? trip.delay; + if (!delay) return On time; + return ( +
+ + +{delay} min +
+ ); + }, + }, + ]; + + const crowdColumns = [ + { + key: 'station', + label: 'Station', + render: (s: any) => {s.station?.name || s.stationName || 'N/A'}, + }, + { + key: 'level', + label: 'Crowd Level', + render: (s: any) => ( + + {s.level || s.crowdLevel || 'LOW'} + + ), + }, + { + key: 'count', + label: 'Estimated Count', + render: (s: any) => {s.estimatedCount ?? s.count ?? '—'}, + }, + { + key: 'updatedAt', + label: 'Last Updated', + render: (s: any) => {s.updatedAt ? formatDateTime(s.updatedAt) : 'N/A'}, + }, + ]; + + const enRoute = tripsArray.filter((t: any) => t.status === 'EN_ROUTE' || t.status === 'BOARDING').length; + const delayed = tripsArray.filter((t: any) => t.delayMinutes > 0 || t.delay > 0).length; return (
-
-
-

Live Tracking

-

Real-time train tracking and status

-
- Add New +
+

Live Tracking

+

Real-time train tracking and station crowd signals

-
-
-
- - setFilters({ ...filters, search: e.target.value })} - /> -
+
+
+

Active Trips

+

{tripsArray.length}

+
+
+

En Route / Boarding

+

{enRoute}

+
+
+

Delayed

+

{delayed}

-

- Live Tracking module - Connect to API endpoint -

+

Active Trips

+
+ + {signalsArray.length > 0 && ( +
+

Station Crowd Signals

+ +
+ )}
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index bb771d7df..049a12237 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -1,21 +1,102 @@ 'use client'; import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Send } from 'lucide-react'; -import Table from '@/components/ui/Table'; +import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; - -const templates = [ - { id: '1', name: 'Booking Confirmation', channel: 'EMAIL', subject: 'Your booking is confirmed', active: true }, - { id: '2', name: 'Payment Receipt', channel: 'EMAIL', subject: 'Payment received', active: true }, - { id: '3', name: 'Trip Reminder', channel: 'SMS', body: 'Your trip is tomorrow', active: true }, - { id: '4', name: 'Cancellation Notice', channel: 'PUSH', body: 'Your booking has been cancelled', active: false }, -]; +import ActionButton from '@/components/ui/ActionButton'; +import { notificationsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; export default function NotificationsPage() { const [showModal, setShowModal] = useState(false); - const [activeTab, setActiveTab] = useState<'templates' | 'send'>('templates'); + const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates'); + const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' }); + const [sendError, setSendError] = useState(null); + const [sendSuccess, setSendSuccess] = useState(false); + const queryClient = useQueryClient(); + + const { data: templates, isLoading: templatesLoading } = useQuery({ + queryKey: ['notification-templates'], + queryFn: notificationsApi.getTemplates, + enabled: activeTab === 'templates', + }); + + const { data: historyData, isLoading: historyLoading } = useQuery({ + queryKey: ['notification-history'], + queryFn: () => notificationsApi.getHistory({ take: 50 }), + enabled: activeTab === 'history', + }); + + const createTemplateMutation = useMutation({ + mutationFn: notificationsApi.createTemplate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); + setShowModal(false); + }, + }); + + const sendMutation = useMutation({ + mutationFn: notificationsApi.send, + onSuccess: () => { + setSendSuccess(true); + setSendError(null); + setSendForm({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' }); + setTimeout(() => setSendSuccess(false), 4000); + }, + onError: (e: any) => setSendError(e?.response?.data?.message || e?.message || 'Failed to send'), + }); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + setSendError(null); + await sendMutation.mutateAsync(sendForm); + }; + + const templatesArray = Array.isArray(templates) ? templates : (templates as any)?.items || []; + const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || []; + + const templateColumns = [ + { key: 'name', label: 'Template Name', render: (t: any) => {t.name} }, + { key: 'channel', label: 'Channel', render: (t: any) => {t.channel || t.type} }, + { + key: 'subject', + label: 'Subject / Body', + render: (t: any) => {t.subject || t.body || t.content || '—'}, + }, + { + key: 'active', + label: 'Status', + render: (t: any) => ( + + {t.isActive !== false ? 'Active' : 'Inactive'} + + ), + }, + { key: 'createdAt', label: 'Created', render: (t: any) => {formatDateTime(t.createdAt)} }, + ]; + + const historyColumns = [ + { key: 'channel', label: 'Channel', render: (n: any) => {n.channel || n.type || 'EMAIL'} }, + { key: 'title', label: 'Title', render: (n: any) => {n.title || n.subject || '—'} }, + { + key: 'recipient', + label: 'Recipient', + render: (n: any) => {n.passenger?.email || n.passenger?.phone || n.recipientEmail || n.recipientPhone || '—'}, + }, + { + key: 'status', + label: 'Status', + render: (n: any) => ( + + {n.status || 'SENT'} + + ), + }, + { key: 'createdAt', label: 'Sent At', render: (n: any) => {formatDateTime(n.createdAt)} }, + ]; return (
@@ -24,109 +105,122 @@ export default function NotificationsPage() {

Notifications

Manage notification templates and send messages

- + {activeTab === 'templates' && ( + setShowModal(true)}>New Template + )}
- - + {(['templates', 'send', 'history'] as const).map((tab) => ( + + ))}
- {activeTab === 'templates' ? ( + {activeTab === 'templates' && (
- ( - {item.channel} - )}, - { key: 'subject', label: 'Subject/Body', render: (item) => item.subject || item.body }, - { key: 'active', label: 'Status', render: (item) => ( - - {item.active ? 'Active' : 'Inactive'} - - )}, - ]} + - ) : ( + )} + + {activeTab === 'send' && (
-
-
- - + {sendSuccess && ( +
+ ✓ Notification sent successfully
-
- - + )} + +
+
+ + +
+
+ + +
- + setSendForm({ ...sendForm, subject: e.target.value })} required />
- + + +