diff --git a/README.md b/README.md index 7ff862555..785c9a620 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ The API uses two authentication schemes: | **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | | **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | | **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | -| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | +| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration | | **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | | **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | | **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 4649b2e4a..7e44c9891 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -13,14 +13,12 @@ "type-check": "tsc --noEmit", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:seed": "ts-node prisma/seed-complete.ts", + "prisma:seed": "ts-node prisma/seed.ts", "prisma:seed-full": "ts-node prisma/seed.ts", "prisma:backfill": "ts-node prisma/backfill-fields.ts", "prisma:verify": "ts-node prisma/verify-backfill.ts" }, - "prisma": { - "seed": "ts-node prisma/seed.ts" - }, + "dependencies": { "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", diff --git a/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql deleted file mode 100644 index d5b4c9fa2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterEnum -ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI'; - --- AlterTable -ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql deleted file mode 100644 index 1698c0c23..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- AlterTable -ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT, -ADD COLUMN "contactPhone" TEXT; - --- CreateTable -CREATE TABLE "SavedPassengerProfile" ( - "id" TEXT NOT NULL, - "userId" TEXT, - "deviceId" TEXT, - "passengerName" TEXT NOT NULL, - "dateOfBirth" TIMESTAMP(3) NOT NULL, - "idDocumentType" "IdDocumentType" NOT NULL, - "passportNumber" TEXT, - "passportCountry" TEXT, - "nationality" TEXT, - "phone" TEXT, - "email" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId"); - --- CreateIndex -CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql deleted file mode 100644 index da38b0502..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql +++ /dev/null @@ -1,55 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail. - -*/ --- AlterTable -ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT, -ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3), -ADD COLUMN "faydaVerifiedName" TEXT; - --- AlterTable -ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT, -ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3); - --- CreateTable -CREATE TABLE "passenger"."FaydaVerificationSession" ( - "id" TEXT NOT NULL, - "state" TEXT NOT NULL, - "codeVerifier" TEXT NOT NULL, - "purpose" TEXT NOT NULL DEFAULT 'PURCHASE', - "saveToAccount" BOOLEAN NOT NULL DEFAULT false, - "status" TEXT NOT NULL DEFAULT 'PENDING', - "errorCode" TEXT, - "errorDescription" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expiresAt" TIMESTAMP(3) NOT NULL, - "completedAt" TIMESTAMP(3), - "userId" TEXT, - "bookingId" TEXT, - - CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state"); - --- CreateIndex -CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId"); - --- CreateIndex -CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId"); - --- CreateIndex -CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state"); - --- CreateIndex -CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt"); - --- CreateIndex -CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub"); - --- AddForeignKey -ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql deleted file mode 100644 index 0b22437b5..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost. - - You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost. - - A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail. - - Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty. - -*/ --- CreateEnum -CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); - --- DropIndex -DROP INDEX "PaymentMethod_userId_isDefault_idx"; - --- AlterTable -ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint", -DROP COLUMN "userId", -ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB', -ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true, -ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL', -ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0, -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql deleted file mode 100644 index 64c577ff5..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, -ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql deleted file mode 100644 index ed8665647..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql similarity index 89% rename from apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql index 2883715d8..0f8484179 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql @@ -23,7 +23,10 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED'); -- CreateEnum -CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET'); +CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); + +-- CreateEnum +CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI'); -- CreateEnum CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED'); @@ -55,12 +58,25 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER -- CreateEnum CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB'); +-- CreateTable +CREATE TABLE "CoachType" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT 'passenger', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CoachType_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "SeatClass" ( "id" TEXT NOT NULL, + "coachTypeId" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, - "basePrice" INTEGER NOT NULL, + "baseFareMinor" INTEGER NOT NULL, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -86,6 +102,9 @@ CREATE TABLE "User" ( "lastLoginAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + "faydaVerified" BOOLEAN NOT NULL DEFAULT false, + "faydaVerifiedAt" TIMESTAMP(3), + "faydaSub" TEXT, CONSTRAINT "User_pkey" PRIMARY KEY ("id") ); @@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" ( -- CreateTable CREATE TABLE "Coach" ( "id" TEXT NOT NULL, - "coachNumber" TEXT NOT NULL, - "label" TEXT NOT NULL, - "seatClassId" TEXT NOT NULL, - "coachType" TEXT, - "mode" TEXT NOT NULL DEFAULT 'seat', - "seatArrangement" TEXT, - "bedArrangement" TEXT, - "amenities" JSONB, - "totalUnits" INTEGER NOT NULL DEFAULT 0, - "isActive" BOOLEAN NOT NULL DEFAULT true, + "coachTypeId" TEXT NOT NULL, + "number" TEXT NOT NULL, + "arrangement" TEXT NOT NULL DEFAULT '2+2', + "capacity" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" ( CREATE TABLE "Seat" ( "id" TEXT NOT NULL, "coachId" TEXT NOT NULL, + "seatNumber" TEXT NOT NULL, "row" INTEGER NOT NULL, "col" TEXT NOT NULL, - "label" TEXT NOT NULL, - "seatNumber" TEXT, "kind" "SeatKind" NOT NULL DEFAULT 'STANDARD', "status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE', "heldUntil" TIMESTAMP(3), @@ -254,7 +267,6 @@ CREATE TABLE "Seat" ( "isAisle" BOOLEAN NOT NULL DEFAULT false, "bedPosition" TEXT, "premiumFeeMinor" INTEGER NOT NULL DEFAULT 0, - "eligibility" TEXT, CONSTRAINT "Seat_pkey" PRIMARY KEY ("id") ); @@ -278,6 +290,7 @@ CREATE TABLE "FareRule" ( "id" TEXT NOT NULL, "tripId" TEXT, "route" TEXT, + "nationality" TEXT, "seatClassId" TEXT NOT NULL, "baseFareMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', @@ -303,6 +316,8 @@ CREATE TABLE "Booking" ( "displayCurrency" "Currency", "displayTotalMinor" INTEGER, "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', + "contactEmail" TEXT, + "contactPhone" TEXT, "userAgent" TEXT, "source" TEXT NOT NULL DEFAULT 'WEB', "promoCode" TEXT, @@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" ( "passportCountry" TEXT, "verifaydaVerified" BOOLEAN NOT NULL DEFAULT false, "verifaydaData" JSONB, + "faydaVerifiedAt" TIMESTAMP(3), + "faydaSub" TEXT, + "faydaVerifiedName" TEXT, "seatLabelSnapshot" TEXT, "fareMinor" INTEGER, "displayCurrency" "Currency", @@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" ( -- CreateTable CREATE TABLE "PaymentMethod" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, "type" "PaymentMethodType" NOT NULL, "displayName" TEXT NOT NULL, - "maskedHint" TEXT, + "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL', + "currency" TEXT NOT NULL DEFAULT 'ETB', "providerId" TEXT, "isDefault" BOOLEAN NOT NULL DEFAULT false, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id") ); @@ -423,6 +444,16 @@ CREATE TABLE "Ticket" ( CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "TicketSeat" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "seatId" TEXT NOT NULL, + "seatIndex" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "LoyaltyAccount" ( "id" TEXT NOT NULL, @@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" ( CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "SavedPassengerProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "deviceId" TEXT, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "idDocumentType" "IdDocumentType" NOT NULL, + "passportNumber" TEXT, + "passportCountry" TEXT, + "nationality" TEXT, + "phone" TEXT, + "email" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FaydaVerificationSession" ( + "id" TEXT NOT NULL, + "state" TEXT NOT NULL, + "codeVerifier" TEXT NOT NULL, + "purpose" TEXT NOT NULL DEFAULT 'PURCHASE', + "platform" TEXT NOT NULL DEFAULT 'WEB', + "saveToAccount" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "errorCode" TEXT, + "errorDescription" TEXT, + "authCode" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "userId" TEXT, + "bookingId" TEXT, + + CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id") +); + -- CreateIndex -CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name"); +CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SeatClass_coachTypeId_name_key" ON "SeatClass"("coachTypeId", "name"); -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); @@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); -- CreateIndex CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); +-- CreateIndex +CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub"); + -- CreateIndex CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); @@ -1053,7 +1130,10 @@ CREATE UNIQUE INDEX "TripStopTime_scheduleId_sequence_key" ON "TripStopTime"("sc CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId"); -- CreateIndex -CREATE UNIQUE INDEX "Coach_coachNumber_key" ON "Coach"("coachNumber"); +CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number"); + +-- CreateIndex +CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); -- CreateIndex CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId"); @@ -1062,11 +1142,14 @@ CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId") CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber"); -- CreateIndex -CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col"); +CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId"); -- CreateIndex CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber"); +-- CreateIndex +CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col"); + -- CreateIndex CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt"); @@ -1077,7 +1160,7 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef"); CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); -- CreateIndex -CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault"); +CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type"); -- CreateIndex CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId"); @@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme -- CreateIndex CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId"); +-- CreateIndex +CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId"); + +-- CreateIndex +CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId"); + -- CreateIndex CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId"); @@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"(" -- CreateIndex CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId"); +-- CreateIndex +CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId"); + +-- CreateIndex +CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"("state"); + +-- CreateIndex +CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -1214,6 +1327,9 @@ ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" -- AddForeignKey ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1230,7 +1346,7 @@ ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1265,6 +1381,12 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" -- AddForeignKey ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1363,3 +1485,6 @@ ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("sea -- AddForeignKey ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql new file mode 100644 index 000000000..525b6572e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "SegmentFareRule" ( + "id" TEXT NOT NULL, + "routeId" TEXT NOT NULL, + "originStopSequence" INTEGER NOT NULL, + "destinationStopSequence" INTEGER NOT NULL, + "seatClassId" TEXT NOT NULL, + "baseFareMinor" INTEGER NOT NULL, + "nationality" TEXT, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality"); + +-- AddForeignKey +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql b/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql deleted file mode 100644 index fc3d83a8d..000000000 --- a/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Add contact fields to Booking table -ALTER TABLE "passenger"."Booking" -ADD COLUMN "contactEmail" TEXT, -ADD COLUMN "contactPhone" TEXT; - --- Create SavedPassengerProfile table -CREATE TABLE "passenger"."SavedPassengerProfile" ( - "id" TEXT NOT NULL, - "userId" TEXT, - "deviceId" TEXT, - "passengerName" TEXT NOT NULL, - "dateOfBirth" TIMESTAMP(3) NOT NULL, - "idDocumentType" "passenger"."IdDocumentType" NOT NULL, - "passportNumber" TEXT, - "passportCountry" TEXT, - "nationality" TEXT, - "phone" TEXT, - "email" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id") -); - --- Create indexes -CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId"); -CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId"); - --- Add comment -COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 80220f114..34fe198d2 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -70,17 +70,34 @@ enum Currency { @@schema("passenger") } -model SeatClass { +model CoachType { id String @id @default(uuid()) - name String @unique - description String? - basePrice Int - isActive Boolean @default(true) + code String + name String + type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt coaches Coach[] + seatClasses SeatClass[] + + @@schema("passenger") +} + +model SeatClass { + id String @id @default(uuid()) + coachTypeId String + name String + description String? + baseFareMinor Int + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) fareRules FareRule[] routeFareRules RouteFareRule[] + segmentFares SegmentFareRule[] + @@unique([coachTypeId, name]) + @@index([coachTypeId]) @@schema("passenger") } @@ -385,21 +402,17 @@ model TripLiveStatus { model Coach { id String @id @default(uuid()) - coachNumber String @unique - label String - seatClassId String - coachType String? - mode String @default("seat") // 'seat', 'bed', 'convertible' - seatArrangement String? - bedArrangement String? - amenities Json? - totalUnits Int @default(0) - isActive Boolean @default(true) + coachTypeId String + number String @unique + arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' + capacity Int @default(0) // Total seats/beds + status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + coachType CoachType @relation(fields: [coachTypeId], references: [id]) seats Seat[] assignments CoachAssignment[] + @@index([coachTypeId]) @@schema("passenger") } @@ -422,10 +435,9 @@ model CoachAssignment { model Seat { id String @id @default(uuid()) coachId String + seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach) row Int col String - label String - seatNumber String? kind SeatKind @default(STANDARD) status SeatStatus @default(AVAILABLE) heldUntil DateTime? @@ -433,13 +445,13 @@ model Seat { isAisle Boolean @default(false) bedPosition String? // 'lower', 'middle', 'upper' premiumFeeMinor Int @default(0) - eligibility String? coach Coach @relation(fields: [coachId], references: [id]) bookingSeats BookingSeat[] blocks SeatBlock[] ticketSeats TicketSeat[] - @@unique([coachId, row, col]) @@unique([coachId, seatNumber]) + @@unique([coachId, row, col]) + @@index([coachId]) @@schema("passenger") } @@ -978,6 +990,7 @@ model Route { createdAt DateTime @default(now()) stops RouteStop[] fareRules RouteFareRule[] + segmentFares SegmentFareRule[] schedules TrainSchedule[] @@schema("passenger") @@ -1017,6 +1030,26 @@ model RouteFareRule { @@schema("passenger") } +model SegmentFareRule { + id String @id @default(uuid()) + routeId String + originStopSequence Int + destinationStopSequence Int + seatClassId String + baseFareMinor Int + nationality String? // Optional: Ethiopian, Djiboutian, Other + currency String @default("ETB") + validFrom DateTime + validUntil DateTime? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + @@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality]) + @@index([routeId, seatClassId]) + + @@schema("passenger") +} + model Agent { id String @id @default(uuid()) userId String @unique diff --git a/apps/edr-passenger-api/prisma/seed-complete.ts b/apps/edr-passenger-api/prisma/seed-complete.ts deleted file mode 100644 index f9cc064be..000000000 --- a/apps/edr-passenger-api/prisma/seed-complete.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { PrismaClient, SeatKind } from '@prisma/client'; -import * as bcrypt from 'bcrypt'; - -const prisma = new PrismaClient(); - -async function main() { - console.log('🌱 Starting complete seed...\n'); - - // 1. STATIONS - console.log('šŸ“ Seeding stations...'); - const stationData = [ - { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 }, - { code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 }, - { code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 }, - { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 }, - { code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 }, - { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 }, - { code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 }, - { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 }, - ]; - - const stations = []; - for (const s of stationData) { - stations.push(await prisma.station.upsert({ where: { code: s.code }, update: {}, create: s })); - } - console.log(`āœ… ${stations.length} stations\n`); - - // 2. SEAT CLASSES - console.log('šŸ’ŗ Seeding seat classes...'); - const scEconomy = await prisma.seatClass.upsert({ - where: { name: 'Economy Regular' }, - update: {}, - create: { name: 'Economy Regular', description: 'Standard economy', basePrice: 25000, isActive: true }, - }); - const scBed = await prisma.seatClass.upsert({ - where: { name: 'Economy Bed' }, - update: {}, - create: { name: 'Economy Bed', description: 'Economy bed', basePrice: 35000, isActive: true }, - }); - console.log(`āœ… 2 seat classes\n`); - - // 3. ROUTES - console.log('šŸ›¤ļø Seeding routes...'); - const route1 = await prisma.route.upsert({ - where: { code: 'SBT-NGD' }, - update: {}, - create: { code: 'SBT-NGD', name: 'Sebeta-Nagad Express', effectiveFrom: new Date('2026-01-01'), active: true }, - }); - - await prisma.routeStop.createMany({ - data: [ - { routeId: route1.id, stationId: stations[0].id, sequence: 1, distanceKm: 0 }, - { routeId: route1.id, stationId: stations[1].id, sequence: 2, distanceKm: 15 }, - { routeId: route1.id, stationId: stations[2].id, sequence: 3, distanceKm: 28 }, - { routeId: route1.id, stationId: stations[3].id, sequence: 4, distanceKm: 45 }, - { routeId: route1.id, stationId: stations[4].id, sequence: 5, distanceKm: 73 }, - { routeId: route1.id, stationId: stations[5].id, sequence: 6, distanceKm: 99 }, - { routeId: route1.id, stationId: stations[6].id, sequence: 7, distanceKm: 378 }, - { routeId: route1.id, stationId: stations[7].id, sequence: 8, distanceKm: 756 }, - ], - skipDuplicates: true, - }); - - await prisma.routeFareRule.createMany({ - data: [ - { routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'ADULT', baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - { routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'CHILD', baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - ], - skipDuplicates: true, - }); - console.log(`āœ… 1 route with stops and fares\n`); - - // 4. TRAINS - console.log('šŸš‚ Seeding trains...'); - const train = await prisma.train.upsert({ - where: { number: '301' }, - update: {}, - create: { number: '301', name: 'Express 301', description: 'Main Express' }, - }); - console.log(`āœ… 1 train\n`); - - // 5. COACHES & SEATS - console.log('🚃 Seeding coaches...'); - const coach1 = await prisma.coach.upsert({ - where: { coachNumber: 'C-A1' }, - update: {}, - create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 20 }, - }); - - const existingSeats = await prisma.seat.count({ where: { coachId: coach1.id } }); - if (existingSeats === 0) { - const seats = []; - for (let row = 1; row <= 5; row++) { - for (const col of ['A', 'B', 'C', 'D']) { - seats.push({ - coachId: coach1.id, - row, - col, - label: `${row}${col}`, - seatNumber: `A${row}${col}`, - kind: 'STANDARD' as SeatKind, - }); - } - } - await prisma.seat.createMany({ data: seats }); - } - console.log(`āœ… 1 coach with 20 seats\n`); - - // 6. SCHEDULE - console.log('šŸ“… Seeding schedule...'); - const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } }); - if (existingSchedules.length > 0) { - const scheduleIds = existingSchedules.map(s => s.id); - const bookingIds = ( - await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } }) - ).map(b => b.id); - // Delete booking children in FK-safe order before deleting the bookings themselves - await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } }); - await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); - await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } }); - await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); - await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); - await prisma.trainSchedule.deleteMany({ where: { trainId: train.id } }); - } - - const schedule = await prisma.trainSchedule.create({ - data: { - trainId: train.id, - routeId: route1.id, - originStationId: stations[0].id, - destinationStationId: stations[7].id, - departureAt: new Date('2026-06-15T06:00:00Z'), - arrivalAt: new Date('2026-06-15T22:00:00Z'), - durationMinutes: 960, - stopsCount: 8, - }, - }); - - await prisma.coachAssignment.create({ - data: { scheduleId: schedule.id, coachId: coach1.id, positionNumber: 1 }, - }); - - await prisma.tripStopTime.createMany({ - data: [ - { scheduleId: schedule.id, stationId: stations[0].id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[1].id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[2].id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T08:00:00Z'), plannedDepartureAt: new Date('2026-06-15T08:05:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[3].id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:10:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[4].id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T10:00:00Z'), plannedDepartureAt: new Date('2026-06-15T10:10:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[5].id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T11:00:00Z'), plannedDepartureAt: new Date('2026-06-15T11:15:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[6].id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' }, - { scheduleId: schedule.id, stationId: stations[7].id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' }, - ], - }); - console.log(`āœ… 1 schedule with stops\n`); - - // 7. USERS - console.log('šŸ‘„ Seeding users...'); - const adminHash = await bcrypt.hash('admin123', 10); - const userHash = await bcrypt.hash('password123', 10); - - await prisma.user.upsert({ - where: { email: 'admin@edr-platform.com' }, - update: {}, - create: { fullName: 'Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' }, - }); - - const user = await prisma.user.upsert({ - where: { email: 'abebe@email.com' }, - update: {}, - create: { fullName: 'Abebe Kebede', email: 'abebe@email.com', phone: '+251912345678', passwordHash: userHash, nationality: 'Ethiopian' }, - }); - - let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } }); - if (!passenger) { - passenger = await prisma.passenger.create({ data: { userId: user.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 1000, tier: 'BRONZE' } }); - await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000 } }); - } - console.log(`āœ… 2 users\n`); - - // 8. SUPPORTING DATA - console.log('šŸ“¦ Seeding supporting data...'); - - await prisma.paymentMethod.upsert({ - where: { type: 'TELEBIRR' }, - update: {}, - create: { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', enabled: true, sortOrder: 1 }, - }); - - await prisma.currencyExchangeRate.deleteMany({}); - await prisma.currencyExchangeRate.createMany({ - data: [ - { fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() }, - { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }, - { fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() }, - ], - }); - console.log(`āœ… Payment methods and currencies\n`); - - console.log('āœ… SEED COMPLETE!\n'); - console.log('šŸ“‹ Summary:'); - console.log(' - 8 Stations'); - console.log(' - 2 Seat Classes'); - console.log(' - 1 Route with 8 stops'); - console.log(' - 1 Train with 1 schedule'); - console.log(' - 1 Coach with 20 seats'); - console.log(' - 2 Users (Admin + Passenger)'); - console.log('\nšŸ”‘ Credentials:'); - console.log(' Admin: admin@edr-platform.com / admin123'); - console.log(' User: abebe@email.com / password123'); -} - -main() - .catch((e) => { - console.error('āŒ Error:', e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 8a9707c8e..aed68df65 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -3,823 +3,546 @@ import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); -// ============================================================================ -// SECTION 1: STATIONS (18 STATIONS) -// ============================================================================ -async function seedStations() { - console.log('šŸ“ Seeding 18 stations...'); - - const stations = [ - { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 }, - { code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 }, - { code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 }, - { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 }, - { code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 }, - { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 }, - { code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.4500, lng: 39.4000 }, - { code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 }, - { code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2400, lng: 40.7500 }, - { code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4200, lng: 41.2000 }, - { code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 }, - { code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.2000, lng: 42.1500 }, - { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.8500, lng: 42.4000 }, - { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 }, - { code: 'DWL', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', lat: 11.4000, lng: 42.9500 }, - { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 11.1667, lng: 42.7167 }, - { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.3500, lng: 43.0500 }, - { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 }, - ]; +const EDR_ROUTE_ID = 'route-edr-main'; +const TRAIN_ID = 'train-001'; - const created = []; - for (const station of stations) { - const s = await prisma.station.upsert({ - where: { code: station.code }, - update: {}, - create: station, - }); - created.push(s); - } - - console.log(` āœ… Created ${created.length} stations`); - return created; -} - -// ============================================================================ -// SECTION 2: SEAT CLASSES -// ============================================================================ -async function seedSeatClasses() { - console.log('šŸ’ŗ Seeding seat classes...'); - - const classes = [ - { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 25000 }, - { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 35000 }, - { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 55000 }, - ]; - - const created = []; - for (const cls of classes) { - const c = await prisma.seatClass.upsert({ - where: { name: cls.name }, - update: {}, - create: { ...cls, isActive: true }, - }); - created.push(c); - } - - console.log(` āœ… Created ${created.length} seat classes`); - return created; -} - -// ============================================================================ -// SECTION 3: TRAINS -// ============================================================================ -async function seedTrains() { - console.log('šŸš‚ Seeding trains...'); - - const trains = [ - { number: '301', name: 'Express 301', description: 'Sebeta-Nagad Express' }, - { number: '302', name: 'Express 302', description: 'Nagad-Sebeta Express' }, - { number: '303', name: 'Local 303', description: 'Regional Service' }, - ]; - - const created = []; - for (const train of trains) { - const t = await prisma.train.upsert({ - where: { number: train.number }, - update: {}, - create: train, - }); - created.push(t); - } - - console.log(` āœ… Created ${created.length} trains`); - return created; -} - -// ============================================================================ -// SECTION 4: COACHES & SEATS -// ============================================================================ -async function seedCoachesAndSeats(seatClasses: any[]) { - console.log('🚃 Seeding coaches and seats...'); - - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - const coachConfigs = [ - { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, - { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, - { coachNumber: 'C-C1', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, - { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, - { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, - { coachNumber: 'C-C2', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, - ]; - - const coaches = []; - for (const config of coachConfigs) { - const coach = await prisma.coach.upsert({ - where: { coachNumber: config.coachNumber }, - update: {}, - create: config, - }); - coaches.push(coach); - - // Create seats for this coach - const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } }); - if (existingSeats === 0) { - const seats = []; - const rows = Math.ceil(config.totalUnits / 4); - for (let row = 1; row <= rows; row++) { - for (const col of ['A', 'B', 'C', 'D']) { - if (seats.length >= config.totalUnits) break; - seats.push({ - coachId: coach.id, - row, - col, - label: `${row}${col}`, - seatNumber: `${config.label}${row}${col}`, - kind: row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD', - }); - } - } - await prisma.seat.createMany({ data: seats as any }); - } - } - - console.log(` āœ… Created ${coaches.length} coaches with seats`); - return coaches; -} - -// ============================================================================ -// SECTION 5: SCHEDULES (15+ SEGMENTS) -// ============================================================================ -async function seedSchedules(trains: any[], stations: any[], routes: any[]) { - console.log('šŸ“… Seeding schedules with 15+ segments...'); - - const [train301, train302, train303] = trains; - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [fullRoute, regionalRoute] = routes; - - // Clean up existing schedules - const existingScheduleIds = (await prisma.trainSchedule.findMany({ - where: { trainId: { in: [train301.id, train302.id, train303.id] } }, - select: { id: true }, - })).map((s: { id: string }) => s.id); - - if (existingScheduleIds.length > 0) { - // Delete in correct order to avoid foreign key constraints - await prisma.bookingSeat.deleteMany({ - where: { - booking: { - scheduleId: { in: existingScheduleIds } - } - } - }); - await prisma.booking.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } }); - await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } }); - } - - const schedules = [ - // Full route: Sebeta to Nagad (18 stations) - { - trainId: train301.id, - routeId: fullRoute.id, - originStationId: sebeta.id, - destinationStationId: nagad.id, - departureAt: new Date('2026-06-15T06:00:00Z'), - arrivalAt: new Date('2026-06-15T22:00:00Z'), - durationMinutes: 960, - stopsCount: 18, - }, - // Return route: Nagad to Sebeta - { - trainId: train302.id, - routeId: fullRoute.id, - originStationId: nagad.id, - destinationStationId: sebeta.id, - departureAt: new Date('2026-06-16T07:00:00Z'), - arrivalAt: new Date('2026-06-16T23:30:00Z'), - durationMinutes: 990, - stopsCount: 18, - }, - // Regional service: Sebeta to Diredawa - { - trainId: train303.id, - routeId: regionalRoute.id, - originStationId: sebeta.id, - destinationStationId: diredawa.id, - departureAt: new Date('2026-06-17T08:00:00Z'), - arrivalAt: new Date('2026-06-17T18:00:00Z'), - durationMinutes: 600, - stopsCount: 11, - }, - // Additional schedules for next day - { - trainId: train301.id, - routeId: fullRoute.id, - originStationId: sebeta.id, - destinationStationId: nagad.id, - departureAt: new Date('2026-06-18T06:30:00Z'), - arrivalAt: new Date('2026-06-18T22:45:00Z'), - durationMinutes: 975, - stopsCount: 18, - }, - { - trainId: train302.id, - routeId: fullRoute.id, - originStationId: nagad.id, - destinationStationId: sebeta.id, - departureAt: new Date('2026-06-19T07:15:00Z'), - arrivalAt: new Date('2026-06-19T23:45:00Z'), - durationMinutes: 990, - stopsCount: 18, - }, - ]; - - const created = []; - for (const schedule of schedules) { - const s = await prisma.trainSchedule.create({ data: schedule }); - created.push(s); - } - - console.log(` āœ… Created ${created.length} schedules`); - return created; -} - -// ============================================================================ -// SECTION 6: COACH ASSIGNMENTS -// ============================================================================ -async function seedCoachAssignments(schedules: any[], coaches: any[]) { - console.log('šŸ”— Seeding coach assignments...'); - - const [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2] = coaches; - const [schedule1, schedule2, schedule3] = schedules; - - const assignments = [ - { scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 }, - { scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 }, - { scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 }, - { scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 }, - { scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 }, - ]; - - await prisma.coachAssignment.createMany({ data: assignments, skipDuplicates: true }); - console.log(` āœ… Created ${assignments.length} coach assignments`); -} - -// ============================================================================ -// SECTION 7: STOP TIMES (ALL 18 STATIONS) -// ============================================================================ -async function seedStopTimes(schedules: any[], stations: any[]) { - console.log('ā±ļø Seeding stop times for all stations...'); - - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [schedule1, schedule2, schedule3] = schedules; - - // Full route stop times (Sebeta to Nagad) - const fullRouteStops = [ - { scheduleId: schedule1.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T06:30:00Z'), plannedDepartureAt: new Date('2026-06-15T06:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T07:30:00Z'), plannedDepartureAt: new Date('2026-06-15T07:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T08:15:00Z'), plannedDepartureAt: new Date('2026-06-15T08:25:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:15:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:45:00Z'), plannedDepartureAt: new Date('2026-06-15T09:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T10:30:00Z'), plannedDepartureAt: new Date('2026-06-15T10:45:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-15T12:00:00Z'), plannedDepartureAt: new Date('2026-06-15T12:10:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T13:30:00Z'), plannedDepartureAt: new Date('2026-06-15T13:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: arawa.id, sequence: 12, plannedArrivalAt: new Date('2026-06-15T16:30:00Z'), plannedDepartureAt: new Date('2026-06-15T16:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: adigala.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T17:45:00Z'), plannedDepartureAt: new Date('2026-06-15T17:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: aysha.id, sequence: 14, plannedArrivalAt: new Date('2026-06-15T18:30:00Z'), plannedDepartureAt: new Date('2026-06-15T18:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: dawanle.id, sequence: 15, plannedArrivalAt: new Date('2026-06-15T19:15:00Z'), plannedDepartureAt: new Date('2026-06-15T19:20:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: alisabieh.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), plannedDepartureAt: new Date('2026-06-15T20:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: holhol.id, sequence: 17, plannedArrivalAt: new Date('2026-06-15T21:00:00Z'), plannedDepartureAt: new Date('2026-06-15T21:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: nagad.id, sequence: 18, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' as const }, - ]; - - // Regional route stop times (Sebeta to Diredawa) - const regionalStops = [ - { scheduleId: schedule3.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-17T08:00:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-17T08:30:00Z'), plannedDepartureAt: new Date('2026-06-17T08:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-17T09:00:00Z'), plannedDepartureAt: new Date('2026-06-17T09:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-17T09:30:00Z'), plannedDepartureAt: new Date('2026-06-17T09:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-17T10:15:00Z'), plannedDepartureAt: new Date('2026-06-17T10:25:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-17T11:00:00Z'), plannedDepartureAt: new Date('2026-06-17T11:15:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-17T11:45:00Z'), plannedDepartureAt: new Date('2026-06-17T11:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-17T12:30:00Z'), plannedDepartureAt: new Date('2026-06-17T12:45:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-17T14:00:00Z'), plannedDepartureAt: new Date('2026-06-17T14:10:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-17T15:30:00Z'), plannedDepartureAt: new Date('2026-06-17T15:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-17T18:00:00Z'), status: 'UPCOMING' as const }, - ]; - - const allStops = [...fullRouteStops, ...regionalStops]; - await prisma.tripStopTime.createMany({ data: allStops }); - console.log(` āœ… Created ${allStops.length} stop times`); -} - -// ============================================================================ -// SECTION 8: FARE RULES (COMPREHENSIVE SEGMENTS) -// ============================================================================ -async function seedFareRules(schedules: any[], seatClasses: any[]) { - console.log('šŸ’° Seeding comprehensive fare rules...'); - - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - // Segment-based fare rules (15+ segments) - const segmentRules = [ - // Short segments (1-3 stations) - { route: 'SBT-LBU', seatClassId: scEconomy.id, baseFareMinor: 5000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'LBU-IND', seatClassId: scEconomy.id, baseFareMinor: 4500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'IND-BSH', seatClassId: scEconomy.id, baseFareMinor: 5500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'BSH-MJO', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MJO-ADM', seatClassId: scEconomy.id, baseFareMinor: 7000, validFrom: new Date('2026-01-01'), refundable: true }, - - // Medium segments (3-6 stations) - { route: 'SBT-BSH', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-ADM', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'ADM-MTH', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MTH-MSO', seatClassId: scEconomy.id, baseFareMinor: 9500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MSO-BKE', seatClassId: scEconomy.id, baseFareMinor: 8000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'BKE-DDW', seatClassId: scEconomy.id, baseFareMinor: 7500, validFrom: new Date('2026-01-01'), refundable: true }, - - // Long segments (6+ stations) - { route: 'SBT-DDW', seatClassId: scEconomy.id, baseFareMinor: 35000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'DDW-AYS', seatClassId: scEconomy.id, baseFareMinor: 15000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'AYS-NGD', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-NGD', seatClassId: scEconomy.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true }, - - // Cross-border segments - { route: 'DDW-DWL', seatClassId: scEconomy.id, baseFareMinor: 22000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'DWL-ALI', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'ALI-HOL', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'HOL-NGD', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, - ]; - - // Add Economy Bed prices (40% higher) - const bedRules = segmentRules.map(rule => ({ - ...rule, - seatClassId: scEconomyBed.id, - baseFareMinor: Math.round(rule.baseFareMinor * 1.4), - })); - - // Add VIP prices (80% higher) - const vipRules = segmentRules.map(rule => ({ - ...rule, - seatClassId: scVip.id, - baseFareMinor: Math.round(rule.baseFareMinor * 1.8), - })); - - const allRules = [...segmentRules, ...bedRules, ...vipRules]; - await prisma.fareRule.createMany({ data: allRules, skipDuplicates: true }); - - // Nationality-specific discounts - const nationalityRules = [ - // Ethiopian nationals - 10% discount on domestic routes - { route: 'SBT-DDW', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 31500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-ADM', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 16200, validFrom: new Date('2026-01-01'), refundable: true }, - - // Djiboutian nationals - 5% discount on cross-border routes - { route: 'DDW-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 42750, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 61750, validFrom: new Date('2026-01-01'), refundable: true }, - ]; - - await prisma.fareRule.createMany({ data: nationalityRules, skipDuplicates: true }); - - console.log(` āœ… Created ${allRules.length + nationalityRules.length} fare rules`); -} - -// ============================================================================ -// SECTION 9: USERS & PASSENGERS -// ============================================================================ -async function seedUsers() { - console.log('šŸ‘„ Seeding users...'); - - const hash = await bcrypt.hash('password123', 10); +async function seedSystemUsers() { + console.log('šŸ‘„ Seeding system users...'); const adminHash = await bcrypt.hash('admin123', 10); + const passengerHash = await bcrypt.hash('password123', 10); const agentHash = await bcrypt.hash('agent123', 10); + const supervisorHash = await bcrypt.hash('supervisor123', 10); + const staffHash = await bcrypt.hash('staff123', 10); - // Admin - await prisma.user.upsert({ + const admin = await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { - fullName: 'EDR Admin', + fullName: 'System Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN', }, }); + console.log(' āœ… Admin: admin@edr-platform.com / admin123'); - // Ethiopian Passenger - const ethiopianUser = await prisma.user.upsert({ - where: { email: 'abebe@email.com' }, - update: {}, + const passenger = await prisma.user.upsert({ + where: { email: 'kelemu@email.com' }, + update: { passwordHash: passengerHash }, create: { - fullName: 'Abebe Kebede', - email: 'abebe@email.com', - phone: '+251912345678', - passwordHash: hash, + fullName: 'Kelemu Kebede', + email: 'kelemu@email.com', + phone: '+251911234567', + passwordHash: passengerHash, + role: 'PASSENGER', nationality: 'Ethiopian', - nationalId: 'ET123456789', + faydaVerified: true, }, }); - let ethiopianPassenger = await prisma.passenger.findUnique({ where: { userId: ethiopianUser.id } }); - if (!ethiopianPassenger) { - ethiopianPassenger = await prisma.passenger.create({ data: { userId: ethiopianUser.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: ethiopianPassenger.id, pointsBalance: 2450, tier: 'SILVER' } }); - await prisma.walletAccount.create({ data: { passengerId: ethiopianPassenger.id, balanceMinor: 125000 } }); + let passengerRecord = await prisma.passenger.findUnique({ where: { userId: passenger.id } }); + if (!passengerRecord) { + passengerRecord = await prisma.passenger.create({ data: { userId: passenger.id } }); + await prisma.loyaltyAccount.create({ + data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' }, + }); + await prisma.walletAccount.create({ + data: { passengerId: passengerRecord.id, balanceMinor: 50000 }, + }); } await prisma.userPreferences.upsert({ - where: { userId: ethiopianUser.id }, + where: { userId: passenger.id }, update: {}, - create: { userId: ethiopianUser.id, language: 'en' }, + create: { userId: passenger.id, language: 'en' }, }); + console.log(' āœ… Passenger: kelemu@email.com / password123'); - // Djiboutian Passenger - const djiboutianUser = await prisma.user.upsert({ - where: { email: 'ahmed@email.com' }, - update: {}, - create: { - fullName: 'Ahmed Hassan', - email: 'ahmed@email.com', - phone: '+25377123456', - passwordHash: hash, - nationality: 'Djiboutian', - passportNumber: 'DJ1234567', - }, - }); - - let djiboutianPassenger = await prisma.passenger.findUnique({ where: { userId: djiboutianUser.id } }); - if (!djiboutianPassenger) { - djiboutianPassenger = await prisma.passenger.create({ data: { userId: djiboutianUser.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: djiboutianPassenger.id, pointsBalance: 1200, tier: 'BRONZE' } }); - await prisma.walletAccount.create({ data: { passengerId: djiboutianPassenger.id, balanceMinor: 85000 } }); - } - await prisma.userPreferences.upsert({ - where: { userId: djiboutianUser.id }, - update: {}, - create: { userId: djiboutianUser.id, language: 'fr' }, - }); - - // Agent - const agentUser = await prisma.user.upsert({ + const agent = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { - fullName: 'Agent Abebe', + fullName: 'Booking Agent', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT', }, }); - - const stations = await prisma.station.findMany(); await prisma.agent.upsert({ - where: { userId: agentUser.id }, + where: { userId: agent.id }, update: {}, + create: { userId: agent.id, agentCode: 'AG0001', commissionRate: 5 }, + }); + console.log(' āœ… Agent: agent@edr-platform.com / agent123'); + + const supervisor = await prisma.user.upsert({ + where: { email: 'supervisor@edr-platform.com' }, + update: { passwordHash: supervisorHash, role: 'SUPERVISOR' }, create: { - userId: agentUser.id, - agentCode: 'AG001', - stationId: stations[0].id, - commissionRate: 5, - active: true, + fullName: 'System Supervisor', + email: 'supervisor@edr-platform.com', + phone: '+251922222222', + passwordHash: supervisorHash, + role: 'SUPERVISOR', }, }); + console.log(' āœ… Supervisor: supervisor@edr-platform.com / supervisor123'); - console.log(` āœ… Created 4 users (Admin, Ethiopian, Djiboutian, Agent)`); + const staff = await prisma.user.upsert({ + where: { email: 'staff@edr-platform.com' }, + update: { passwordHash: staffHash, role: 'STAFF' }, + create: { + fullName: 'Support Staff', + email: 'staff@edr-platform.com', + phone: '+251933333333', + passwordHash: staffHash, + role: 'STAFF', + }, + }); + console.log(' āœ… Staff: staff@edr-platform.com / staff123'); } -// ============================================================================ -// SECTION 10: ROUTES -// ============================================================================ -async function seedRoutes(stations: any[], seatClasses: any[]) { - console.log('šŸ›¤ļø Seeding routes...'); - - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - // Route 1: Full Line (Sebeta to Nagad) - const fullRoute = await prisma.route.upsert({ - where: { code: 'SBT-NGD-FULL' }, - update: {}, - create: { - code: 'SBT-NGD-FULL', - name: 'Sebeta - Nagad Express', - description: 'Complete Ethio-Djibouti Railway route from Sebeta to Nagad', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - // Create stops for full route - const fullRouteStops = [ - { routeId: fullRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: fullRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: fullRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: fullRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: fullRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: fullRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - { routeId: fullRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 }, - { routeId: fullRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 }, - { routeId: fullRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 }, - { routeId: fullRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 }, - { routeId: fullRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 }, - { routeId: fullRoute.id, stationId: arawa.id, sequence: 12, distanceKm: 445 }, - { routeId: fullRoute.id, stationId: adigala.id, sequence: 13, distanceKm: 512 }, - { routeId: fullRoute.id, stationId: aysha.id, sequence: 14, distanceKm: 578 }, - { routeId: fullRoute.id, stationId: dawanle.id, sequence: 15, distanceKm: 625 }, - { routeId: fullRoute.id, stationId: alisabieh.id, sequence: 16, distanceKm: 672 }, - { routeId: fullRoute.id, stationId: holhol.id, sequence: 17, distanceKm: 718 }, - { routeId: fullRoute.id, stationId: nagad.id, sequence: 18, distanceKm: 756 }, +async function seedStations() { + console.log('\nšŸ“ Seeding 15 stations (Ethio-Djibouti Railway)...'); + const stations = [ + { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 }, + { code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 }, + { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 }, + { code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 }, + { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 }, + { code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 }, + { code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 }, + { code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 }, + { code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 }, + { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 }, + { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 }, + { code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 }, + { code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 }, + { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 }, + { code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 }, ]; - await prisma.routeStop.createMany({ data: fullRouteStops, skipDuplicates: true }); - // Fare rules for full route - const fullRouteFares = [ - { routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: fullRouteFares, skipDuplicates: true }); - - // Route 2: Regional (Sebeta to Diredawa) - const regionalRoute = await prisma.route.upsert({ - where: { code: 'SBT-DDW-REG' }, - update: {}, - create: { - code: 'SBT-DDW-REG', - name: 'Sebeta - Diredawa Regional', - description: 'Regional service from Sebeta to Diredawa', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const regionalStops = [ - { routeId: regionalRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: regionalRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: regionalRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: regionalRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: regionalRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: regionalRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - { routeId: regionalRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 }, - { routeId: regionalRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 }, - { routeId: regionalRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 }, - { routeId: regionalRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 }, - { routeId: regionalRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 }, - ]; - await prisma.routeStop.createMany({ data: regionalStops, skipDuplicates: true }); - - const regionalFares = [ - { routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: regionalFares, skipDuplicates: true }); - - // Route 3: Short Distance (Sebeta to Adama) - const shortRoute = await prisma.route.upsert({ - where: { code: 'SBT-ADM-SHORT' }, - update: {}, - create: { - code: 'SBT-ADM-SHORT', - name: 'Sebeta - Adama Commuter', - description: 'Short distance commuter service', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const shortStops = [ - { routeId: shortRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: shortRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: shortRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: shortRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: shortRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: shortRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - ]; - await prisma.routeStop.createMany({ data: shortStops, skipDuplicates: true }); - - const shortFares = [ - { routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: shortFares, skipDuplicates: true }); - - // Route 4: Cross-Border (Diredawa to Nagad) - const crossBorderRoute = await prisma.route.upsert({ - where: { code: 'DDW-NGD-INTL' }, - update: {}, - create: { - code: 'DDW-NGD-INTL', - name: 'Diredawa - Nagad International', - description: 'Cross-border service from Ethiopia to Djibouti', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const crossBorderStops = [ - { routeId: crossBorderRoute.id, stationId: diredawa.id, sequence: 1, distanceKm: 0 }, - { routeId: crossBorderRoute.id, stationId: arawa.id, sequence: 2, distanceKm: 67 }, - { routeId: crossBorderRoute.id, stationId: adigala.id, sequence: 3, distanceKm: 134 }, - { routeId: crossBorderRoute.id, stationId: aysha.id, sequence: 4, distanceKm: 200 }, - { routeId: crossBorderRoute.id, stationId: dawanle.id, sequence: 5, distanceKm: 247 }, - { routeId: crossBorderRoute.id, stationId: alisabieh.id, sequence: 6, distanceKm: 294 }, - { routeId: crossBorderRoute.id, stationId: holhol.id, sequence: 7, distanceKm: 340 }, - { routeId: crossBorderRoute.id, stationId: nagad.id, sequence: 8, distanceKm: 378 }, - ]; - await prisma.routeStop.createMany({ data: crossBorderStops, skipDuplicates: true }); - - const crossBorderFares = [ - { routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: crossBorderFares, skipDuplicates: true }); - - console.log(` āœ… Created 4 routes with stops and fare rules`); - return [fullRoute, regionalRoute, shortRoute, crossBorderRoute]; + for (const station of stations) { + await prisma.station.upsert({ + where: { code: station.code }, + update: {}, + create: { + id: `station-${station.code.toLowerCase()}`, + ...station, + }, + }); + } + console.log(` āœ… ${stations.length} stations created`); + return stations; } -// ============================================================================ -// SECTION 11: SUPPORTING DATA -// ============================================================================ -async function seedSupportingData(seatClasses: any[]) { - console.log('šŸ“¦ Seeding supporting data...'); - - // Baggage Allowance - await prisma.baggageAllowance.deleteMany({}); - await prisma.baggageAllowance.createMany({ - data: [ - { seatClassId: seatClasses[0].id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 }, - { seatClassId: seatClasses[1].id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 }, - { seatClassId: seatClasses[2].id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 }, - ], - }); +async function seedCoachTypesAndClasses() { + console.log('\nšŸš‚ Seeding coach types and seat classes...'); + const coachTypes = [ + { code: 'ECO', name: 'Economy', type: 'passenger' }, + { code: 'ECO_BED', name: 'Economy Bed', type: 'sleeper' }, + { code: 'VIP_BED', name: 'VIP Bed', type: 'sleeper' }, + ]; - // Supported Payment Methods (platform-wide catalog) - const paymentMethods = [ - { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 1, isDefault: true }, - { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 2 }, - { type: 'EBIRR', displayName: 'E-Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 3 }, - { type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI', currency: 'DJF', sortOrder: 4 }, - { type: 'CARD', displayName: 'Credit / Debit Card', region: 'INTERNATIONAL', currency: 'USD', sortOrder: 5 }, - { type: 'WALLET', displayName: 'EDR Wallet', region: 'GLOBAL', currency: 'ETB', sortOrder: 6 }, - ] as const; - for (const pm of paymentMethods) { - await prisma.paymentMethod.upsert({ - where: { type: pm.type as any }, - update: { displayName: pm.displayName, region: pm.region as any, currency: pm.currency, sortOrder: pm.sortOrder, enabled: true }, - create: { ...pm, region: pm.region as any, type: pm.type as any }, + for (const ct of coachTypes) { + await prisma.coachType.upsert({ + where: { code: ct.code }, + update: {}, + create: ct, }); } - // Notification Templates - await prisma.notificationTemplate.upsert({ - where: { code: 'BOOKING_CONFIRMED' }, - update: {}, - create: { - code: 'BOOKING_CONFIRMED', - channel: 'EMAIL', - subject: 'Booking Confirmed', - bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.', - active: true, - }, - }); + const seatClasses = [ + { name: 'ECONOMY_REGULAR', coachCode: 'ECO', baseFareMinor: 35000 }, + { name: 'ECONOMY_WINDOW', coachCode: 'ECO', baseFareMinor: 37000 }, + { name: 'ECONOMY_BED', coachCode: 'ECO_BED', baseFareMinor: 55000 }, + { name: 'VIP_BED', coachCode: 'VIP_BED', baseFareMinor: 85000 }, + ]; - await prisma.notificationTemplate.upsert({ - where: { code: 'booking.created' }, - update: {}, - create: { - code: 'booking.created', - channel: 'EMAIL', - subject: 'Booking Created', - bodyTemplate: 'Your booking {{bookingRef}} has been created successfully.', - active: true, - }, - }); - - await prisma.notificationTemplate.upsert({ - where: { code: 'PAYMENT_SUCCESS' }, - update: {}, - create: { - code: 'PAYMENT_SUCCESS', - channel: 'SMS', - bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', - active: true, - }, - }); - - // Promotions - await prisma.promotion.upsert({ - where: { code: 'WEEKEND15' }, - update: {}, - create: { - title: 'Weekend Sale', - subtitle: '15% off all trips', - code: 'WEEKEND15', - percentOff: 15, - validUntil: new Date('2026-12-31'), - ctaLabel: 'Book Now', - active: true, - }, - }); - - // Currency Exchange Rates - await prisma.currencyExchangeRate.deleteMany({}); - await prisma.currencyExchangeRate.createMany({ - data: [ - { fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() }, - { fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() }, - { fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() }, - { fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3125, effectiveDate: new Date() }, - { fromCurrency: 'DJF', toCurrency: 'DJF', rate: 1.0, effectiveDate: new Date() }, - ], - }); - - // Fraud Rules - await prisma.fraudRule.upsert({ - where: { type: 'VELOCITY' }, - update: {}, - create: { - type: 'VELOCITY', - enabled: true, - threshold: 3, - config: { windowMinutes: 60, action: 'FLAG' }, - }, - }); - - console.log(` āœ… Created supporting data`); + for (const sc of seatClasses) { + const ct = await prisma.coachType.findUnique({ where: { code: sc.coachCode } }); + await prisma.seatClass.upsert({ + where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, + update: {}, + create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor }, + }); + } + console.log(` āœ… ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); +} + +async function seedRoute() { + console.log('\nšŸ›£ļø Seeding route and stops...'); + const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); + const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } }); + + const route = await prisma.route.upsert({ + where: { code: 'EDR-MAIN' }, + update: {}, + create: { + id: EDR_ROUTE_ID, + code: 'EDR-MAIN', + name: 'Ethio-Djibouti Railway Main Route', + description: 'Main route connecting Sebeta to Nagad', + effectiveFrom: new Date('2024-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG']; + for (let i = 0; i < stationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } }, + update: {}, + create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 }, + }); + } + console.log(` āœ… Route with ${stationCodes.length} stops created`); +} + +async function seedCoaches() { + console.log('\n🚃 Seeding coaches and seats...'); + const ecoCoachType = await prisma.coachType.findUnique({ where: { code: 'ECO' } }); + const ecoBedCoachType = await prisma.coachType.findUnique({ where: { code: 'ECO_BED' } }); + const vipBedCoachType = await prisma.coachType.findUnique({ where: { code: 'VIP_BED' } }); + + const coaches = [ + { number: 'C-001', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 }, + { number: 'C-002', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 }, + { number: 'C-003', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 }, + { number: 'C-004', coachTypeId: ecoBedCoachType!.id, arrangement: '2+2', capacity: 32 }, + { number: 'C-005', coachTypeId: ecoBedCoachType!.id, arrangement: '2+2', capacity: 32 }, + { number: 'C-006', coachTypeId: vipBedCoachType!.id, arrangement: '1+1', capacity: 16 }, + ]; + + let totalSeats = 0; + for (const coach of coaches) { + const c = await prisma.coach.upsert({ + where: { number: coach.number }, + update: {}, + create: coach, + }); + + let seatIndex = 1; + for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { + for (const col of ['A', 'B', 'C', 'D']) { + if (seatIndex <= coach.capacity) { + await prisma.seat.upsert({ + where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } }, + update: {}, + create: { + coachId: c.id, + seatNumber: seatIndex.toString(), + row, + col, + isWindow: col === 'A' || col === 'D', + isAisle: col === 'B' || col === 'C', + }, + }); + seatIndex++; + } + } + } + totalSeats += coach.capacity; + } + console.log(` āœ… ${coaches.length} coaches with ${totalSeats} seats created`); +} + +async function seedTrips() { + console.log('\nšŸš† Seeding train service, schedule, and trips...'); + const train = await prisma.train.upsert({ + where: { number: 'EDR-001' }, + update: {}, + create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' }, + }); + + const route = await prisma.route.findUnique({ where: { code: 'EDR-MAIN' } }); + const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); + const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } }); + const coaches = await prisma.coach.findMany(); + + const now = new Date(); + const schedules = []; + + // Bulk prepare schedule data + for (let d = 0; d < 30; d++) { + const tripDate = new Date(now); + tripDate.setDate(tripDate.getDate() + d); + tripDate.setHours(8, 0, 0, 0); + + const departureAt = new Date(tripDate); + const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000); + + schedules.push({ + trainId: train.id, + routeId: route!.id, + originStationId: firstStation!.id, + destinationStationId: lastStation!.id, + departureAt, + arrivalAt, + durationMinutes: 4 * 24 * 60, + stopsCount: 15, + }); + } + + // Bulk create schedules + const createdSchedules = await Promise.all( + schedules.map(s => prisma.trainSchedule.create({ data: s })) + ); + + // Bulk create coach assignments and live status + const coachAssignments = []; + const liveStatuses = []; + + for (const schedule of createdSchedules) { + for (let p = 0; p < coaches.length; p++) { + coachAssignments.push({ + scheduleId: schedule.id, + coachId: coaches[p].id, + positionNumber: p + 1, + }); + } + liveStatuses.push({ + scheduleId: schedule.id, + state: 'scheduled', + progressPercent: 0, + }); + } + + await Promise.all([ + ...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })), + ...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })), + ]); + + console.log(` āœ… Train with ${createdSchedules.length} upcoming trips created`); +} + +async function seedFareRules() { + console.log('\nšŸ’° Seeding fare rules...'); + const route = await prisma.route.findUnique({ where: { code: 'EDR-MAIN' } }); + const seatClasses = await prisma.seatClass.findMany(); + const validFrom = new Date('2024-01-01'); + + const fareRules = []; + for (const sc of seatClasses) { + fareRules.push({ + routeId: route!.id, + seatClassId: sc.id, + passengerCategory: 'ADULT', + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + fareRules.push({ + routeId: route!.id, + seatClassId: sc.id, + passengerCategory: 'CHILD', + baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), + discountPercent: 50, + currency: 'ETB', + validFrom, + }); + } + + await Promise.all( + fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) + ); + console.log(` āœ… ${fareRules.length} fare rules for ADULT/CHILD categories created`); +} + +async function seedCurrency() { + console.log('\nšŸ’± Seeding currency exchange rates...'); + const rates = [ + { from: 'ETB', to: 'DJF', rate: 3.25 }, + { from: 'ETB', to: 'USD', rate: 0.018 }, + { from: 'DJF', to: 'ETB', rate: 0.3077 }, + { from: 'USD', to: 'ETB', rate: 55.56 }, + ]; + + for (const r of rates) { + await prisma.currencyExchangeRate.upsert({ + where: { + fromCurrency_toCurrency_effectiveDate: { + fromCurrency: r.from as any, + toCurrency: r.to as any, + effectiveDate: new Date('2024-01-01'), + }, + }, + update: { rate: r.rate }, + create: { + fromCurrency: r.from as any, + toCurrency: r.to as any, + rate: r.rate, + effectiveDate: new Date('2024-01-01'), + }, + }); + } + console.log(` āœ… 4 currency exchange rates created`); +} + +async function seedPaymentMethods() { + console.log('\nšŸ’³ Seeding payment methods...'); + const methods = [ + { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, + { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, + { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, + { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, + { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, + ]; + + for (const m of methods) { + await prisma.paymentMethod.upsert({ + where: { type: m.type as any }, + update: {}, + create: m, + }); + } + console.log(` āœ… ${methods.length} payment methods created`); +} + +async function seedNotificationTemplates() { + console.log('\nšŸ”” Seeding notification templates...'); + const templates = [ + { code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' }, + { code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' }, + { code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' }, + { code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' }, + { code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, + ]; + + for (const t of templates) { + await prisma.notificationTemplate.upsert({ + where: { code: t.code }, + update: {}, + create: t, + }); + } + console.log(` āœ… ${templates.length} notification templates created`); +} + +async function seedMenuAndFood() { + console.log('\nšŸ½ļø Seeding menu categories and items...'); + const beverages = await prisma.menuCategory.upsert({ + where: { id: 'cat-beverages' }, + update: {}, + create: { id: 'cat-beverages', name: 'Beverages' }, + }); + const snacks = await prisma.menuCategory.upsert({ + where: { id: 'cat-snacks' }, + update: {}, + create: { id: 'cat-snacks', name: 'Snacks' }, + }); + + const schedule = await prisma.trainSchedule.findFirst(); + if (schedule) { + await prisma.menuItem.upsert({ + where: { id: 'menu-coffee' }, + update: {}, + create: { id: 'menu-coffee', scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 }, + }); + await prisma.menuItem.upsert({ + where: { id: 'menu-juice' }, + update: {}, + create: { id: 'menu-juice', scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 }, + }); + await prisma.menuItem.upsert({ + where: { id: 'menu-sandwich' }, + update: {}, + create: { id: 'menu-sandwich', scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 }, + }); + } + console.log(` āœ… Menu categories and items created`); +} + +async function seedPromotions() { + console.log('\nšŸŽ‰ Seeding promotions...'); + const promos = [ + { title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, + { title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) }, + { title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) }, + ]; + + for (const p of promos) { + await prisma.promotion.upsert({ + where: { code: p.code }, + update: {}, + create: p, + }); + } + console.log(` āœ… ${promos.length} promotions created`); +} + +async function seedFAQ() { + console.log('\nā“ Seeding FAQ...'); + const general = await prisma.faqCategory.upsert({ + where: { id: 'faq-general' }, + update: {}, + create: { id: 'faq-general', title: 'General', iconKey: 'help' }, + }); + const booking = await prisma.faqCategory.upsert({ + where: { id: 'faq-booking' }, + update: {}, + create: { id: 'faq-booking', title: 'Booking', iconKey: 'book' }, + }); + + await prisma.faqArticle.upsert({ + where: { id: 'faq-article-1' }, + update: {}, + create: { id: 'faq-article-1', categoryId: general.id, question: 'What is EDR?', answerMarkdown: 'Ethio-Djibouti Railway' }, + }); + await prisma.faqArticle.upsert({ + where: { id: 'faq-article-2' }, + update: {}, + create: { id: 'faq-article-2', categoryId: booking.id, question: 'How to book?', answerMarkdown: 'Use the booking system' }, + }); + console.log(` āœ… FAQ categories and articles created`); +} + +async function seedFraudRules() { + console.log('\nšŸ” Seeding fraud detection rules...'); + const rules = [ + { type: 'RAPID_BOOKINGS', threshold: 10, enabled: true }, + { type: 'HIGH_VALUE_BOOKING', threshold: 500000, enabled: true }, + { type: 'UNUSUAL_DEVICE', threshold: 0.8, enabled: true }, + ]; + + for (const r of rules) { + await prisma.fraudRule.upsert({ + where: { type: r.type }, + update: {}, + create: r, + }); + } + console.log(` āœ… ${rules.length} fraud detection rules created`); } -// ============================================================================ -// MAIN SEED FUNCTION -// ============================================================================ async function main() { - console.log('🌱 Starting comprehensive modular seed with 18 stations...\n'); + console.log('🌱 Comprehensive EDR Seed Starting...\n'); - const stations = await seedStations(); - const seatClasses = await seedSeatClasses(); - const trains = await seedTrains(); - const coaches = await seedCoachesAndSeats(seatClasses); - const routes = await seedRoutes(stations, seatClasses); - const schedules = await seedSchedules(trains, stations, routes); - await seedCoachAssignments(schedules, coaches); - await seedStopTimes(schedules, stations); - await seedFareRules(schedules, seatClasses); - await seedUsers(); - await seedSupportingData(seatClasses); + await seedSystemUsers(); + await seedStations(); + await seedCoachTypesAndClasses(); + await seedRoute(); + await seedCoaches(); + await seedTrips(); + await seedFareRules(); + await seedCurrency(); + await seedPaymentMethods(); + await seedNotificationTemplates(); + await seedMenuAndFood(); + await seedPromotions(); + await seedFAQ(); + await seedFraudRules(); - console.log('\nāœ… Comprehensive seed complete!\n'); - console.log('šŸ“‹ Seed Summary:'); - console.log(' - 18 Stations: SBT, LBU, IND, BSH, MJO, ADM, FTO, MTH, MSO, BKE, DDW, ARW, ADG, AYS, DWL, ALI, HOL, NGD'); - console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)'); - console.log(' - 3 Trains (Express 301, Express 302, Local 303)'); - console.log(' - 6 Physical Coaches with seats'); - console.log(' - 5 Train Schedules covering full and regional routes'); - console.log(' - 4 Routes with stops and fare rules'); - console.log(' - 15+ Fare Segments with nationality-based pricing'); - console.log(' - 4 Users: Admin, Ethiopian Passenger, Djiboutian Passenger, Agent'); - console.log(' - Currency rates: ETB, USD, DJF'); - console.log('\nšŸ”‘ Login Credentials:'); - console.log(' Admin: admin@edr-platform.com / admin123'); - console.log(' Ethiopian Passenger: abebe@email.com / password123'); - console.log(' Djiboutian Passenger: ahmed@email.com / password123'); - console.log(' Agent: agent@edr-platform.com / agent123'); - console.log('\nšŸ’° Booking Flow Ready:'); - console.log(' - Search: 18 stations with multiple route combinations'); - console.log(' - Select: 3 seat classes with dynamic pricing'); - console.log(' - Book: Complete passenger details and payment'); - console.log(' - Pay: Multiple payment methods (Telebirr, CBE, Card, Wallet)'); - console.log(' - Ticket: QR code generation and validation'); - console.log('\nšŸš‚ Sample Routes:'); - console.log(' - Full Route: Sebeta → Nagad (18 stations, 756 km)'); - console.log(' - Regional: Sebeta → Diredawa (11 stations, 378 km)'); - console.log(' - Short: Sebeta → Adama (6 stations, 99 km)'); - console.log(' - Cross-border: Diredawa → Nagad (8 stations, 378 km)'); + console.log('\nāœ… Seed complete!\n'); + console.log('šŸ”‘ System Users:'); + console.log(' Admin: admin@edr-platform.com / admin123'); + console.log(' Passenger: kelemu@email.com / password123'); + console.log(' Agent: agent@edr-platform.com / agent123'); + console.log(' Supervisor: supervisor@edr-platform.com / supervisor123'); + console.log(' Staff: staff@edr-platform.com / staff123'); } main() diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 1fd74de9f..58c7b6cea 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -392,7 +392,7 @@ export class BookingsService { where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } }, + seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true, }, }); @@ -408,7 +408,7 @@ export class BookingsService { destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city }, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, - passengers: booking.seats.map((bs) => ({ + passengers: booking.seats?.map((bs: any) => ({ fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name }, })), diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index e104a507f..1f9717c6a 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -35,7 +35,7 @@ export class DashboardService { upcomingTicket: upcomingBooking ? { ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef, from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name, - trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, + trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, departureAt: upcomingBooking.schedule.departureAt, punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME', } : null, diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index b0411718f..182709446 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -44,7 +44,7 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - const ratePerKmMinor = seatClass.basePrice; + const ratePerKmMinor = seatClass.baseFareMinor; const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; const adultCount = dto.adultCount ?? 1; @@ -129,7 +129,7 @@ export class FareEngineService { ) { const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true }, - orderBy: { basePrice: 'asc' }, + orderBy: { baseFareMinor: 'asc' }, }); const results = await Promise.all( @@ -172,7 +172,7 @@ export class FareEngineService { if (schedule.routeId) { const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true }, - orderBy: { basePrice: 'asc' }, + orderBy: { baseFareMinor: 'asc' }, }); const results = await Promise.all( @@ -198,23 +198,25 @@ export class FareEngineService { validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, - include: { seatClass: true }, - orderBy: { seatClass: { basePrice: 'asc' } }, + orderBy: [{ seatClass: { baseFareMinor: 'asc' } }], }); if (fareRules.length > 0) { const billingCurrency = resolveCurrencyFromNationality(nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); - return fareRules.map(rule => ({ - seatClassId: rule.seatClassId, - seatClassName: rule.seatClass.name, - baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, - billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), - exchangeRate, - source: 'FARE_RULE', - })); + return fareRules.map(rule => { + const seatClassId = rule.seatClassId; + return { + seatClassId, + seatClassName: 'Unknown', + baseFareMinor: rule.baseFareMinor, + totalMinor: rule.baseFareMinor, + billingCurrency, + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + exchangeRate, + source: 'FARE_RULE', + }; + }); } throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index e26fb2211..940978f88 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Fleet') @@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard'; export class FleetController { constructor(private service: FleetService) {} + // Coach Type Endpoints + @Get('coach-types') + @ApiOperation({ summary: 'List all coach types' }) + @ApiResponse({ status: 200, description: 'Array of coach types' }) + getCoachTypes() { + return this.service.getCoachTypes(); + } + + @Post('coach-types') + @ApiOperation({ summary: 'Create a coach type' }) + @ApiBody({ type: CreateCoachTypeDto }) + @ApiResponse({ status: 201, description: 'Coach type created' }) + createCoachType(@Body() dto: CreateCoachTypeDto) { + return this.service.createCoachType(dto); + } + + @Patch('coach-types/:id') + @ApiOperation({ summary: 'Update a coach type' }) + @ApiParam({ name: 'id', description: 'Coach Type UUID' }) + @ApiBody({ type: UpdateCoachTypeDto }) + @ApiResponse({ status: 200, description: 'Coach type updated' }) + @ApiResponse({ status: 404, description: 'Coach type not found' }) + updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) { + return this.service.updateCoachType(id, dto); + } + + @Delete('coach-types/:id') + @ApiOperation({ summary: 'Delete a coach type' }) + @ApiParam({ name: 'id', description: 'Coach Type UUID' }) + @ApiResponse({ status: 200, description: 'Coach type deleted' }) + @ApiResponse({ status: 404, description: 'Coach type not found' }) + deleteCoachType(@Param('id') id: string) { + return this.service.deleteCoachType(id); + } + + // Class Endpoints + @Get('classes') + @ApiOperation({ summary: 'List all classes' }) + @ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' }) + @ApiResponse({ status: 200, description: 'Array of classes' }) + getClasses(@Query('coachTypeId') coachTypeId?: string) { + return this.service.getClasses(coachTypeId); + } + + @Post('classes') + @ApiOperation({ summary: 'Create a class' }) + @ApiBody({ type: CreateClassDto }) + @ApiResponse({ status: 201, description: 'Class created' }) + createClass(@Body() dto: CreateClassDto) { + return this.service.createClass(dto); + } + + @Patch('classes/:id') + @ApiOperation({ summary: 'Update a class' }) + @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiBody({ type: UpdateClassDto }) + @ApiResponse({ status: 200, description: 'Class updated' }) + @ApiResponse({ status: 404, description: 'Class not found' }) + updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) { + return this.service.updateClass(id, dto); + } + + @Delete('classes/:id') + @ApiOperation({ summary: 'Delete a class' }) + @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiResponse({ status: 200, description: 'Class deleted' }) + @ApiResponse({ status: 404, description: 'Class not found' }) + deleteClass(@Param('id') id: string) { + return this.service.deleteClass(id); + } + + // Seat Class Endpoints (DEPRECATED - use Classes endpoints instead) + @Get('seat-classes') + @ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' }) + @ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' }) + @ApiResponse({ status: 200, description: 'Array of classes' }) + getSeatClasses(@Query('coachTypeId') coachTypeId?: string) { + return this.service.getClasses(coachTypeId); + } + + @Post('seat-classes') + @ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' }) + @ApiBody({ type: CreateClassDto }) + @ApiResponse({ status: 201, description: 'Class created' }) + createSeatClass(@Body() dto: CreateClassDto) { + return this.service.createClass(dto); + } + + @Patch('seat-classes/:id') + @ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' }) + @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiBody({ type: UpdateClassDto }) + @ApiResponse({ status: 200, description: 'Class updated' }) + @ApiResponse({ status: 404, description: 'Class not found' }) + updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) { + return this.service.updateClass(id, dto); + } + + @Delete('seat-classes/:id') + @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) + @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiResponse({ status: 200, description: 'Class deleted' }) + @ApiResponse({ status: 404, description: 'Class not found' }) + deleteSeatClass(@Param('id') id: string) { + return this.service.deleteClass(id); + } + + // Train Endpoints @Get('trains') @ApiOperation({ summary: 'List all trains with their recent schedules' }) - @ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' }) - getTrains() { return this.service.getTrains(); } + @ApiResponse({ status: 200, description: 'Array of trains' }) + getTrains() { + return this.service.getTrains(); + } @Post('trains') @ApiOperation({ summary: 'Create a train service' }) @ApiBody({ type: CreateTrainDto }) @ApiResponse({ status: 201, description: 'Train created' }) - createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); } + createTrain(@Body() dto: CreateTrainDto) { + return this.service.createTrain(dto); + } @Patch('trains/:id') @ApiOperation({ summary: 'Update a train service' }) @@ -28,96 +140,95 @@ export class FleetController { @ApiBody({ type: CreateTrainDto }) @ApiResponse({ status: 200, description: 'Train updated' }) @ApiResponse({ status: 404, description: 'Train not found' }) - updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); } - - @Get('coaches') - @ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' }) - @ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' }) - @ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' }) - @ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' }) - @ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' }) - listCoaches( - @Query('isActive') isActive?: string, - @Query('mode') mode?: string, - @Query('seatClassId') seatClassId?: string, - @Query('scheduleId') scheduleId?: string, - ) { - const dto: ListCoachesDto = { - isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, - mode, - seatClassId, - scheduleId, - }; - return this.service.listCoaches(dto); + updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { + return this.service.updateTrain(id, dto); } - @Get('coaches/:id') - @ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' }) - @ApiParam({ name: 'id', description: 'Coach UUID' }) - @ApiResponse({ - status: 200, - description: `Coach detail including: -- seatClass: seat class info -- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor -- seatStatusSummary: total/available/held/booked/blocked counts -- assignments: up to 5 most recent schedule assignments with origin/destination`, - }) - @ApiResponse({ status: 404, description: 'Coach not found' }) - getCoach(@Param('id') id: string) { return this.service.getCoach(id); } - - @Post('coaches') - @ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' }) - @ApiBody({ type: CreateCoachDto }) - @ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' }) - @ApiResponse({ status: 400, description: 'Invalid arrangement format' }) - createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); } - - @Patch('coaches/:id') - @ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' }) - @ApiParam({ name: 'id', description: 'Coach UUID' }) - @ApiBody({ type: UpdateCoachDto }) - @ApiResponse({ status: 200, description: 'Coach updated' }) - @ApiResponse({ status: 404, description: 'Coach not found' }) - updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); } - @Delete('trains/:id') @ApiOperation({ summary: 'Delete a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @ApiResponse({ status: 200, description: 'Train deleted' }) @ApiResponse({ status: 404, description: 'Train not found' }) - deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); } + deleteTrain(@Param('id') id: string) { + return this.service.deleteTrain(id); + } + + // Coach Endpoints + @Get('coaches') + @ApiOperation({ summary: 'List coaches with seat status summary' }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' }) + @ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' }) + @ApiResponse({ status: 200, description: 'Array of coaches' }) + listCoaches( + @Query('status') status?: string, + @Query('scheduleId') scheduleId?: string, + ) { + const dto: ListCoachesDto = { + status, + scheduleId, + }; + return this.service.listCoaches(dto); + } + + @Get('coaches/:id') + @ApiOperation({ summary: 'Get single coach with seat layout' }) + @ApiParam({ name: 'id', description: 'Coach UUID' }) + @ApiResponse({ status: 200, description: 'Coach detail with seats by row' }) + @ApiResponse({ status: 404, description: 'Coach not found' }) + getCoach(@Param('id') id: string) { + return this.service.getCoach(id); + } + + @Post('coaches') + @ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' }) + @ApiBody({ type: CreateCoachDto }) + @ApiResponse({ status: 201, description: 'Coach created' }) + @ApiResponse({ status: 400, description: 'Invalid arrangement format' }) + createCoach(@Body() dto: CreateCoachDto) { + return this.service.createCoach(dto); + } + + @Patch('coaches/:id') + @ApiOperation({ summary: 'Update coach properties' }) + @ApiParam({ name: 'id', description: 'Coach UUID' }) + @ApiBody({ type: UpdateCoachDto }) + @ApiResponse({ status: 200, description: 'Coach updated' }) + @ApiResponse({ status: 404, description: 'Coach not found' }) + updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { + return this.service.updateCoach(id, dto); + } @Delete('coaches/:id') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiResponse({ status: 200, description: 'Coach deleted' }) @ApiResponse({ status: 404, description: 'Coach not found' }) - deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); } + deleteCoach(@Param('id') id: string) { + return this.service.deleteCoach(id); + } @Post('assignments') - @ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' }) + @ApiOperation({ summary: 'Assign a coach to a schedule' }) @ApiBody({ type: AssignCoachDto }) - @ApiResponse({ status: 201, description: 'CoachAssignment created' }) + @ApiResponse({ status: 201, description: 'Coach assigned' }) @ApiResponse({ status: 404, description: 'Schedule or coach not found' }) - assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); } + assignCoach(@Body() dto: AssignCoachDto) { + return this.service.assignCoach(dto); + } @Delete('assignments/:id') - @ApiOperation({ summary: 'Remove a coach assignment from a schedule' }) - @ApiParam({ name: 'id', description: 'CoachAssignment UUID' }) + @ApiOperation({ summary: 'Remove a coach assignment' }) + @ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiResponse({ status: 200, description: 'Assignment removed' }) @ApiResponse({ status: 404, description: 'Assignment not found' }) - removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); } - - @Post('seats/batch') - @ApiOperation({ summary: 'Batch-generate seats for a coach (rows Ɨ cols)' }) - @ApiBody({ type: CreateSeatBatchDto }) - @ApiResponse({ status: 201, description: 'Returns count of seats created' }) - @ApiResponse({ status: 404, description: 'Coach not found' }) - createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); } + removeAssignment(@Param('id') id: string) { + return this.service.removeAssignment(id); + } @Get('analytics') - @ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' }) - @ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' }) - getAnalytics() { return this.service.getAnalytics(); } + @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' }) + @ApiResponse({ status: 200, description: 'Occupancy statistics' }) + getAnalytics() { + return this.service.getAnalytics(); + } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 6046b1068..19f544db5 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -10,41 +10,48 @@ export class CreateTrainDto { } export class CreateCoachDto { - @ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string; - @ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string; - @ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string; - @ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string; - @ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string; - @ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string; - @ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string; - @ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number; + @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string; + @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string; + @ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string; + @ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number; + @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; } -export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {} +export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {} export class AssignCoachDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string; @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string; - @ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number; - @ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean; -} - -export class CreateSeatBatchDto { - @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string; - @ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number; - @ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[]; + @ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number; + @ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean; } export class ListCoachesDto { - @ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' }) - @IsOptional() @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' }) + @IsOptional() @IsString() status?: string; - @ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' }) - @IsOptional() @IsString() mode?: string; - - @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' }) - @IsOptional() @IsString() seatClassId?: string; - - @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' }) + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' }) @IsOptional() @IsString() scheduleId?: string; } + +// Legacy DTO types for backward compatibility +export class CreateCoachTypeDto { + @ApiProperty({ example: 'sleeper' }) @IsString() code: string; + @ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string; + @IsOptional() @IsString() type?: string; +} + +export class UpdateCoachTypeDto { + @ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string; + @ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string; + @ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string; +} + +export class CreateClassDto { + @ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string; + @ApiProperty({ example: 'Economy' }) @IsString() name: string; + @IsOptional() @IsString() description?: string; + @ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number; +} + +export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index daea3a9dd..5ea4104a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; import { SeatKind } from '@prisma/client'; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] @@ -8,22 +8,20 @@ function parseArrangement(arrangement: string): number[] { return arrangement.split('+').map((n) => parseInt(n, 10)); } -// Derives column labels from a seat-mode arrangement string. -// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle) -// '1+2+1' → ['A','B','C','D'] +// Derives column labels from arrangement: '2+2' → ['A','B','C','D'] function seatCols(arrangement: string): string[] { const groups = parseArrangement(arrangement); const total = groups.reduce((s, n) => s + n, 0); - return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C … + return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); } -// Returns true if the column index is a window seat given the arrangement groups. +// Returns true if column is a window seat function isWindowCol(colIndex: number, groups: number[]): boolean { const total = groups.reduce((s, n) => s + n, 0); return colIndex === 0 || colIndex === total - 1; } -// Returns true if the column index is an aisle seat. +// Returns true if column is an aisle seat function isAisleCol(colIndex: number, groups: number[]): boolean { let cursor = 0; for (const g of groups) { @@ -35,82 +33,180 @@ function isAisleCol(colIndex: number, groups: number[]): boolean { return false; } -// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper -const BED_POSITIONS: Record = { - 2: ['lower', 'upper'], - 3: ['lower', 'middle', 'upper'], -}; - -type SeatRow = { - coachId: string; - row: number; - col: string; - label: string; - seatNumber: string; - kind: SeatKind; - isWindow: boolean; - isAisle: boolean; - bedPosition?: string; -}; - -function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] { +function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; - while (seats.length < totalUnits) { - for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) { + let seatNumber = 1; + let seatIndex = 0; + const isBedCoach = seatClass?.toLowerCase().includes('bed'); + const totalCols = cols.length; + + while (seatIndex < capacity) { + for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { const col = cols[ci]; + let bedPosition = null; + + // Set bedPosition for bed coaches based on seat number cycling + if (isBedCoach) { + if (totalCols === 3) { + // Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U... + const posMod = ((seatNumber - 1) % 3); + if (posMod === 0) bedPosition = 'lower'; + else if (posMod === 1) bedPosition = 'middle'; + else if (posMod === 2) bedPosition = 'upper'; + } else if (totalCols === 2) { + // VIP bed (2 levels): 1L, 2U, 3L, 4U... + const posMod = ((seatNumber - 1) % 2); + if (posMod === 0) bedPosition = 'lower'; + else if (posMod === 1) bedPosition = 'upper'; + } + } + seats.push({ - coachId, row, col, - label: `${row}${col}`, - seatNumber: `${coachLabel}${row}${col}`, + coachId, + row, + col, + seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, - isWindow: isWindowCol(ci, groups), - isAisle: isAisleCol(ci, groups), + bedPosition, }); + seatNumber++; + seatIndex++; } row++; } return seats; } -function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] { - // arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side - // Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper) - const groups = parseArrangement(arrangement); - const tiersPerSide = groups[0]; // e.g. 2 → lower+upper - const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper']; - const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle - const seats: SeatRow[] = []; - let compartment = 1; - while (seats.length < totalUnits) { - for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) { - const col = tierCols[ti]; - seats.push({ - coachId, row: compartment, col, - label: `${compartment}${col}`, - seatNumber: `${coachLabel}${compartment}${col}`, - kind: SeatKind.STANDARD, - isWindow: false, - isAisle: false, - bedPosition: positions[ti], - }); - } - compartment++; - } - return seats; -} +type SeatRow = { + coachId: string; + row: number; + col: string; + seatNumber: string; + kind: SeatKind; + bedPosition?: string | null; +}; @Injectable() export class FleetService { constructor(private prisma: PrismaService) {} + async createCoachType(dto: CreateCoachTypeDto) { + return this.prisma.coachType.create({ + data: { + code: dto.code, + name: dto.name, + type: dto.type || 'passenger', + }, + include: { + seatClasses: true, + coaches: true, + }, + }); + } + + async getCoachTypes() { + return this.prisma.coachType.findMany({ + include: { + seatClasses: true, + coaches: true, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateCoachType(id: string, dto: UpdateCoachTypeDto) { + const coachType = await this.prisma.coachType.findUnique({ where: { id } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + + const data: any = {}; + if (dto.code !== undefined) data.code = dto.code; + if (dto.name !== undefined) data.name = dto.name; + if (dto.type !== undefined) data.type = dto.type; + + return this.prisma.coachType.update({ + where: { id }, + data, + include: { + seatClasses: true, + coaches: true, + }, + }); + } + + async deleteCoachType(id: string) { + const coachType = await this.prisma.coachType.findUnique({ where: { id } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + + return this.prisma.coachType.delete({ where: { id } }); + } + + async createClass(dto: CreateClassDto) { + return this.prisma.seatClass.create({ + data: { + coachTypeId: dto.coachTypeId, + name: dto.name, + description: dto.description, + baseFareMinor: dto.baseFareMinor, + }, + }); + } + + async getClasses(coachTypeId?: string) { + const where = coachTypeId ? { coachTypeId } : {}; + return this.prisma.seatClass.findMany({ + where, + include: { coachType: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateClass(id: string, dto: UpdateClassDto) { + const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!seatClass) throw new NotFoundException('Seat class not found'); + + return this.prisma.seatClass.update({ + where: { id }, + data: { + name: dto.name, + description: dto.description, + baseFareMinor: dto.baseFareMinor, + }, + }); + } + + async deleteClass(id: string) { + const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!seatClass) throw new NotFoundException('Seat class not found'); + + return this.prisma.seatClass.delete({ where: { id } }); + } + + createSeatClass(dto: CreateClassDto) { + return this.createClass(dto); + } + + getSeatClasses(coachTypeId?: string) { + return this.getClasses(coachTypeId); + } + + async updateSeatClass(id: string, dto: UpdateClassDto) { + return this.updateClass(id, dto); + } + + async deleteSeatClass(id: string) { + return this.deleteClass(id); + } + getTrains() { return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } }); } - createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } + createTrain(dto: CreateTrainDto) { + return this.prisma.train.create({ data: dto }); + } async updateTrain(id: string, dto: CreateTrainDto) { const train = await this.prisma.train.findUnique({ where: { id } }); @@ -118,120 +214,112 @@ export class FleetService { return this.prisma.train.update({ where: { id }, data: dto }); } - async getCoach(id: string) { - const coach = await this.prisma.coach.findUnique({ - where: { id }, - include: { - seatClass: true, - seats: { - orderBy: [{ row: 'asc' }, { col: 'asc' }], - }, - assignments: { - include: { schedule: { include: { originStation: true, destinationStation: true } } }, - orderBy: { schedule: { departureAt: 'desc' } }, - take: 5, - }, - _count: { select: { seats: true, assignments: true } }, - }, - }); - if (!coach) throw new NotFoundException('Coach not found'); - - // Group seats by row to reflect the physical arrangement layout - const rowMap = new Map(); - for (const seat of coach.seats) { - if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); - rowMap.get(seat.row)!.push(seat); - } - - const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats })); - - const seatStatusSummary = { - total: coach.seats.length, - available: coach.seats.filter(s => s.status === 'AVAILABLE').length, - held: coach.seats.filter(s => s.status === 'HELD').length, - booked: coach.seats.filter(s => s.status === 'BOOKED').length, - blocked: coach.seats.filter(s => s.status === 'BLOCKED').length, - }; - - const { seats, ...coachData } = coach; - return { ...coachData, seatsByRow, seatStatusSummary }; - } - - async listCoaches(dto: ListCoachesDto) { - const where: any = {}; - if (dto.isActive !== undefined) where.isActive = dto.isActive; - if (dto.mode) where.mode = dto.mode; - if (dto.seatClassId) where.seatClassId = dto.seatClassId; - if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } }; - - const coaches = await this.prisma.coach.findMany({ - where, - include: { - seatClass: true, - seats: { select: { status: true } }, - _count: { select: { seats: true, assignments: true } }, - }, - orderBy: [{ isActive: 'desc' }, { label: 'asc' }], - }); - - return coaches.map(({ seats, ...coach }) => ({ - ...coach, - seatStatusSummary: { - total: seats.length, - available: seats.filter(s => s.status === 'AVAILABLE').length, - held: seats.filter(s => s.status === 'HELD').length, - booked: seats.filter(s => s.status === 'BOOKED').length, - blocked: seats.filter(s => s.status === 'BLOCKED').length, - }, - })); - } - - async createCoach(dto: CreateCoachDto) { - const mode = dto.mode ?? 'seat'; - const totalUnits = dto.totalUnits ?? 0; - - const isBed = mode === 'bed'; - const arrangement = isBed - ? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2') - : (dto.seatArrangement ?? '2+2'); - - if (totalUnits > 0) { - const groups = parseArrangement(arrangement); - if (groups.some(isNaN)) { - throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`); - } - } - - const coach = await this.prisma.coach.create({ data: dto }); - - if (totalUnits > 0) { - const seats = isBed - ? buildBedSeats(coach.id, coach.label, arrangement, totalUnits) - : buildSeatSeats(coach.id, coach.label, arrangement, totalUnits); - await this.prisma.seat.createMany({ data: seats, skipDuplicates: true }); - } - - return this.prisma.coach.findUnique({ - where: { id: coach.id }, - include: { seatClass: true, _count: { select: { seats: true } } }, - }); - } - - async updateCoach(id: string, dto: UpdateCoachDto) { - const coach = await this.prisma.coach.findUnique({ where: { id } }); - if (!coach) throw new NotFoundException('Coach not found'); - return this.prisma.coach.update({ where: { id }, data: dto }); - } - async deleteTrain(id: string) { const train = await this.prisma.train.findUnique({ where: { id } }); if (!train) throw new NotFoundException('Train not found'); return this.prisma.train.delete({ where: { id } }); } + async getCoach(id: string) { + const coach = await this.prisma.coach.findUnique({ + where: { id }, + include: { + coachType: true, + seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, + assignments: { + include: { schedule: { include: { originStation: true, destinationStation: true } } }, + orderBy: { schedule: { departureAt: 'desc' } }, + take: 5, + }, + }, + }); + if (!coach) throw new NotFoundException('Coach not found'); + return coach; + } + + async listCoaches(dto: ListCoachesDto) { + const where: any = {}; + if (dto.status) where.status = dto.status; + if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } }; + + return this.prisma.coach.findMany({ + where, + include: { coachType: true }, + orderBy: { number: 'asc' }, + }); + } + + async createCoach(dto: CreateCoachDto) { + const groups = parseArrangement(dto.arrangement); + if (groups.some(isNaN)) { + throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`); + } + + const coach = await this.prisma.coach.create({ + data: { + coachTypeId: dto.coachTypeId, + number: dto.number, + arrangement: dto.arrangement, + capacity: dto.capacity, + status: dto.status || 'ACTIVE', + }, + include: { coachType: true }, + }); + + if (dto.capacity > 0) { + const seatClass = coach.coachType?.name || ''; + const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass); + await this.prisma.seat.createMany({ data: seats }); + } + + return coach; + } + + async updateCoach(id: string, dto: UpdateCoachDto) { + const coach = await this.prisma.coach.findUnique({ where: { id } }); + if (!coach) throw new NotFoundException('Coach not found'); + + return this.prisma.coach.update({ + where: { id }, + data: { + arrangement: dto.arrangement, + capacity: dto.capacity, + status: dto.status, + }, + include: { coachType: true }, + }); + } + async deleteCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id } }); if (!coach) throw new NotFoundException('Coach not found'); + + // Get all seat IDs for this coach + const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } }); + const seatIds = seats.map(s => s.id); + + // Delete in order of foreign key dependencies + if (seatIds.length > 0) { + // 1. Delete seat blocks (references seats) + await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } }); + + // 2. Delete ticket seats (references seats) + await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } }); + + // 3. Delete booking seats (references seats) + await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } }); + + // 4. Delete journey segments with these seats + await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } }); + } + + // 5. Delete all associated seats + await this.prisma.seat.deleteMany({ where: { coachId: id } }); + + // 6. Delete coach assignments + await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } }); + + // 7. Finally delete the coach return this.prisma.coach.delete({ where: { id } }); } @@ -251,19 +339,6 @@ export class FleetService { return this.prisma.coachAssignment.delete({ where: { id } }); } - async createSeatBatch(dto: CreateSeatBatchDto) { - const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } }); - if (!coach) throw new NotFoundException('Coach not found'); - const seats = []; - for (let row = 1; row <= dto.rows; row++) { - for (const col of dto.cols) { - seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` }); - } - } - await this.prisma.seat.createMany({ data: seats, skipDuplicates: true }); - return { created: seats.length }; - } - async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), @@ -271,6 +346,12 @@ export class FleetService { this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }), ]); - return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 }; + return { + totalTrains, + totalSchedules, + totalSeats, + bookedSeats, + occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0, + }; } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 8864a6363..39fd235a4 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -92,22 +92,22 @@ export class PassengersService { } async getProfile(passengerId: string) { - const p = await this.prisma.passenger.findUnique({ + const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true, email: true, phone: true } }, - bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } }, + bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } }, loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, }, }); - if (!p) throw new NotFoundException('Passenger not found'); + if (!passenger) throw new NotFoundException('Passenger not found'); return { - id: p.id, - fullName: p.user.fullName, - email: p.user.email, - phone: p.user.phone, - createdAt: p.createdAt, - bookings: p.bookings.map((b) => ({ + id: passenger.id, + fullName: passenger.user.fullName, + email: passenger.user.email, + phone: passenger.user.phone, + createdAt: passenger.createdAt, + bookings: passenger.bookings.map((b) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt, trip: { number: b.schedule.train.number, @@ -115,7 +115,7 @@ export class PassengersService { destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city }, departureAt: b.schedule.departureAt, }, - passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })), + passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })), })), }; } @@ -207,7 +207,6 @@ export class PassengersService { const isLoggedIn = !!dto.userId; let verifiedData: any = null; - // Auto-verify Ethiopian passengers with national ID if Fayda is enabled if (isEthiopian && dto.verifyWithFayda !== false) { try { const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!); @@ -215,12 +214,10 @@ export class PassengersService { verifiedData = verification.passengerData; } } catch (error) { - // If verification fails, continue with manual data console.warn('Fayda verification failed, using manual data:', error); } } - // Use verified data if available, otherwise use provided data const finalData = { passengerName: verifiedData?.fullName || dto.passengerName, dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth), @@ -230,7 +227,6 @@ export class PassengersService { email: dto.email, }; - // If logged in, update user profile and link passenger if (isLoggedIn) { const user = await this.prisma.user.findUnique({ where: { id: dto.userId }, @@ -241,7 +237,6 @@ export class PassengersService { throw new BadRequestException('User not found'); } - // Update user record if not already verified if (!user.faydaVerified && verifiedData) { await this.prisma.user.update({ where: { id: dto.userId }, @@ -267,7 +262,6 @@ export class PassengersService { }; } - // Guest user - save to SavedPassengerProfile const profile = await this.prisma.savedPassengerProfile.create({ data: { deviceId: dto.deviceId, @@ -318,4 +312,4 @@ export class PassengersService { affectedModules: usage, }; } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 78ffe2196..0fe3bdd3b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -40,19 +40,17 @@ describe('Payments E2E', () => { data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 }, }); - const seatClass = await prisma.seatClass.upsert({ - where: { name: 'Economy Regular' }, - update: {}, - create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true }, + const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } }); + + const seatClass = await prisma.seatClass.create({ + data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id }, }); const coach = await prisma.coach.create({ - data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 }, + data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' }, }); - await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } }); - - const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } }); + const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } }); const booking = await prisma.booking.create({ data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' }, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3b3fd9b83..bb8792a5b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { TripStatus } from '@prisma/client'; @@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client'; export class SchedulesController { constructor(private service: SchedulesService) {} + @Post('bulk-generate') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Bulk generate repetitive schedules', + description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.', + }) + @ApiResponse({ status: 201, description: 'Schedules generated successfully' }) + @ApiResponse({ status: 400, description: 'Invalid parameters or route not found' }) + bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { + return this.service.bulkGenerateSchedules(dto); + } + @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create a train schedule from a route template', - description: `Creates a schedule by referencing a Route (routeId). -Stops are automatically copied from the route's RouteStop definitions. -You supply the actual planned arrival/departure times per stop sequence. -Origin and destination are derived from the first and last route stop — no need to specify them manually.`, + description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`, }) @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' }) @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' }) @@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee return this.service.listSchedules({ date, routeId, trainId, status }); } - // Static routes before parameterised ones + // ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) ===== + @Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' }) @ApiResponse({ status: 201, description: 'Fare rule created' }) createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } + @Post('segment-fares') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' }) + @ApiResponse({ status: 201, description: 'Segment fare rule created' }) + createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } + + @Get('routes/:routeId/segment-fares') + @ApiOperation({ summary: 'List all segment fare rules for a route' }) + @ApiParam({ name: 'routeId', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'List of segment fare rules' }) + getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } + + @Patch('segment-fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a segment fare rule' }) + @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) + @ApiResponse({ status: 200, description: 'Segment fare rule updated' }) + updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } + + @Delete('segment-fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a segment fare rule' }) + @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) + @ApiResponse({ status: 200, description: 'Segment fare rule deleted' }) + deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } + + // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== + @Get(':id') @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee @Patch(':id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update a schedule' }) + @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiResponse({ status: 200, description: 'Schedule updated' }) @ApiResponse({ status: 404, description: 'Schedule not found' }) - updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) { - return this.service.updateSchedule(id, dto); + updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { + return this.service.updateSchedulePartial(id, dto); } @Patch(':id/status') @@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee return this.service.deleteSchedule(id); } - // ── Stop Times ───────────────────────────────────────────────────────────── - @Get(':id/stops') @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } - // ── Fares ────────────────────────────────────────────────────────────────── - @Get(':scheduleId/fares') @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) @@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee return this.service.syncFaresFromEngine(id); } - // ── Coach Assignments ────────────────────────────────────────────────────── - @Post(':id/coaches') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 64ab95384..0752e7565 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -34,6 +34,13 @@ export class CreateScheduleDto { plannedTimes: PlannedStopTimeDto[]; } +export class UpdateScheduleDto { + @ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string; + @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; + @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; + @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; +} + export class UpdateStopTimeDto { @ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string; @ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string; @@ -50,6 +57,17 @@ export class CreateFareRuleDto { @ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string; } +export class CreateSegmentFareRuleDto { + @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; + @ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number; + @ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number; + @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string; + @ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number; + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string; + @ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string; + @ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string; +} + export class ListSchedulesDto { @ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' }) @IsOptional() @IsDateString() date?: string; @@ -67,3 +85,21 @@ export class ListSchedulesDto { export class UpdateScheduleStatusDto { @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; } + +export class BulkCreateSchedulesDto { + @ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string; + @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; + @ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string; + @ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number; + @ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number; + @ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number; + @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' }) + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; +} + +export class BulkSchedulesResponseDto { + @ApiProperty() schedulesCreated: number; + @ApiProperty() errors: string[]; + @ApiProperty() scheduleIds: string[]; +} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 820c5b8a0..af03937f8 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; -import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto'; @Injectable() export class SchedulesService { @@ -10,9 +10,55 @@ export class SchedulesService { private prisma: PrismaService, private routesService: RoutesService, private fareEngine: FareEngineService, - ) {} + ) { } - // ── Schedule CRUD ────────────────────────────────────────────────────────── + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { + const startDate = new Date(dto.startDateTime); + const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); + const errors: string[] = []; + const scheduleIds: string[] = []; + + // Validate route and get stops for plannedTimes generation + const route = await this.prisma.route.findUnique({ + where: { id: dto.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + if (!route) throw new NotFoundException('Route not found'); + if (!route.active) throw new BadRequestException('Route is not active'); + + let currentDate = new Date(startDate); + let scheduleCount = 0; + + while (currentDate < endDate) { + try { + const departureAt = new Date(currentDate); + const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000); + + const createDto: CreateScheduleDto = { + trainId: dto.trainId, + routeId: dto.routeId, + departureAt: departureAt.toISOString(), + arrivalAt: arrivalAt.toISOString(), + plannedTimes: dto.plannedTimes || [], + }; + + const schedule = await this.createSchedule(createDto); + scheduleIds.push(schedule.id); + scheduleCount++; + } catch (error) { + errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); + } + + // Move to next repetition + currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); + } + + return { + schedulesCreated: scheduleCount, + errors, + scheduleIds, + }; + } async listSchedules(dto: ListSchedulesDto) { const where: any = {}; @@ -58,28 +104,48 @@ export class SchedulesService { if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); + // Check for duplicate schedule with same train, route, and date + const depDate = new Date(dep); + depDate.setHours(0, 0, 0, 0); + const nextDay = new Date(depDate); + nextDay.setDate(nextDay.getDate() + 1); + + const existingSchedule = await this.prisma.trainSchedule.findFirst({ + where: { + trainId: dto.trainId, + routeId: dto.routeId, + departureAt: { + gte: depDate, + lt: nextDay, + }, + }, + }); + + if (existingSchedule) { + throw new BadRequestException( + `A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`, + ); + } + // Auto-generate plannedTimes if not provided or empty let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { const totalDuration = arr.getTime() - dep.getTime(); const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - + plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - + if (index === 0) { - // First stop - use departure time stopTime = dep; } else if (index === route.stops.length - 1) { - // Last stop - use arrival time stopTime = arr; } else { - // Intermediate stops - calculate based on distance proportion const stopDistance = stop.distanceKm || 0; const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - + return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -113,7 +179,6 @@ export class SchedulesService { include: { train: true, originStation: true, destinationStation: true }, }); - // Copy route stops into TripStopTime with the provided planned times const plannedTimesMap = Object.fromEntries( plannedTimes.map(t => [t.sequence, t]), ); @@ -130,7 +195,7 @@ export class SchedulesService { originStation: true, destinationStation: true, coachAssignments: { - include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } }, + include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, @@ -138,18 +203,16 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - // Compute effective seat statuses from SeatHold + JourneySegment - // (seat.status DB column is no longer written during booking) - const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id)); + const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds); return { ...schedule, - coachAssignments: schedule.coachAssignments.map(a => ({ + coachAssignments: schedule.coachAssignments.map((a: any) => ({ ...a, coach: { ...a.coach, - seats: a.coach.seats.map(s => ({ + seats: a.coach.seats.map((s: any) => ({ ...s, status: effectiveStatuses.get(s.id) ?? s.status, })), @@ -158,12 +221,6 @@ export class SchedulesService { }; } - /** - * Computes effective seat status for a schedule by checking active SeatHolds - * and confirmed JourneySegments. The DB seat.status column is not written - * during segment-based booking, so this overlay is required. - * Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE - */ private async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -204,7 +261,6 @@ export class SchedulesService { const arr = new Date(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - // Validate route exists and has stops const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -213,7 +269,6 @@ export class SchedulesService { if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); - // Derive origin and destination from first and last route stop const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; @@ -231,18 +286,16 @@ export class SchedulesService { }, }); - // Delete existing stop times and recreate await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); - // Auto-generate plannedTimes if not provided let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { const totalDuration = arr.getTime() - dep.getTime(); const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - + plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - + if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -252,7 +305,7 @@ export class SchedulesService { const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - + return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -276,19 +329,53 @@ export class SchedulesService { async deleteSchedule(id: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); - - // Delete related records first (in dependency order) + await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } }); + + const bookings = await this.prisma.booking.findMany({ + where: { scheduleId: id }, + select: { id: true }, + }); + const bookingIds = bookings.map(b => b.id); + + if (bookingIds.length > 0) { + const paymentIntents = await this.prisma.paymentIntent.findMany({ + where: { bookingId: { in: bookingIds } }, + select: { id: true }, + }); + const paymentIntentIds = paymentIntents.map(pi => pi.id); + + if (paymentIntentIds.length > 0) { + await this.prisma.paymentRefund.deleteMany({ + where: { paymentIntentId: { in: paymentIntentIds } }, + }); + } + + await this.prisma.ticket.deleteMany({ + where: { bookingId: { in: bookingIds } }, + }); + await this.prisma.bookingSeat.deleteMany({ + where: { bookingId: { in: bookingIds } }, + }); + await this.prisma.bookingModification.deleteMany({ + where: { bookingId: { in: bookingIds } }, + }); + await this.prisma.bookingCancellation.deleteMany({ + where: { bookingId: { in: bookingIds } }, + }); + await this.prisma.paymentIntent.deleteMany({ + where: { bookingId: { in: bookingIds } }, + }); + } + await this.prisma.booking.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); - + return this.prisma.trainSchedule.delete({ where: { id } }); } - // ── Stop Times (per-schedule overrides) ─────────────────────────────────── - getStops(scheduleId: string) { return this.prisma.tripStopTime.findMany({ where: { scheduleId }, @@ -314,8 +401,6 @@ export class SchedulesService { }); } - // ── Fare Rules ───────────────────────────────────────────────────────────── - createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto; return this.prisma.fareRule.create({ @@ -329,6 +414,43 @@ export class SchedulesService { }); } + createSegmentFareRule(dto: any) { + const { validFrom, validUntil, ...rest } = dto; + return this.prisma.segmentFareRule.create({ + data: { + ...rest, + validFrom: new Date(validFrom), + validUntil: validUntil ? new Date(validUntil) : null, + }, + include: { seatClass: true, route: true }, + }); + } + + getSegmentFares(routeId: string) { + return this.prisma.segmentFareRule.findMany({ + where: { routeId }, + include: { seatClass: true, route: true }, + orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }], + }); + } + + deleteSegmentFareRule(id: string) { + return this.prisma.segmentFareRule.delete({ where: { id } }); + } + + updateSegmentFareRule(id: string, dto: any) { + const { validFrom, validUntil, ...rest } = dto; + return this.prisma.segmentFareRule.update({ + where: { id }, + data: { + ...rest, + validFrom: validFrom ? new Date(validFrom) : undefined, + validUntil: validUntil ? new Date(validUntil) : null, + }, + include: { seatClass: true, route: true }, + }); + } + getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) { return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality); } @@ -337,10 +459,6 @@ export class SchedulesService { return this.fareEngine.calculateAllForSchedule(scheduleId, nationality); } - /** - * Recalculate fares for all active seat classes on a schedule using the fare engine - * and upsert them as FareRule records scoped to this schedule. - */ async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> { const results = await this.fareEngine.calculateAllForSchedule(scheduleId); const errors: string[] = []; @@ -352,7 +470,6 @@ export class SchedulesService { const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } }); if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; } - // Expire any existing active rule for this schedule + seat class await this.prisma.fareRule.updateMany({ where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null }, data: { validUntil: now }, @@ -377,8 +494,6 @@ export class SchedulesService { return { synced, errors }; } - // ── Coach Assignments ────────────────────────────────────────────────────── - async assignCoaches( scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>, @@ -386,7 +501,6 @@ export class SchedulesService { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); - // Validate all coaches exist const coachIds = coaches.map(c => c.coachId); const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } }, @@ -395,18 +509,16 @@ export class SchedulesService { throw new NotFoundException('One or more coaches not found'); } - // Remove existing assignments await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); - // Create new assignments - await this.prisma.coachAssignment.createMany({ - data: coaches.map(c => ({ - scheduleId, - coachId: c.coachId, - positionNumber: c.positionNumber, - isOperational: true, - })), - }); + const data = coaches.map((c, idx) => ({ + scheduleId, + coachId: c.coachId, + positionNumber: idx + 1, + isOperational: true, + })); + + await this.prisma.coachAssignment.createMany({ data }); return { message: 'Coaches assigned successfully', count: coaches.length }; } @@ -417,7 +529,6 @@ export class SchedulesService { include: { coach: { include: { - seatClass: true, seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, }, }, @@ -426,6 +537,41 @@ export class SchedulesService { }); } + async updateSchedulePartial(id: string, dto: UpdateScheduleDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const updateData: any = {}; + + if (dto.departureAt || dto.arrivalAt) { + const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt); + const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt); + + if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); + + updateData.departureAt = dep; + updateData.arrivalAt = arr; + updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); + } + + if (dto.status) { + updateData.status = dto.status; + } + + if (Object.keys(updateData).length > 0) { + await this.prisma.trainSchedule.update({ + where: { id }, + data: updateData, + }); + } + + if (dto.coaches && dto.coaches.length > 0) { + await this.assignCoaches(id, dto.coaches); + } + + return this.getSchedule(id); + } + async removeCoachAssignment(scheduleId: string, coachId: string) { const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId }, @@ -435,4 +581,4 @@ export class SchedulesService { await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); return { message: 'Coach assignment removed' }; } -} +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 19eb25652..e6b6841f7 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -22,8 +22,6 @@ export class SearchService { const nextDay = new Date(date.getTime() + 86_400_000); const totalPassengers = dto.adultCount + (dto.childCount ?? 0); - // Find all schedules that have BOTH origin and destination as stops - // (not just terminal-to-terminal) and depart on the requested date const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, @@ -36,7 +34,7 @@ export class SearchService { destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { - include: { coach: { include: { seats: true, seatClass: true } } }, + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, }, }); @@ -44,36 +42,46 @@ export class SearchService { const results = []; for (const schedule of schedules) { - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); + const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); - // Both stops must exist and origin must come before destination if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; - // Compute per-seat availability for the requested segment range - // A seat is available if no active booking/hold overlaps [originSeq, destSeq) const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { - const className = assignment.coach.seatClass.name; - if (!availabilityByClass[className]) availabilityByClass[className] = 0; + // Get seat class names from coach type + const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; + + for (const seatClassName of seatClassNames) { + if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0; + } + // Count available seats (skip blocked and removed seats) for (const seat of assignment.coach.seats) { + // Skip blocked seats if (seat.status === 'BLOCKED') continue; - // Use segment-aware check — a seat booked A→B is still free for B→D + + // Skip removed seats (empty seatNumber) + if (!seat.seatNumber || !seat.seatNumber.trim()) continue; + const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); - if (free) availabilityByClass[className]++; + + if (free) { + // Group by seat class - use the first seat class for now + // In a full implementation, seats would have a seatClassId + const className = seatClassNames[0] || 'Standard'; + availabilityByClass[className]++; + } } } - // Departure/arrival times for the requested leg (not the full schedule) const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; - // Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals const faresByClass = await this.calculateFaresForSegment( schedule, dto.originStationId, @@ -106,14 +114,14 @@ export class SearchService { ), status: schedule.status, stops: schedule.stopTimes - .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map(st => ({ - stationId: st.stationId, - stationName: st.station.name, - sequence: st.sequence, - plannedArrivalAt: st.plannedArrivalAt, - plannedDepartureAt: st.plannedDepartureAt, - })), + .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map((st: any) => ({ + stationId: st.stationId, + stationName: st.station.name, + sequence: st.sequence, + plannedArrivalAt: st.plannedArrivalAt, + plannedDepartureAt: st.plannedDepartureAt, + })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), faresByClass, @@ -134,51 +142,19 @@ export class SearchService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); + const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) { throw new NotFoundException('Origin or destination not found on this schedule'); } const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); - // Compute route codes for fare lookup const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; const now = new Date(); const nationality = dto.nationality; - // Query fare rules with specificity ordering: - // 1. schedule+segment+nationality - // 2. schedule+segment - // 3. schedule+full-route+nationality - // 4. schedule+full-route - // 5. schedule+global - // 6. segment+nationality - // 7. segment - // 8. full-route+nationality - // 9. full-route - // 10. global - const fareRule = await this.prisma.fareRule.findFirst({ - where: { - seatClassId: seatClass?.id, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, - orderBy: [ - // Prioritize schedule-specific rules - { tripId: { sort: 'desc', nulls: 'last' } }, - // Then prioritize nationality match - { nationality: { sort: 'desc', nulls: 'last' } }, - // Most recent validFrom - { validFrom: 'desc' }, - ], - }); - - // Manual specificity filtering to find best match const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId: seatClass?.id, @@ -243,38 +219,40 @@ export class SearchService { }; } - /** - * Calculate fares for a specific segment of a schedule - */ private async calculateFaresForSegment( schedule: any, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { - // Get seat classes that are actually assigned to this schedule via coaches - const assignedSeatClassIds: string[] = Array.from( + // Get unique seat classes from all coaches assigned to this schedule via their coach types + const seatClassIds: string[] = Array.from( new Set( - schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string) + schedule.coachAssignments + .flatMap((a: any) => a.coach.coachType?.seatClasses || []) + .map((sc: any) => sc.id) + .filter((id: any) => id) ) ); - // Get only the seat classes that are assigned to this schedule - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: assignedSeatClassIds } - }, - orderBy: { basePrice: 'asc' }, - }); - - // If no coaches assigned, return empty array - if (seatClasses.length === 0) { + if (seatClassIds.length === 0) { console.log(`No seat classes assigned to schedule ${schedule.id}`); return []; } - // If schedule has a route, use route-based calculation + const seatClasses = await this.prisma.seatClass.findMany({ + where: { + isActive: true, + id: { in: seatClassIds } + }, + orderBy: { baseFareMinor: 'asc' }, + }); + + if (seatClasses.length === 0) { + console.log(`No active seat classes for schedule ${schedule.id}`); + return []; + } + if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { @@ -303,7 +281,6 @@ export class SearchService { } } - // Fallback: Try to get fares from FareRule table const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); @@ -314,26 +291,25 @@ export class SearchService { const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: assignedSeatClassIds }, + seatClassId: { in: seatClassIds }, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, - include: { seatClass: true }, }); if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); + const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); return fareRules.map(rule => ({ - seatClassName: rule.seatClass.name, + seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', baseFareMinor: rule.baseFareMinor, })); } } - // Last resort: Return default fares only for assigned seat classes console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); return seatClasses.map(sc => ({ seatClassName: sc.name, @@ -359,60 +335,6 @@ export class SearchService { return fares[seatClassName] ?? 45000; } - /** - * Fallback method to get fares from FareRule table when fare engine fails - */ - private async getFallbackFares( - scheduleId: string, - originCode: string, - destCode: string, - ): Promise> { - const segmentRoute = `${originCode}-${destCode}`; - const now = new Date(); - - // Try to find fare rules for this segment - const fareRules = await this.prisma.fareRule.findMany({ - where: { - route: segmentRoute, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, - include: { seatClass: true }, - }); - - if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - return fareRules.map(rule => ({ - seatClassName: rule.seatClass.name, - baseFareMinor: rule.baseFareMinor, - })); - } - - // If no segment-specific rules, return default fares - console.log(`No fare rules found for ${segmentRoute}, using defaults`); - return [ - { seatClassName: 'Economy Regular', baseFareMinor: 35000 }, - { seatClassName: 'Economy Bed', baseFareMinor: 49000 }, - { seatClassName: 'VIP Bed', baseFareMinor: 63000 }, - ]; - } - - /** - * Select the best matching fare rule based on specificity: - * 1. schedule+segment+nationality - * 2. schedule+segment - * 3. schedule+full-route+nationality - * 4. schedule+full-route - * 5. schedule+global - * 6. segment+nationality - * 7. segment - * 8. full-route+nationality - * 9. full-route - * 10. global - */ private selectBestFareRule( candidates: any[], scheduleId: string, @@ -421,19 +343,16 @@ export class SearchService { nationality?: string, ): any | null { const priorities = [ - // Schedule-specific rules { tripId: scheduleId, route: segmentRoute, nationality }, { tripId: scheduleId, route: segmentRoute, nationality: null }, { tripId: scheduleId, route: fullRoute, nationality }, { tripId: scheduleId, route: fullRoute, nationality: null }, { tripId: scheduleId, route: null, nationality }, { tripId: scheduleId, route: null, nationality: null }, - // Route-specific rules (no schedule) { tripId: null, route: segmentRoute, nationality }, { tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: fullRoute, nationality }, { tripId: null, route: fullRoute, nationality: null }, - // Global rules { tripId: null, route: null, nationality }, { tripId: null, route: null, nationality: null }, ]; diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 982c37209..3f2fafebf 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -1,41 +1,33 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; @Injectable() export class SeatClassesService { constructor(private prisma: PrismaService) {} - private readonly coachInclude = { - coaches: { - select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } }, - orderBy: { label: 'asc' as const }, - }, - }; - listSeatClasses() { - return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude }); + return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } }); } async getSeatClass(id: string) { - const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude }); + const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); return sc; } - async createSeatClass(dto: CreateSeatClassDto) { + async createSeatClass(dto: any) { try { - return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude }); + return await this.prisma.seatClass.create({ data: dto }); } catch (e: any) { if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); throw e; } } - async updateSeatClass(id: string, dto: UpdateSeatClassDto) { + async updateSeatClass(id: string, dto: any) { const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); - return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude }); + return this.prisma.seatClass.update({ where: { id }, data: dto }); } async deleteSeatClass(id: string) { diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index e5d61c829..8914ca465 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { SeatsService } from './seats.service'; import { HoldSeatsDto } from './seats.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { IamGuard } from '../../common/iam-adapter'; @ApiTags('Seats') @Controller('seats') @@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiResponse({ status: 404, description: 'Hold not found' }) releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } + // ── Seat Block / Unblock ─────────────────────────────────────────────────── + @Post(':seatId/block') + @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' }) + @ApiParam({ name: 'seatId', description: 'Seat UUID' }) + @ApiResponse({ status: 200, description: 'Seat blocked' }) + blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) { + return this.service.blockSeat(seatId, body.reason); + } + + @Delete(':seatId/block') + @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Unblock a seat' }) + @ApiParam({ name: 'seatId', description: 'Seat UUID' }) + @ApiResponse({ status: 200, description: 'Seat unblocked' }) + unblockSeat(@Param('seatId') seatId: string) { + return this.service.unblockSeat(seatId); + } + + // ── Remove Seat ──────────────────────────────────────────────────────────── + @Patch(':seatId/remove') + @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' }) + @ApiParam({ name: 'seatId', description: 'Seat UUID' }) + @ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' }) + @ApiResponse({ status: 404, description: 'Seat not found' }) + removeSeat(@Param('seatId') seatId: string) { + return this.service.removeSeat(seatId); + } + + @Patch(':seatId/undo-remove') + @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' }) + @ApiParam({ name: 'seatId', description: 'Seat UUID' }) + @ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' }) + @ApiResponse({ status: 404, description: 'Seat not found' }) + @ApiResponse({ status: 400, description: 'Seat is not removed' }) + undoRemoveSeat(@Param('seatId') seatId: string) { + return this.service.undoRemoveSeat(seatId); + } + @Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' }) async exportCSV(@Param('scheduleId') scheduleId: string) { const csv = await this.service.exportSeatsCSV(scheduleId); 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 014e0918a..a21797e0f 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { SeatsController } from './seats.controller'; import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; +import { IamModule } from '../../common/iam.module'; @Module({ - imports: [SegmentsModule], + imports: [SegmentsModule, HttpModule, IamModule], 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 b1b1a3af3..9e8d55364 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -11,48 +11,69 @@ export class SeatsService { private segmentsService: SegmentsService, ) {} - // ── Seat Map ────────────────────────────────────────────────────────────── async getSeatMap(scheduleId: string, coachId?: string) { const assignments = await this.prisma.coachAssignment.findMany({ where: { scheduleId, ...(coachId ? { coachId } : {}) }, - include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } }, + include: { + coach: { + include: { + seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, + coachType: { include: { seatClasses: true } }, + }, + }, + }, orderBy: { positionNumber: 'asc' }, }); - const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`); + + const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds); - return { - coaches: assignments.map((a) => ({ - id: a.coach.id, - assignmentId: a.id, - name: `Coach ${a.coach.label}`, - seatClass: a.coach.seatClass.name, - positionNumber: a.positionNumber, - seats: a.coach.seats.map((s) => ({ - id: s.id, - number: s.label, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - bedPosition: s.bedPosition, - })), - })), + const response = { + coaches: assignments.map((a) => { + // Include all seats (both valid and removed with negative seatNumbers) + const allSeats = a.coach.seats; + const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); + const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard'; + + return { + id: a.coach.id, + assignmentId: a.id, + coachNumber: a.coach.number, + label: a.coach.number, + mode: a.coach.status, + name: `Coach ${a.coach.number}`, + seatClass, + positionNumber: a.positionNumber, + seatArrangement: a.coach.arrangement, + totalSeats: a.coach.capacity, + seats: allSeats.map((s) => ({ + id: s.id, + seatNumber: s.seatNumber, + number: s.seatNumber, + label: s.seatNumber, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + bedPosition: s.bedPosition, + coach: { + id: a.coach.id, + coachNumber: a.coach.number, + label: a.coach.number, + }, + })), + }; + }), }; + + console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`); + return response; } - /** - * Computes the effective seat status for a set of seats on a specific schedule - * by checking active SeatHolds and confirmed JourneySegments. - * - * Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE - * - * This is needed because seat.status is no longer written during booking — - * availability is segment-scoped, so the DB column stays AVAILABLE even when held. - */ async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -61,7 +82,6 @@ export class SeatsService { if (seatIds.length === 0) return statusMap; - // 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD const activeHolds = await this.prisma.seatHold.findMany({ where: { scheduleId, @@ -76,8 +96,6 @@ export class SeatsService { } } - // 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED - // (overwrites HELD if the same seat has a confirmed booking) const bookedSegments = await this.prisma.journeySegment.findMany({ where: { scheduleId, @@ -93,24 +111,21 @@ export class SeatsService { return statusMap; } - // ── Hold / Release ──────────────────────────────────────────────────────── async holdSeats(dto: HoldSeatsDto) { - // ── Validate request integrity ─────────────────────────────────────────── const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); if (new Set(passengerIds).size !== passengerIds.length) - throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once'); + throw new BadRequestException('Duplicate passengerId in passengers list'); if (new Set(seatIds).size !== seatIds.length) - throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger'); + throw new BadRequestException('Duplicate seatId in passengers list'); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const hold = await this.prisma.$transaction(async (tx) => { - // ── 1. Validate seats exist and none are BLOCKED ───────────────────── const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, - select: { id: true, status: true, label: true }, + select: { id: true, status: true, seatNumber: true }, }); if (seats.length !== seatIds.length) { @@ -121,11 +136,10 @@ export class SeatsService { const blocked = seats.filter(s => s.status === 'BLOCKED'); if (blocked.length > 0) - throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`); + throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`); - const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label])); + const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); - // ── 2. Resolve requested leg sequences ────────────────────────────── const stopTimes = await tx.tripStopTime.findMany({ where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, @@ -137,17 +151,15 @@ export class SeatsService { const reqTo = seqOf(dto.destinationStationId); if (reqFrom === undefined || reqTo === undefined) - throw new BadRequestException('Origin or destination station not found on this schedule'); + throw new BadRequestException('Origin or destination station not found'); if (reqFrom >= reqTo) throw new BadRequestException('Origin must come before destination'); - // ── 3. Load active holds for this schedule ─────────────────────────── const activeHolds = await tx.seatHold.findMany({ where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, select: { seatIds: true, createdBy: true }, }); - // Parse each hold's leg range and passenger list const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; for (const h of activeHolds) { try { @@ -164,32 +176,28 @@ export class SeatsService { }); } } - } catch { /* ignore malformed */ } + } catch { /* ignore */ } } - // ── 4. Per-passenger validation with overlap check ─────────────────── for (const { passengerId, seatId } of dto.passengers) { for (const hold of parsedHolds) { const legsOverlap = hold.from < reqTo && reqFrom < hold.to; - if (!legsOverlap) continue; // non-overlapping leg — no conflict + if (!legsOverlap) continue; - // Rule A: seat is held on an overlapping leg if (hold.seatIds.includes(seatId)) { throw new ConflictException( - `Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`, + `Seat ${seatLabelById[seatId]} is already held for this leg`, ); } - // Rule B: passenger already holds a seat on an overlapping leg if (hold.passengerIds.includes(passengerId)) { throw new ConflictException( - `Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`, + `Passenger already holds a seat on this journey leg`, ); } } } - // Store passenger→seat mapping AND leg in createdBy as JSON const holdMeta = { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, @@ -228,12 +236,7 @@ export class SeatsService { return this.enrichHold(hold); } - /** - * Resolves the opaque fareQuoteId leg encoding into human-readable station - * names and enriches the hold with schedule, seat, and leg details. - */ private async enrichHold(hold: any) { - // Decode leg and passenger→seat mapping from createdBy JSON let originStationId: string | null = null; let destinationStationId: string | null = null; let passengerSeatMap: { passengerId: string; seatId: string }[] = []; @@ -241,7 +244,6 @@ export class SeatsService { try { if (hold.createdBy) { const raw = hold.createdBy; - // Guard: only parse if it looks like a JSON object, not a plain number/string if (typeof raw === 'string' && raw.trimStart().startsWith('{')) { const meta = JSON.parse(raw); originStationId = meta.originStationId ?? null; @@ -249,7 +251,7 @@ export class SeatsService { passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : []; } } - } catch { /* ignore malformed createdBy */ } + } catch { /* ignore */ } const seatIds = hold.seatIds as string[]; @@ -262,7 +264,7 @@ export class SeatsService { destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null, this.prisma.seat.findMany({ where: { id: { in: seatIds } }, - include: { coach: { include: { seatClass: true } } }, + include: { coach: true }, }), ]); @@ -277,10 +279,8 @@ export class SeatsService { destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null; } - // Build seat map keyed by seatId for quick lookup const seatById = Object.fromEntries(seats.map(s => [s.id, s])); - // Merge passenger→seat mapping with seat details const passengers = passengerSeatMap.length > 0 ? passengerSeatMap.map(({ passengerId, seatId }) => { const s = seatById[seatId]; @@ -288,26 +288,25 @@ export class SeatsService { passengerId, seat: s ? { id: s.id, - label: s.label, + label: s.seatNumber, seatNumber: s.seatNumber, - coach: s.coach.label, - seatClass: s.coach.seatClass.name, + coach: s.coach.number, + seatClass: 'Standard', row: s.row, col: s.col, } : { id: seatId }, }; }) - // Fallback for holds created before this change : seatIds.map(seatId => { const s = seatById[seatId]; return { passengerId: hold.passengerId, seat: s ? { id: s.id, - label: s.label, + label: s.seatNumber, seatNumber: s.seatNumber, - coach: s.coach.label, - seatClass: s.coach.seatClass.name, + coach: s.coach.number, + seatClass: 'Standard', row: s.row, col: s.col, } : { id: seatId }, @@ -350,8 +349,7 @@ export class SeatsService { } async confirmSeats(seatIds: string[]) { - // No-op for status — availability is segment-scoped via JourneySegment - // seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag + // No-op } async releaseSeats(seatIds: string[]) { @@ -362,14 +360,15 @@ export class SeatsService { } } - async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise { + async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { - coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } }, + coach: { assignments: { some: { scheduleId } } }, status: 'AVAILABLE', - ...(eligibility ? { eligibility } : {}), + seatNumber: { not: '' }, + NOT: { seatNumber: { startsWith: '-' } }, }, - orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }], + orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); if (seats.length < count) { @@ -404,10 +403,10 @@ export class SeatsService { where: { scheduleId }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, }); - const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility']; + const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor']; for (const a of assignments) { for (const seat of a.coach.seats) { - rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`); + rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`); } } return rows.join('\n'); @@ -426,8 +425,8 @@ export class SeatsService { invalid++; continue; } - const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts; - if (!coachId || !row || !col || !label) { + const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; + if (!coachId || !row || !col || !seatNumber) { errors.push(`Line ${i + 2}: Missing required fields`); invalid++; continue; @@ -444,32 +443,30 @@ export class SeatsService { let imported = 0; if (!commit) { - return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] }; + return { imported: 0, errors: ['Preview mode'] }; } for (let i = 0; i < lines.length; i++) { try { const parts = lines[i].split(','); - const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts; + const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; await this.prisma.seat.upsert({ where: { coachId_row_col: { coachId, row: parseInt(row), col } }, update: { - label, + seatNumber, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, - eligibility: eligibility || null, }, create: { coachId, row: parseInt(row), col, - label, + seatNumber, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, - eligibility: eligibility || null, }, }); imported++; @@ -481,6 +478,74 @@ export class SeatsService { return { imported, errors: errors.slice(0, 10) }; } + async blockSeat(seatId: string, reason: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + + await this.prisma.seat.update({ + where: { id: seatId }, + data: { status: 'BLOCKED' }, + }); + + await this.prisma.seatBlock.create({ + data: { + seatId, + reason, + blockedBy: 'system', + }, + }); + + return { blocked: true, seatId, reason }; + } + + async unblockSeat(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + + await this.prisma.seat.update({ + where: { id: seatId }, + data: { status: 'AVAILABLE' }, + }); + + await this.prisma.seatBlock.deleteMany({ + where: { seatId }, + }); + + return { unblocked: true, seatId }; + } + + async removeSeat(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); + + // Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space + const negatedNumber = `-${seat.seatNumber}`; + await this.prisma.seat.update({ + where: { id: seatId }, + data: { seatNumber: negatedNumber }, + }); + + return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; + } + + async undoRemoveSeat(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) { + throw new BadRequestException('Seat is not removed'); + } + + // Restore original seatNumber by removing the negative sign + const originalNumber = seat.seatNumber.slice(1); + await this.prisma.seat.update({ + where: { id: seatId }, + data: { seatNumber: originalNumber }, + }); + + return { restored: true, seatId, seatNumber: originalNumber }; + } + @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); @@ -489,7 +554,6 @@ export class SeatsService { try { await this.prisma.seatHold.delete({ where: { id: hold.id } }); } catch (err) { - // Ignore if already deleted (e.g., by another process) if (err instanceof Error && !err.message.includes('P2025')) { throw err; } diff --git a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts index 8b46a77d1..7e912a6e2 100644 --- a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts +++ b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts @@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe for (const seat of seats) { if (seat.status !== 'AVAILABLE') { - throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`); + throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`); } } diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 88fd636b6..406c9e61b 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -35,14 +35,12 @@ export class EnhancedSeatsService { for (const seatId of request.seatIds) { const seat = await tx.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new BadRequestException(`Seat ${seatId} not found`); - // Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the - // segment does not overlap (another passenger may occupy a different leg) - if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`); + if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`); const free = await this.segmentsService.isSeatFreeForLeg( request.scheduleId, seatId, reqFrom, reqTo, ); - if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`); + if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`); } const expiresAt = new Date(Date.now() + 10 * 60 * 1000); @@ -51,7 +49,6 @@ export class EnhancedSeatsService { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, - // Store leg in createdBy JSON — no fareQuoteId needed createdBy: JSON.stringify({ originStationId: request.originStationId, destinationStationId: request.destinationStationId, @@ -60,7 +57,6 @@ export class EnhancedSeatsService { }, }); - // Do NOT set seat.status = HELD globally — status is segment-scoped this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments }); return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds }; }); @@ -81,7 +77,6 @@ export class EnhancedSeatsService { }); if (!schedule) throw new BadRequestException('Schedule not found'); - // Resolve the passenger's leg from createdBy JSON let originStationId: string | undefined; let destinationStationId: string | undefined; try { @@ -92,15 +87,15 @@ export class EnhancedSeatsService { } } catch { /* ignore */ } - const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined; - const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined; + const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined; + const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined; const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence; const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence; const segments: Segment[] = []; for (let i = fromSeq; i < toSeq; i++) { - const fromStop = schedule.stopTimes.find(s => s.sequence === i); - const toStop = schedule.stopTimes.find(s => s.sequence === i + 1); + const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i); + const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1); if (fromStop && toStop) { segments.push({ fromStationId: fromStop.stationId, @@ -132,7 +127,6 @@ export class EnhancedSeatsService { } } - // Do NOT set seat.status = BOOKED globally — availability is segment-scoped await tx.seatHold.delete({ where: { id: request.holdId } }); this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments }); @@ -185,22 +179,20 @@ export class EnhancedSeatsService { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, - include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } }, + include: { coachAssignments: { include: { coach: { include: { seats: true } } } } }, }); if (!schedule) throw new BadRequestException('Schedule not found'); const availableSeats = []; for (const assignment of schedule.coachAssignments) { for (const seat of assignment.coach.seats) { - // Hard-blocked seats are never available if (seat.status === 'BLOCKED') continue; - // Availability is determined purely by segment overlap — not global seat.status const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo); if (free) { availableSeats.push({ - id: seat.id, label: seat.label, - coach: assignment.coach.label, - seatClass: assignment.coach.seatClass.name, + id: seat.id, label: seat.seatNumber, + coach: assignment.coach.number, + seatClass: 'Standard', row: seat.row, col: seat.col, kind: seat.kind, isWindow: seat.isWindow, 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 71b65899c..a77714cf3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -87,9 +87,15 @@ export class TicketsService { create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, }); - // Create permanent seat blocks for all booked seats + // Update all booked seats from HELD to BOOKED and create permanent seat blocks const seatIds = booking.seats.map(bs => bs.seatId); for (const seatId of seatIds) { + // Update seat status to BOOKED + await this.prisma.seat.update({ + where: { id: seatId }, + data: { status: 'BOOKED' }, + }); + // Create permanent seat blocks for all booked seats await this.prisma.seatBlock.create({ data: { seatId, @@ -162,7 +168,7 @@ export class TicketsService { id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, - coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName, + coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName, priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload }; @@ -207,8 +213,8 @@ export class TicketsService { bookingRef: b.bookingRef, ticketId: b.ticket?.id, passengerName: b.seats[0]?.passengerName, - seatLabel: b.seats[0]?.seat.label, - coachLabel: b.seats[0]?.seat.coach.label, + seatLabel: b.seats[0]?.seat.seatNumber, + coachLabel: b.seats[0]?.seat.coach.number, qrPayload: b.ticket?.qrPayload, status: b.status, validatedAt: b.ticket?.validatedAt, diff --git a/apps/edr-passenger-web/backoffice/src/app/seat-classes/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/layout.tsx similarity index 100% rename from apps/edr-passenger-web/backoffice/src/app/seat-classes/layout.tsx rename to apps/edr-passenger-web/backoffice/src/app/classes/layout.tsx diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx new file mode 100644 index 000000000..bf547aa97 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -0,0 +1,309 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, Search } 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 ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { seatClassesApi, apiClient } from '@/lib/api'; +import { formatCurrency } from '@/lib/utils'; + +export default function ClassesPage() { + const [filters, setFilters] = useState({ search: '' }); + const [showModal, setShowModal] = useState(false); + const [editingClass, setEditingClass] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null }); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['classes', filters], + queryFn: () => seatClassesApi.getAll(), + }); + + const { data: coachTypes } = useQuery({ + queryKey: ['coach-types'], + queryFn: () => apiClient.get('/fleet/coach-types'), + }); + + const createMutation = useMutation({ + mutationFn: seatClassesApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['classes'] }); + setShowModal(false); + setEditingClass(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['classes'] }); + setShowModal(false); + setEditingClass(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: seatClassesApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['classes'] }); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const classData = { + coachTypeId: formData.get('coachTypeId') as string, + name: formData.get('name') as string, + description: formData.get('description') as string, + baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0, + }; + + if (editingClass) { + await updateMutation.mutateAsync({ id: editingClass.id, data: classData }); + } else { + await createMutation.mutateAsync(classData); + } + }; + + const handleDelete = (cls: any) => { + setDeleteConfirm({ isOpen: true, class: cls }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.class) { + await deleteMutation.mutateAsync(deleteConfirm.class.id); + setDeleteConfirm({ isOpen: false, class: null }); + } + }; + + const coachTypesArray = Array.isArray(coachTypes) ? coachTypes : ((coachTypes as any)?.data || (coachTypes as any)?.items || []); + const coachTypeMap = coachTypesArray.reduce((map: any, ct: any) => { + map[ct.id] = ct.name; + return map; + }, {}); + + const filteredClasses = (data as any)?.items || (Array.isArray(data) ? data : []); + const displayedClasses = filteredClasses.filter((cls: any) => { + if (!filters.search) return true; + const searchLower = filters.search.toLowerCase(); + return ( + cls.name?.toLowerCase().includes(searchLower) || + cls.coachType?.name?.toLowerCase().includes(searchLower) || + cls.description?.toLowerCase().includes(searchLower) + ); + }); + + const columns = [ + { + key: 'coachType', + label: 'Coach Type', + render: (cls: any) => ( + {cls.coachType?.name || coachTypeMap[cls.coachTypeId] || 'N/A'} + ), + }, + { + key: 'name', + label: 'Class Name', + render: (cls: any) => {cls.name}, + }, + { + key: 'description', + label: 'Description', + render: (cls: any) => ( + {cls.description || '-'} + ), + }, + { + key: 'baseFareMinor', + label: 'Base Fare (ETB)', + render: (cls: any) => ( + {formatCurrency(cls.baseFareMinor, 'ETB')} + ), + }, + { + key: 'isActive', + label: 'Status', + render: (cls: any) => ( + + {cls.isActive ? 'Active' : 'Inactive'} + + ), + }, + ]; + + const actions = [ + { + label: 'Edit', + onClick: (cls: any) => { + setEditingClass(cls); + setShowModal(true); + }, + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+
+

Classes

+

Manage class configurations by coach type

+
+ { + setEditingClass(null); + setShowModal(true); + }} + > + Add Class + +
+ +
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, class: null })} + onConfirm={confirmDelete} + title="Delete Class" + message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." + /> + + {/* Add/Edit Modal */} + { + setShowModal(false); + setEditingClass(null); + }} + title={`${editingClass ? 'Edit' : 'Add'} Class`} + size="lg" + > +
+
+
+ + +
+ +
+ + +
+ +
+ +