diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts new file mode 100644 index 000000000..b7846bac9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1719667261000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 17ed1c101..513aaf98e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a06f9eb87..45c2658db 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; @@ -36,7 +36,7 @@ export class FirstMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} /** @@ -251,16 +251,19 @@ export class FirstMileService { const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.firstMilePickupAddress, - destinationYard: booking?.originYard?.label, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 3bfe2cd19..6c8c9d1ca 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -34,6 +34,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 2e8fb2463..77a8a2fea 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; @@ -32,12 +32,11 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( - private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -185,16 +184,19 @@ export class LastMileService { }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.destinationYard?.label, - destinationYard: booking?.lastMileDeliveryAddress, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index bcfa643b6..4c2ebe971 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -20,6 +20,7 @@ export class PaymentClientService { private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" + // "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index dbd635d86..a2f0aad2f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -163,6 +163,10 @@ export class PaymentService { failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); + await this.datasource.getRepository(Booking).update( + { id: dto.bookingId }, + { paymentStatus: "PAID", status: "PAID" }, + ); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { @@ -289,20 +293,19 @@ export class PaymentService { providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; - - const paidAt = input.paidAt ?? new Date(); + // const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + // if (!intent) throw new NotFoundException("PaymentIntent not found"); + // if (intent.status === "success") return { alreadyFinalized: true }; // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); + // await mg.update( + // PaymentEntity, + // // { id: intent.id }, + // {id:input.intentId}, + // { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, + // ); await mg.update( Booking, { id: input.bookingId }, @@ -398,34 +401,46 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId:event.intentId, bookingId: event.referenceId, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }); + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; - } + // console.log(`Received payment event: ${JSON.stringify(event)}`); + // if (event.eventType === "payment.succeeded") { + // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`); + // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + // if (!intent) { + // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + // } + // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`); + // const { alreadyFinalized } = await this.finalizePaymentSuccess({ + // intentId: intent.id, + // bookingId: event.referenceId, + // providerTxnId: event.providerTxnId, + // paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + // }); + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // return { processed: true, alreadyFinalized }; + // } - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } + // if (event.eventType === "payment.failed") { + // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + // if (!intent) { + // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + // } + // await this.markPaymentFailed({ + // intentId: intent.id, + // failureCode: event.failureCode, + // failureMessage: event.failureMessage, + // }); + // return { processed: true }; + // } - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; + // return { processed: false, reason: `Unknown event type: ${event.eventType}` }; } private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9514d8c5c..e17ea46c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -314,6 +314,7 @@ const FirstMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -529,6 +530,7 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -566,7 +568,7 @@ const FirstMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -887,6 +889,17 @@ const FirstMilePage = () => { ); })} + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 894a5a091..9798a90bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -298,6 +298,7 @@ const LastMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -508,6 +509,7 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -545,7 +547,7 @@ const LastMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -866,6 +868,17 @@ const LastMilePage = () => { ); })} + diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 2b0ee8041..1b5c8e9d3 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -23,10 +23,10 @@ RUN pnpm turbo build --filter="@edr/passenger-api..." FROM base AS deployer COPY --from=builder /app/ . RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy -RUN if [ -d node_modules/.prisma ]; then \ - mkdir -p /deploy/node_modules && \ - cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ - fi +# Copy Prisma schema and generated client to deployment directory +RUN mkdir -p /deploy/node_modules/.prisma /deploy/node_modules/@prisma && \ + cp -r node_modules/.prisma/client /deploy/node_modules/.prisma/ 2>/dev/null || true && \ + cp -r node_modules/@prisma/client /deploy/node_modules/@prisma/ 2>/dev/null || true # --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` # against the real DB, as its own gated step *before* the app image is built/deployed. @@ -37,7 +37,10 @@ WORKDIR /deploy RUN corepack enable && corepack prepare pnpm@11.1.1 --activate ENV CI=true ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"] +# Copy the resolution script +COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/ +RUN chmod +x /deploy/scripts/resolve-migrations.sh +CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"] FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat diff --git a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql deleted file mode 100644 index a3a9b7445..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey"; - --- DropIndex -DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key"; - --- AlterTable: Station -ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone"; - --- AlterTable: Ticket — add columns with safe defaults -ALTER TABLE "passenger"."Ticket" - ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1, - ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS "scheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT ''; - --- DropTable -DROP TABLE IF EXISTS "passenger"."TicketSeat"; - --- Remove GateValidationLog rows referencing orphan tickets first -DELETE FROM "passenger"."GateValidationLog" -WHERE "ticketId" IN ( - SELECT "id" FROM "passenger"."Ticket" - WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat") -); - --- Remove orphan ticket rows -DELETE FROM "passenger"."Ticket" -WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId"); - --- AddForeignKey -ALTER TABLE "passenger"."Ticket" - ADD CONSTRAINT "Ticket_seatId_fkey" - FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") - ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql deleted file mode 100644 index 0e9961c0a..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Drop temporary defaults that were only needed for the backfill -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT; diff --git a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql deleted file mode 100644 index 028b08500..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove timezone column if it still exists -ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone"; \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql b/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql deleted file mode 100644 index e9ba7761b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql deleted file mode 100644 index aa6cd855a..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ /dev/null @@ -1,117 +0,0 @@ --- Migration: Add Configurable Fare Management System - --- Main fare configuration table -CREATE TABLE "fare_configurations" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "effective_date" TIMESTAMP(3) NOT NULL, - "expiry_date" TIMESTAMP(3), - "is_active" BOOLEAN NOT NULL DEFAULT false, - "is_default" BOOLEAN NOT NULL DEFAULT false, - "created_by" TEXT, - "approved_by" TEXT, - "approved_at" TIMESTAMP(3), - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id") -); - --- Rate structure by nationality and coach/position -CREATE TABLE "fare_rate_rules" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' - "coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED' - "bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats - "rate_per_km_minor" INTEGER NOT NULL, - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id") -); - --- Configurable fare components (insurance, premiums, service charges, taxes) -CREATE TABLE "fare_components" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' - "component_name" TEXT NOT NULL, - "calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT' - "value_minor" INTEGER, -- For fixed amounts - "percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%) - "applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL' - "apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id") -); - --- Age-based pricing rules -CREATE TABLE "age_pricing_rules" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "rule_name" TEXT NOT NULL, - "min_age" INTEGER NOT NULL, - "max_age" INTEGER, - "pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED' - "discount_percentage" DECIMAL(5,4), -- For discounted fares - "max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child) - "applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id") -); - --- Audit trail for configuration changes -CREATE TABLE "fare_configuration_audit" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' - "changed_by" TEXT, - "changes" JSONB, -- Store the actual changes made - "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id") -); - --- Foreign key constraints -ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- Indexes for performance -CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); -CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); -CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; - -CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); -CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); -CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); - --- Add legacy mode flag to existing fare tables for gradual migration -ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT; -ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT; - --- Add feature flag support -CREATE TABLE "system_features" ( - "id" TEXT NOT NULL, - "feature_name" TEXT NOT NULL UNIQUE, - "is_enabled" BOOLEAN NOT NULL DEFAULT false, - "config" JSONB, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") -); - --- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}'); \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql deleted file mode 100644 index 9ccd3d52e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced) -ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT; - --- Unique constraint: one IAM user maps to exactly one Passenger -ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); - --- Index for fast lookup by iamUserId on every protected request -CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId"); - --- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users) -ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT; - --- Index for Fayda callback to resolve IAM user -CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId"); 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 deleted file mode 100644 index 525b6572e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 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/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index f2250e52b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ --- DropForeignKey -ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; - --- AlterTable -ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL; - --- CreateTable -CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" ( - "id" TEXT NOT NULL, - "ticketId" TEXT NOT NULL, - "seatId" TEXT NOT NULL, - "seatIndex" INTEGER NOT NULL DEFAULT 0, - - CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId"); - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey' - AND conrelid = 'passenger."Passenger"'::regclass - ) THEN - ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey" - FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey' - AND conrelid = 'passenger."TicketSeat"'::regclass - ) THEN - ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" - FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey' - AND conrelid = 'passenger."TicketSeat"'::regclass - ) THEN - ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" - FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; -END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql deleted file mode 100644 index ec1cfd078..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema) -ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; -ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; -ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; - --- Rename columns (preserves all existing data) -ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; -ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; -ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; - --- Rename indexes on FraudAlert to match new column name -DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; -CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql deleted file mode 100644 index 52914b220..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- AuditLog: drop FK, rename column, update index -ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; -ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; -DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; -CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); - --- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data) -ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey"; -ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId"; -DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql deleted file mode 100644 index 125074c12..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3); - --- RenameIndex -ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql deleted file mode 100644 index 9b9228768..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql deleted file mode 100644 index 577312395..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ /dev/null @@ -1,275 +0,0 @@ --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; - --- DropForeignKey -ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; - --- DropForeignKey -ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; - --- DropForeignKey -ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; - --- DropForeignKey -ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; - --- DropForeignKey -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; - --- AlterTable -ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT, -ADD COLUMN "returnHoldId" TEXT, -ADD COLUMN "returnOriginStationId" TEXT, -ADD COLUMN "returnScheduleId" TEXT, -ADD COLUMN "returnSeatClassId" TEXT; - --- AlterTable -ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; - --- AlterTable -ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - --- gender column already TEXT from init migration - --- CreateIndex -CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql deleted file mode 100644 index 7e7d9bd58..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Empty placeholder migration -SELECT 1; diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql deleted file mode 100644 index 1673a795b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence"); - -CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence"); - --- Ensure all indexes exist -CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); -CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); -CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId"); -CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql deleted file mode 100644 index 9f70a96b1..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql +++ /dev/null @@ -1,164 +0,0 @@ --- Add CASCADE delete to all foreign key constraints that are missing it - --- TrainSchedule relations -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE; - --- Coach relation -ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE; - --- CoachAssignment relations -ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - -ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE; - --- Booking relations -ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - -ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- BookingSeat relations -ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - -ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- PaymentIntent -ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- PaymentRefund -ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE; - --- Ticket -ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- TicketSeat -ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- WalletLedgerEntry -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE; - --- Notification -ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - --- MenuItem -ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - -ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE; - --- FoodOrder -ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- FoodOrderItem -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE; - --- FaqArticle -ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE; - --- SupportMessage -ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE; - --- TripStopTime -ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- TripLiveStatus -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- JourneySegment -ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE; - -ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- AgentBooking -ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - -ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- AgentShift -ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - --- AgentCommission -ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - --- BookingModification -ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- BookingCancellation -ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- GateValidationLog -ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE; - --- BaggageBooking -ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- RouteFareRule -ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; - --- SegmentFareRule -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; - --- StationCrowdSignal -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE; - --- SeatBlock -ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- SavedRoute -ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - --- LoyaltyLedgerEntry -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; - --- LoyaltyReward -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; - --- FareRule -ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql deleted file mode 100644 index 582db9567..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql +++ /dev/null @@ -1,82 +0,0 @@ --- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.* --- but ran when tables were still in public schema (before 20260626 moved them). --- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run. - --- ──────────────────────────────────────────────────────────── --- 1. Passenger.iamUserId --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Passenger_iamUserId_key' - AND conrelid = 'passenger."Passenger"'::regclass - ) THEN - ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 2. FaydaVerificationSession.iamUserId --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; -CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 3. UserPreferences: rename userId → iamUserId (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; - ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 4. Device: rename userId → iamUserId (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; - ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; - ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; - DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; - CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; - ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; - DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; - CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); - END IF; -END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index 33f793aa3..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat). --- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL. - -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; -ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql deleted file mode 100644 index fb2e47592..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ──────────────────────────────────────────────────────────── --- 1. Add iamUserId to Agent --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Agent_iamUserId_key' - AND conrelid = 'passenger."Agent"'::regclass - ) THEN - ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 2. Populate iamUserId for existing agent records --- Match via User.email → iam.users.email (skip if iam schema absent) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = 'iam' AND table_name = 'users' - ) THEN - UPDATE passenger."Agent" a - SET "iamUserId" = iu.id - FROM passenger."User" u - JOIN iam.users iu ON iu.email = u.email - WHERE a."userId" = u.id - AND a."iamUserId" IS NULL; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; -DROP INDEX IF EXISTS passenger."Agent_userId_key"; -ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; - --- ──────────────────────────────────────────────────────────── --- 4. Drop Passenger.userId FK (column stays as plain nullable string) --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql deleted file mode 100644 index a20893705..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql +++ /dev/null @@ -1,290 +0,0 @@ --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; - --- DropForeignKey -ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; - --- DropForeignKey -ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; - --- DropForeignKey -ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; - --- DropForeignKey -ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; - --- DropForeignKey -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; - --- DropIndex -DROP INDEX IF EXISTS "Journey_bookingId_idx"; - --- AlterTable -ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY'; - --- CreateTable -CREATE TABLE "SystemConfig" ( - "id" TEXT NOT NULL, - "key" TEXT NOT NULL, - "value" TEXT NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId' - ) THEN - ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql deleted file mode 100644 index bc6e6c2a2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateEnum -CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); - --- AlterTable: add return leg tracking columns to Booking -ALTER TABLE "Booking" - ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', - ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN "returnBoardedAt" TIMESTAMP(3); - --- Set NEITHER_USED for existing confirmed round-trip bookings -UPDATE "Booking" -SET "returnLegStatus" = 'NEITHER_USED' -WHERE "bookingType" = 'ROUND_TRIP' - AND "status" IN ('CONFIRMED', 'BOARDED'); - --- AlterTable: add leg column to GateValidationLog -ALTER TABLE "GateValidationLog" - ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql deleted file mode 100644 index b0a5bc0b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Create passenger schema if it doesn't exist -CREATE SCHEMA IF NOT EXISTS passenger; - --- Move enums from public to passenger schema (only if they exist in public) -DO $$ -DECLARE - e text; -BEGIN - FOR e IN - SELECT typname FROM pg_type - JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace - WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e' - LOOP - EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); - END LOOP; -EXCEPTION WHEN others THEN NULL; -END $$; - --- Move tables from public to passenger schema (only if they exist in public) -DO $$ -DECLARE - t text; -BEGIN - FOR t IN - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations') - LOOP - EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); - END LOOP; -EXCEPTION WHEN others THEN NULL; -END $$; - --- Add missing columns to Booking -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT, - ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); - --- Add ReturnLegStatus enum and column -DO $$ BEGIN - CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ( - 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE'; - --- Add missing columns to other tables -ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT; -ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1; -ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT; -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); - -ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql deleted file mode 100644 index 12f4a0eb7..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Add bookingId to Journey for per-booking segment release -ALTER TABLE "passenger"."Journey" - ADD COLUMN IF NOT EXISTS "bookingId" TEXT; - -CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId"); -CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId"); - --- AddForeignKey (column created above; FK was misplaced in 20260623073543_config) -ALTER TABLE "passenger"."Journey" - DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey"; -ALTER TABLE "passenger"."Journey" - ADD CONSTRAINT "Journey_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- Ensure JourneySegment cascades on Journey delete -ALTER TABLE "passenger"."JourneySegment" - DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; - -ALTER TABLE "passenger"."JourneySegment" - ADD CONSTRAINT "JourneySegment_journeyId_fkey" - FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql deleted file mode 100644 index 435d95829..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration already applied directly to the database. --- This file exists only to satisfy Prisma's migration directory check (P3015). diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql deleted file mode 100644 index 2dff947d2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql +++ /dev/null @@ -1,153 +0,0 @@ --- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema) -ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Agent_iamUserId_key' - AND conrelid = 'passenger."Agent"'::regclass - ) THEN - ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); - --- Drop old Agent.userId FK and column if they still exist -ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; -DROP INDEX IF EXISTS passenger."Agent_userId_key"; -ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; - --- Drop old Passenger.userId FK (column stays as plain nullable string) -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; - --- TravelPackage -CREATE TABLE IF NOT EXISTS passenger."TravelPackage" ( - "id" TEXT NOT NULL, - "code" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "status" TEXT NOT NULL DEFAULT 'DRAFT', - "outboundScheduleId" TEXT NOT NULL, - "returnScheduleId" TEXT NOT NULL, - "originStationId" TEXT NOT NULL, - "destinationStationId" TEXT NOT NULL, - "boardingTime" TIMESTAMP(3) NOT NULL, - "departureTime" TIMESTAMP(3) NOT NULL, - "arrivalTime" TIMESTAMP(3) NOT NULL, - "totalCapacity" INTEGER NOT NULL, - "bookedCount" INTEGER NOT NULL DEFAULT 0, - "includedServices" JSONB NOT NULL, - "coachConfiguration" TEXT, - "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, - "busTransferRoute" TEXT, - "validFrom" TIMESTAMP(3) NOT NULL, - "validUntil" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code"); -CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom"); - --- PackagePriceTier -CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" ( - "id" TEXT NOT NULL, - "packageId" TEXT NOT NULL, - "seatType" TEXT NOT NULL, - "label" TEXT NOT NULL, - "priceMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "availableSeats" INTEGER NOT NULL DEFAULT 0, - "bookedSeats" INTEGER NOT NULL DEFAULT 0, - CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType"); - --- PackageBooking -CREATE TABLE IF NOT EXISTS passenger."PackageBooking" ( - "id" TEXT NOT NULL, - "bookingRef" TEXT NOT NULL, - "packageId" TEXT NOT NULL, - "priceTierId" TEXT NOT NULL, - "passengerId" TEXT, - "contactEmail" TEXT, - "contactPhone" TEXT, - "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', - "passengerCount" INTEGER NOT NULL DEFAULT 1, - "totalMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "displayCurrency" TEXT, - "displayTotalMinor" INTEGER, - "promoCode" TEXT, - "source" TEXT NOT NULL DEFAULT 'WEB', - "paidAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef"); -CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status"); - --- PackageBookingPassenger -CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" ( - "id" TEXT NOT NULL, - "bookingId" TEXT NOT NULL, - "passengerName" TEXT NOT NULL, - "dateOfBirth" TIMESTAMP(3), - "idDocumentType" TEXT, - "idDocumentNumber" TEXT, - "passportNumber" TEXT, - "passportCountry" TEXT, - "seatLabel" TEXT, - CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") -); - --- PackagePaymentIntent -CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" ( - "id" TEXT NOT NULL, - "packageBookingId" TEXT NOT NULL, - "amountMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "method" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION', - "providerRef" TEXT, - "paidAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId"); - --- Foreign keys -ALTER TABLE passenger."TravelPackage" - ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" - FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."TravelPackage" - ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" - FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackagePriceTier" - ADD CONSTRAINT "PackagePriceTier_packageId_fkey" - FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_packageId_fkey" - FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_priceTierId_fkey" - FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_passengerId_fkey" - FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBookingPassenger" - ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackagePaymentIntent" - ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" - FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql deleted file mode 100644 index 545b6a5b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Create PackageStatus enum -DO $$ BEGIN - CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$; - --- Drop default, cast column to enum, restore default -ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT; -ALTER TABLE passenger."TravelPackage" - ALTER COLUMN "status" TYPE passenger."PackageStatus" - USING "status"::passenger."PackageStatus"; -ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql deleted file mode 100644 index 04e2a7610..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql +++ /dev/null @@ -1,42 +0,0 @@ --- CreateTable -CREATE TABLE "passenger"."ExcessBaggageCharge" ( - "id" TEXT NOT NULL, - "bookingId" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "excessWeightKg" INTEGER NOT NULL, - "feePerKgMinor" INTEGER NOT NULL, - "totalMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "status" TEXT NOT NULL DEFAULT 'PENDING', - "paymentToken" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "paidAt" TIMESTAMP(3), - "waivedBy" TEXT, - "waivedReason" TEXT, - "contactPhone" TEXT, - "contactEmail" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status"); - --- AddForeignKey -ALTER TABLE "passenger"."ExcessBaggageCharge" - ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") - ON DELETE RESTRICT ON UPDATE CASCADE; - --- Seed default paymentToken using gen_random_uuid() for any rows that may exist -UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = ''; diff --git a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql deleted file mode 100644 index 34a2e2caa..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql similarity index 79% rename from apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql index 4ae48ea16..d4d7cbe42 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql @@ -22,11 +22,14 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); -- CreateEnum CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED'); +-- CreateEnum +CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); + -- CreateEnum CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); -- CreateEnum -CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI'); +CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI', 'DMONEY'); -- CreateEnum CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED'); @@ -58,6 +61,9 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER -- CreateEnum CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB'); +-- CreateEnum +CREATE TYPE "PackageStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD_OUT', 'EXPIRED', 'CANCELLED'); + -- CreateTable CREATE TABLE "CoachType" ( "id" TEXT NOT NULL, @@ -130,9 +136,11 @@ CREATE TABLE "Session" ( -- CreateTable CREATE TABLE "Passenger" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "userId" TEXT, + "iamUserId" TEXT, "defaultTravelerProfileId" TEXT, "preferredLanguage" TEXT, + "blockedUntil" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id") @@ -143,6 +151,7 @@ CREATE TABLE "TravelerProfile" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "fullName" TEXT NOT NULL, + "gender" TEXT, "relationship" TEXT NOT NULL, "dateOfBirth" TIMESTAMP(3), "nationalId" TEXT, @@ -161,7 +170,6 @@ CREATE TABLE "Station" ( "countryCode" TEXT, "sequence" INTEGER NOT NULL DEFAULT 0, "isOperational" BOOLEAN NOT NULL DEFAULT true, - "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', "lat" DECIMAL(9,6), "lng" DECIMAL(9,6), @@ -314,6 +322,7 @@ CREATE TABLE "Booking" ( "bookingRef" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "scheduleId" TEXT NOT NULL, + "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', "status" "BookingStatus" NOT NULL DEFAULT 'DRAFT', "currency" TEXT NOT NULL DEFAULT 'ETB', "totalMinor" INTEGER NOT NULL, @@ -321,13 +330,29 @@ CREATE TABLE "Booking" ( "childCount" INTEGER NOT NULL DEFAULT 0, "displayCurrency" "Currency", "displayTotalMinor" INTEGER, - "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', + "returnScheduleId" TEXT, + "returnOriginStationId" TEXT, + "returnDestinationStationId" TEXT, + "returnHoldId" TEXT, + "returnSeatClassId" TEXT, + "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', + "leg2ScheduleId" TEXT, + "leg2OriginStationId" TEXT, + "leg2DestinationStationId" TEXT, + "leg2SeatClassId" TEXT, + "returnLeg2ScheduleId" TEXT, + "returnLeg2OriginStationId" TEXT, + "returnLeg2DestStationId" TEXT, + "returnLeg2SeatClassId" TEXT, + "outboundBoardedAt" TIMESTAMP(3), + "returnBoardedAt" TIMESTAMP(3), "contactEmail" TEXT, "contactPhone" TEXT, "userAgent" TEXT, "source" TEXT NOT NULL DEFAULT 'WEB', "promoCode" TEXT, "paidAt" TIMESTAMP(3), + "paymentReminderSentAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -339,6 +364,8 @@ CREATE TABLE "BookingSeat" ( "id" TEXT NOT NULL, "bookingId" TEXT NOT NULL, "seatId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "scheduleId" TEXT, "passengerName" TEXT NOT NULL, "dateOfBirth" TIMESTAMP(3), "passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT', @@ -438,7 +465,11 @@ CREATE TABLE "Ticket" ( "id" TEXT NOT NULL, "bookingId" TEXT NOT NULL, "bookingRef" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'CONFIRMED', + "passengerName" TEXT NOT NULL, + "seatId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "scheduleId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', "qrPayload" TEXT NOT NULL, "barcodePayload" TEXT, "pdfUrl" TEXT, @@ -446,20 +477,11 @@ CREATE TABLE "Ticket" ( "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "validatedAt" TIMESTAMP(3), "validatorId" TEXT, + "boardedAt" TIMESTAMP(3), 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, @@ -679,7 +701,7 @@ CREATE TABLE "SupportMessage" ( -- CreateTable CREATE TABLE "UserPreferences" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "pushEnabled" BOOLEAN NOT NULL DEFAULT true, "emailEnabled" BOOLEAN NOT NULL DEFAULT true, "smsEnabled" BOOLEAN NOT NULL DEFAULT false, @@ -699,7 +721,7 @@ CREATE TABLE "UserPreferences" ( -- CreateTable CREATE TABLE "Device" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "platform" "DevicePlatform" NOT NULL, "name" TEXT NOT NULL, "pushToken" TEXT, @@ -727,6 +749,7 @@ CREATE TABLE "SavedRoute" ( CREATE TABLE "Journey" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, + "bookingId" TEXT, "status" TEXT NOT NULL, "totalMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', @@ -796,7 +819,7 @@ CREATE TABLE "RouteStop" ( "routeId" TEXT NOT NULL, "stationId" TEXT NOT NULL, "sequence" INTEGER NOT NULL, - "distanceKm" INTEGER, + "distanceKm" DOUBLE PRECISION, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id") @@ -820,10 +843,27 @@ CREATE TABLE "RouteFareRule" ( CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id") ); +-- 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") +); + -- CreateTable CREATE TABLE "Agent" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT, "agentCode" TEXT NOT NULL, "stationId" TEXT, "commissionRate" INTEGER NOT NULL DEFAULT 5, @@ -910,6 +950,7 @@ CREATE TABLE "GateValidationLog" ( "ticketId" TEXT NOT NULL, "validatorId" TEXT NOT NULL, "gateId" TEXT, + "leg" TEXT, "status" TEXT NOT NULL, "reason" TEXT, "validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -943,10 +984,32 @@ CREATE TABLE "BaggageBooking" ( CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "ExcessBaggageCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "excessWeightKg" INTEGER NOT NULL, + "feePerKgMinor" INTEGER NOT NULL, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + "waivedBy" TEXT, + "waivedReason" TEXT, + "contactPhone" TEXT, + "contactEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "AuditLog" ( "id" TEXT NOT NULL, - "userId" TEXT, + "iamUserId" TEXT, "action" TEXT NOT NULL, "entityType" TEXT NOT NULL, "entityId" TEXT, @@ -1014,7 +1077,7 @@ CREATE TABLE "FraudRule" ( -- CreateTable CREATE TABLE "FraudAlert" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "eventType" TEXT NOT NULL, "triggeredRules" TEXT[], "context" JSONB NOT NULL, @@ -1077,7 +1140,7 @@ CREATE TABLE "FaydaVerificationSession" ( "id" TEXT NOT NULL, "state" TEXT NOT NULL, "codeVerifier" TEXT NOT NULL, - "purpose" TEXT NOT NULL DEFAULT 'PURCHASE', + "purpose" TEXT NOT NULL DEFAULT 'VERIFY', "platform" TEXT NOT NULL DEFAULT 'WEB', "saveToAccount" BOOLEAN NOT NULL DEFAULT false, "status" TEXT NOT NULL DEFAULT 'PENDING', @@ -1087,12 +1150,137 @@ CREATE TABLE "FaydaVerificationSession" ( "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" TIMESTAMP(3) NOT NULL, "completedAt" TIMESTAMP(3), - "userId" TEXT, + "iamUserId" TEXT, "bookingId" TEXT, CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TravelPackage" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "status" "PackageStatus" NOT NULL DEFAULT 'DRAFT', + "outboundScheduleId" TEXT NOT NULL, + "returnScheduleId" TEXT NOT NULL, + "originStationId" TEXT NOT NULL, + "destinationStationId" TEXT NOT NULL, + "boardingTime" TIMESTAMP(3) NOT NULL, + "departureTime" TIMESTAMP(3) NOT NULL, + "arrivalTime" TIMESTAMP(3) NOT NULL, + "totalCapacity" INTEGER NOT NULL, + "bookedCount" INTEGER NOT NULL DEFAULT 0, + "includedServices" JSONB NOT NULL, + "coachConfiguration" TEXT, + "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, + "busTransferRoute" TEXT, + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackagePriceTier" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "seatType" TEXT NOT NULL, + "label" TEXT NOT NULL, + "priceMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "availableSeats" INTEGER NOT NULL DEFAULT 0, + "bookedSeats" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageBooking" ( + "id" TEXT NOT NULL, + "bookingRef" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT NOT NULL, + "passengerId" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "passengerCount" INTEGER NOT NULL DEFAULT 1, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "displayCurrency" "Currency", + "displayTotalMinor" INTEGER, + "promoCode" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageBookingPassenger" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3), + "idDocumentType" "IdDocumentType", + "idDocumentNumber" TEXT, + "passportNumber" TEXT, + "passportCountry" TEXT, + "seatLabel" TEXT, + + CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackagePaymentIntent" ( + "id" TEXT NOT NULL, + "packageBookingId" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "method" "PaymentMethodType" NOT NULL, + "status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION', + "providerRef" TEXT, + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageInquiry" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT, + "travelerCount" INTEGER NOT NULL, + "contactName" TEXT NOT NULL, + "contactEmail" TEXT, + "contactPhone" TEXT, + "notes" TEXT, + "status" TEXT NOT NULL DEFAULT 'NEW', + "enquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackageInquiry_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId"); @@ -1114,15 +1302,24 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); -- CreateIndex CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId"); +-- CreateIndex +CREATE UNIQUE INDEX "Passenger_iamUserId_key" ON "Passenger"("iamUserId"); + -- CreateIndex CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId"); +-- CreateIndex +CREATE INDEX "Passenger_iamUserId_idx" ON "Passenger"("iamUserId"); + -- CreateIndex CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code"); -- CreateIndex CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); +-- CreateIndex +CREATE INDEX "Station_sequence_idx" ON "Station"("sequence"); + -- CreateIndex CREATE UNIQUE INDEX "Train_number_key" ON "Train"("number"); @@ -1141,6 +1338,9 @@ CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number"); -- CreateIndex CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); +-- CreateIndex +CREATE INDEX "Coach_sequence_idx" ON "Coach"("sequence"); + -- CreateIndex CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId"); @@ -1165,6 +1365,9 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef"); -- CreateIndex CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); +-- CreateIndex +CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); + -- CreateIndex CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type"); @@ -1187,13 +1390,10 @@ CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"( CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId"); -- CreateIndex -CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId"); +CREATE INDEX "Ticket_bookingId_idx" ON "Ticket"("bookingId"); -- CreateIndex -CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId"); - --- CreateIndex -CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId"); +CREATE INDEX "Ticket_seatId_idx" ON "Ticket"("seatId"); -- CreateIndex CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId"); @@ -1208,7 +1408,10 @@ CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId"); CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code"); -- CreateIndex -CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId"); +CREATE UNIQUE INDEX "UserPreferences_iamUserId_key" ON "UserPreferences"("iamUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Journey_bookingId_key" ON "Journey"("bookingId"); -- CreateIndex CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone"); @@ -1232,11 +1435,20 @@ CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", " CREATE INDEX "RouteFareRule_routeId_seatClassId_idx" ON "RouteFareRule"("routeId", "seatClassId"); -- CreateIndex -CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId"); +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"); + +-- CreateIndex +CREATE UNIQUE INDEX "Agent_iamUserId_key" ON "Agent"("iamUserId"); -- CreateIndex CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode"); +-- CreateIndex +CREATE INDEX "Agent_iamUserId_idx" ON "Agent"("iamUserId"); + -- CreateIndex CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId"); @@ -1262,7 +1474,19 @@ CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validat CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId"); -- CreateIndex -CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt"); +CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "ExcessBaggageCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_status_idx" ON "ExcessBaggageCharge"("status"); + +-- CreateIndex +CREATE INDEX "AuditLog_iamUserId_createdAt_idx" ON "AuditLog"("iamUserId", "createdAt"); -- CreateIndex CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId"); @@ -1280,7 +1504,7 @@ CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"( CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type"); -- CreateIndex -CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt"); +CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON "FraudAlert"("iamUserId", "createdAt"); -- CreateIndex CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged"); @@ -1307,7 +1531,7 @@ CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("de CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state"); -- CreateIndex -CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId"); +CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "FaydaVerificationSession"("iamUserId"); -- CreateIndex CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId"); @@ -1318,6 +1542,30 @@ CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"( -- CreateIndex CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt"); +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "TravelPackage_code_key" ON "TravelPackage"("code"); + +-- CreateIndex +CREATE INDEX "TravelPackage_status_validFrom_idx" ON "TravelPackage"("status", "validFrom"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackagePriceTier_packageId_seatType_key" ON "PackagePriceTier"("packageId", "seatType"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackageBooking_bookingRef_key" ON "PackageBooking"("bookingRef"); + +-- CreateIndex +CREATE INDEX "PackageBooking_packageId_status_idx" ON "PackageBooking"("packageId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackagePaymentIntent_packageBookingId_key" ON "PackagePaymentIntent"("packageBookingId"); + +-- CreateIndex +CREATE INDEX "PackageInquiry_packageId_idx" ON "PackageInquiry"("packageId"); + -- AddForeignKey ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1325,7 +1573,7 @@ ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1372,6 +1620,9 @@ ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("pa -- AddForeignKey ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1388,10 +1639,7 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" 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; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_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; @@ -1432,15 +1680,12 @@ ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY -- AddForeignKey ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1457,7 +1702,10 @@ ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +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; -- AddForeignKey ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1484,13 +1732,37 @@ ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ExcessBaggageCharge" ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" FOREIGN KEY ("outboundScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBookingPassenger" ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackagePaymentIntent" ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" FOREIGN KEY ("packageBookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh new file mode 100644 index 000000000..cc34c747e --- /dev/null +++ b/apps/edr-passenger-api/scripts/resolve-migrations.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +echo "🔍 Checking for failed migrations..." + +# Mark legacy migrations as applied +npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true +npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true +npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true +npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true +npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true + +echo "✅ Migration resolution complete" \ No newline at end of file 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 7e8947034..2ca7d5ac4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -483,8 +483,8 @@ export class BookingsService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -660,8 +660,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -854,8 +854,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -1060,7 +1060,7 @@ export class BookingsService { loyaltyRedemptionPoints?: number ) { const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1076,8 +1076,8 @@ export class BookingsService { } const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); return { baseFareMinor, @@ -1103,6 +1103,8 @@ export class BookingsService { nationality?: string, originStopSeq?: number, destStopSeq?: number, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); @@ -1149,13 +1151,13 @@ export class BookingsService { const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); if (bestMatch) return bestMatch.baseFareMinor; - // 3. FareEngine — distance × rate-per-km from the schedule's route + // 3. FareEngine — distance × rate-per-km from the booking's actual segment stations if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index cfddd77f8..6907d14a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +/** Booking cutoff: reject new bookings within this many ms of departure. */ +const BOOKING_CUTOFF_MS = 30 * 60 * 1000; + function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -74,6 +77,10 @@ export class GuestBookingService { }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); @@ -142,7 +149,9 @@ export class GuestBookingService { dto.seatClassId, segmentRoute, fullRoute, - primaryNationality + primaryNationality, + dto.originStationId, + dto.destinationStationId, ); const adultFareMinor = baseFareMinor * adultCount; @@ -160,8 +169,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -293,6 +302,10 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); @@ -350,8 +363,8 @@ export class GuestBookingService { const primaryNationality = passengersData[0]?.nationality; const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), ]); const paidChildrenCount = Math.max(0, childCount - 1); @@ -369,8 +382,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -505,6 +518,10 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -551,11 +568,11 @@ export class GuestBookingService { this.getBaseFare(dto.scheduleId, dto.seatClassId, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; @@ -569,8 +586,8 @@ export class GuestBookingService { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -702,6 +719,10 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -750,10 +771,10 @@ export class GuestBookingService { const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + @@ -951,17 +972,28 @@ export class GuestBookingService { segmentRoute?: string, fullRoute?: string, nationality?: string, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); - // 1. FareRule table — explicit override rules - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId, - validFrom: { lte: now }, - OR: [{ validUntil: null }, { validUntil: { gte: now } }], - }, - }); + // 1. FareRule table — explicit override rules (same priority logic as the fare engine) + const [candidates, seatClass] = await Promise.all([ + this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }), + this.prisma.seatClass.findUnique({ + where: { id: seatClassId }, + select: { premiumMinor: true, insuranceFeeMinor: true }, + }), + ]); + + const premiumMinor = seatClass?.premiumMinor ?? 0; + const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, @@ -982,10 +1014,11 @@ export class GuestBookingService { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); - if (match) return match.baseFareMinor; + // Return base fare + seat-class surcharges so the booking total matches the quoted fare + if (match) return match.baseFareMinor + premiumMinor + insuranceMinor; } - // 2. FareEngine — distance × rate-per-km from the schedule's route + // 2. FareEngine — distance × rate-per-km from the booking's actual segment stations const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, @@ -995,12 +1028,15 @@ export class GuestBookingService { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + // Use the booking's boarding/alighting stations so the distance reflects the + // passenger's actual segment, not the full schedule route. + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); - return fare.baseFarePerPassengerMinor; + // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor + return fare.farePerPassengerMinor; } catch { // FareEngine throws if distanceKm is missing; fall through to error } diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5652ae91d..eb8010cd6 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -4,14 +4,14 @@ import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; // Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) -export const NATIONALITY_CURRENCY_MAP: Record = { - ETHIOPIAN: Currency.ETB, - DJIBOUTIAN: Currency.DJF, +export const NATIONALITY_CURRENCY_MAP: Record = { + ETHIOPIAN: 'ETB', + DJIBOUTIAN: 'DJF', }; export function resolveCurrencyFromNationality(nationality?: string): Currency { - if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; + if (!nationality) return 'ETB' as Currency; + return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency; } export class FareCalculateDto { 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 f2ce49791..847164551 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 @@ -284,13 +284,16 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; + const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); + const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, + taxMinor, + totalMinor, billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + totalInBillingCurrency: Math.round(totalMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 97cf2600e..280ef2752 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -31,6 +31,7 @@ import { SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto, + BookingAmountResponseDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -139,16 +140,33 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", + "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.", }) - @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods( - @Query("currency") currency?: string, @Query("region") region?: PaymentRegionEnum, ) { - return this.service.getSupportedPaymentMethods(region, currency); + return this.service.getSupportedPaymentMethods(region); + } + + @Get("booking-amount") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: "Get booking amount in a specific currency", + description: + "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " + + "If currency is ETB the stored amount is returned as-is (no conversion). " + + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", + }) + @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) + @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" }) + @ApiOkResponse({ type: BookingAmountResponseDto }) + getBookingAmount( + @Query("bookingId") bookingId: string, + @Query("currency") currency: string, + ) { + return this.service.getBookingAmountByCurrency(bookingId, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index c1b168138..410716b85 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -136,3 +136,9 @@ export class IntentStatusDto { @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; } + +export class BookingAmountResponseDto { + @ApiProperty({ example: 'booking-uuid' }) booking_id: string; + @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; + @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 80980cadb..af496a5a2 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -476,7 +476,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { + getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -490,12 +490,41 @@ export class PaymentsService { }, } : {}), - ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } + async getBookingAmountByCurrency( + bookingId: string, + currency: string, + ): Promise<{ booking_id: string; currency: string; amount: number }> { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { id: true, totalMinor: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const requestedCurrency = currency.toUpperCase(); + const amountInETB = booking.totalMinor / 100; + + if (requestedCurrency === 'ETB') { + return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; + } + + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any }, + orderBy: { effectiveDate: 'desc' }, + }); + if (!exchangeRate) { + throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`); + } + + const rate = Number(exchangeRate.rate); + const converted = parseFloat((amountInETB * rate).toFixed(2)); + return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; + } + /** * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index 21768ab8b..8339bf477 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -1,11 +1,11 @@ -import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator'; +import { IsString, IsInt, IsNumber, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number; + @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; } export class CreateRouteDto { @@ -34,7 +34,7 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; } export class UpdateRouteDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index c7998e9cd..d8df838ca 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -34,7 +34,7 @@ export class RoutesService { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, - distanceKm: s.distanceKm, + distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, })), }, }, @@ -135,7 +135,12 @@ export class RoutesService { if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); return this.prisma.routeStop.create({ - data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm }, + data: { + routeId, + stationId: dto.stationId, + sequence: dto.sequence, + distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, + }, }); } 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 ac55bc14b..6d1091719 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,9 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { TripStatus } from '@prisma/client'; @ApiTags('Schedule') @Controller('schedules') 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 4e422f2ad..b6363e085 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,7 +1,27 @@ import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client'; + +export enum TripStatus { + SCHEDULED = 'SCHEDULED', + BOARDING = 'BOARDING', + EN_ROUTE = 'EN_ROUTE', + ARRIVED = 'ARRIVED', + CANCELLED = 'CANCELLED', + DELAYED = 'DELAYED', +} + +export enum StopStatus { + COMPLETED = 'COMPLETED', + APPROACHING = 'APPROACHING', + CURRENT = 'CURRENT', + UPCOMING = 'UPCOMING', +} + +export enum PassengerCategory { + ADULT = 'ADULT', + CHILD = 'CHILD', +} export class PlannedStopTimeDto { @ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number; 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 3a7b02682..e14db6b0a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -474,8 +474,8 @@ export class SearchService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 70009d1d8..bc52cc613 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; -/** Minutes before departure at which each action fires. */ -const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS -const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking +/** Maximum time (hours) a passenger has to pay after booking. */ +const MAX_PAYMENT_HOURS = 2; +/** Minutes before departure: cutoff for new bookings and payment deadline. */ +const CUTOFF_MINUTES = 30; -/** Half-width of the reminder detection window (cron runs every 2 min). */ -const REMINDER_WINDOW_MINUTES = 2; +/** + * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + */ +function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { + const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; +} function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { @@ -28,66 +35,72 @@ export class TasksService { ) {} // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // Every 1 min: advance TrainSchedule statuses. + // + // SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings) + // BOARDING → EN_ROUTE at actual departure + // EN_ROUTE → ARRIVED at arrival time // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async syncScheduleStatuses() { const now = new Date(); + const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); - const [departed, arrived] = await Promise.all([ + const [boarding, departed, arrived] = await Promise.all([ this.prisma.trainSchedule.updateMany({ - where: { status: 'SCHEDULED', departureAt: { lte: now } }, + where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } }, + data: { status: 'BOARDING' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: 'BOARDING', departureAt: { lte: now } }, data: { status: 'EN_ROUTE' }, }), this.prisma.trainSchedule.updateMany({ - where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + where: { status: 'EN_ROUTE', arrivalAt: { lte: now } }, data: { status: 'ARRIVED' }, }), ]); - if (departed.count > 0 || arrived.count > 0) { + if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) { this.logger.log( - `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, ); } } // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: payment deadline enforcement. + // Every 1 min: payment deadline enforcement. // - // • 3 h before departure → send one SMS reminder to complete payment. - // • 2 h before departure → cancel booking if payment is still pending - // and notify the passenger by SMS. + // Reminder — sent once at the midpoint of the booking's payment window: + // reminder_at = booking_time + total_window / 2 // - // Example: train departs 08:00 - // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") - // 06:00 → booking auto-cancelled, cancellation SMS sent + // Cancel — when now ≥ payment_deadline + // payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + // + // Examples (departure 10:00, cutoff 9:30): + // Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45 + // Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15 // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async enforcePaymentDeadlines() { const now = new Date(); - await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), ]); } - // ── 3-hour reminder ─────────────────────────────────────────────────────── + // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { - // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking - // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. - const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; - const reminderMs = REMINDER_MINUTES * 60 * 1000; - - const windowStart = new Date(now.getTime() + reminderMs - windowMs); - const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + // Only look at bookings created within the last 3 h with a future departure. + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); const bookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', paymentReminderSentAt: null, - schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + createdAt: { gte: threeHoursAgo }, + schedule: { departureAt: { gte: now } }, } as any, include: { schedule: { @@ -101,15 +114,28 @@ export class TasksService { for (const booking of bookings) { try { - const dep = booking.schedule.departureAt as Date; - const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); - const origin = booking.schedule.originStation?.name ?? ''; - const dest = booking.schedule.destinationStation?.name ?? ''; + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); + + // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately + if (totalWindowMs < 2 * 60 * 1000) continue; + + // Remind once, at the midpoint of the total payment window + const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2); + if (now < reminderAt) continue; + + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime()); + const remainingMin = Math.round(remainingMs / 60_000); const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` + - `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + `Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` + + `or your booking will be cancelled.`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); @@ -121,7 +147,8 @@ export class TasksService { }); this.logger.log( - `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + `Payment reminder sent: ${booking.bookingRef} ` + + `(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`, ); } catch (err) { this.logger.error( @@ -131,14 +158,22 @@ export class TasksService { } } - // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // Deadline is reached when either branch of the MIN is in the past: + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + 30min → departure within 30 min const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', - schedule: { departureAt: { lte: cutoff } }, + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { schedule: { departureAt: { lte: departureCutoff } } }, + ], }, include: { schedule: { @@ -151,8 +186,16 @@ export class TasksService { }, }); + let cancelledCount = 0; + for (const booking of expiredBookings) { try { + // Re-verify exact deadline to avoid racing with a concurrent payment confirmation + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + if (now < paymentDeadline) continue; + // 1. Release held seats (Journey rows are the occupancy source of truth) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -161,12 +204,12 @@ export class TasksService { data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before departure deadline', + reason: 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', refundStatus: 'NOT_APPLICABLE', }, - }).catch(() => null); // booking may already have a cancellation record + }).catch(() => null); // 3. Mark cancelled await this.prisma.booking.update({ @@ -175,22 +218,20 @@ export class TasksService { }); // 4. Notify passenger - const dep = booking.schedule.departureAt as Date; const origin = booking.schedule.originStation?.name ?? ''; const dest = booking.schedule.destinationStation?.name ?? ''; const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + - `because payment was not completed before the deadline.`; + `because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log( - `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, - ); + this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; } catch (err) { this.logger.error( `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, @@ -198,8 +239,8 @@ export class TasksService { } } - if (expiredBookings.length > 0) { - this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 29af2b29b..8c3479f83 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; -import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; type BookingWithTicket = { @@ -77,54 +76,71 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!_booking || !pnr) { + if (!pnr) { alert('Booking data not available. Please try again.'); return; } + if (!passengers.length) { + alert('No passenger data found.'); + return; + } setIsGeneratingVoucher(true); try { - console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); - - const { generateVoucherPDF } = await import('@/lib/generate-voucher'); - - const voucherData = { - bookingRef: pnr, - status: _booking.status || 'CONFIRMED', - passengers: passengers.map(p => ({ - fullName: p.name, - category: 'ADULT', - seat: p.seatNumber ? { - number: p.seatNumber, - coach: 'N/A', - seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', - } : undefined, - })), - schedule: { - trainNumber: selectedSchedule?.trainNumber || 'N/A', - trainName: 'EDR Express', - origin: { - name: selectedSchedule?.origin || 'Origin', - code: 'ORG', - city: selectedSchedule?.origin || 'Origin', - }, - destination: { - name: selectedSchedule?.destination || 'Destination', - code: 'DST', - city: selectedSchedule?.destination || 'Destination', - }, - departureAt: selectedSchedule?.departureTime || new Date().toISOString(), - arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), - }, - totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), - currency: 'ETB', - bookingType: 'ONE_WAY', - createdAt: new Date().toISOString(), + const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); + + const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + const totalFare = _booking?.totalMinor + || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0); + const farePerPassenger = Math.round(totalFare / passengers.length); + const createdAt = _booking?.createdAt || new Date().toISOString(); + const status = _booking?.status || 'CONFIRMED'; + + const outbound = { + trainNumber: activeSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' }, + destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' }, + departureAt: activeSchedule?.departureTime || new Date().toISOString(), + arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(), + seatClass: activeSchedule?.selectedSeatClassName, }; - console.log('📄 Voucher data prepared:', voucherData); - await generateVoucherPDF(voucherData); - console.log('✅ Voucher generated successfully'); + const inbound = inboundSchedule ? { + trainNumber: inboundSchedule.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin }, + destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination }, + departureAt: inboundSchedule.departureTime || new Date().toISOString(), + arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(), + seatClass: inboundSchedule.selectedSeatClassName, + } : undefined; + + for (let i = 0; i < passengers.length; i++) { + const p = passengers[i]; + const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + + await generatePassengerVoucherPDF({ + bookingRef: pnr, + ticketNumber, + passengerName: p.name || `Passenger ${i + 1}`, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + seatNumber: p.seatNumber, + outboundSeatNumber: (p as any).outboundSeatNumber, + inboundSeatNumber: (p as any).inboundSeatNumber, + status, + outboundSchedule: outbound, + inboundSchedule: inbound, + isRoundTrip, + fareMinor: farePerPassenger, + currency: 'ETB', + createdAt, + }); + + // brief pause between downloads so browsers don't block them + if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } } catch (error) { console.error('❌ Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -191,17 +207,9 @@ export default function ConfirmationPage() { - {/* Trip Summary with QR Code */} + {/* Trip Details */}
-
- {/* QR Code Section */} -
- -

Scan at gate

-
- - {/* Trip Details */} -
+
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
)} -
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 92b82d2cb..b38234732 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -332,12 +332,135 @@ function DobPickerModal({ ); } +// ─── phone validation ───────────────────────────────────────────────────────── + +type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + +const PHONE_PRESETS: Record = { + ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' }, + DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' }, + OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' }, +}; + +function getPhoneNat(nationality: string): PhoneNat { + const n = (nationality || '').toUpperCase(); + if (n === 'ETHIOPIAN') return 'ETHIOPIAN'; + if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN'; + return 'OTHER'; +} + +function validatePhone(phone: string, nationality: string): string | null { + const normalized = (phone || '').replace(/[\s\-().]/g, ''); + if (!normalized) return 'Phone number is required'; + const nat = getPhoneNat(nationality); + if (nat === 'ETHIOPIAN') { + if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; + return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + } + if (nat === 'DJIBOUTIAN') { + if (/^\+253\d{8}$/.test(normalized)) return null; + return 'Invalid Djiboutian phone number (e.g., +25377123456)'; + } + if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null; + return 'Invalid international phone number (e.g., +14155552671)'; +} + +function stripPhonePrefix(stored: string, nat: PhoneNat): string { + const code = PHONE_PRESETS[nat].code; + if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length); + if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1); + return stored; +} + +function buildFullNumber(localInput: string, nat: PhoneNat): string { + const stripped = localInput.replace(/[\s\-().]/g, ''); + if (!stripped) return stripped; + if (nat === 'ETHIOPIAN') { + if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped; + return '+251' + stripped; + } + if (nat === 'DJIBOUTIAN') { + if (stripped.startsWith('+')) return stripped; + return '+253' + stripped; + } + return stripped.startsWith('+') ? stripped : '+' + stripped; +} + +function PhoneInput({ + nationality, + storedValue, + onInterimChange, + onNormalized, + error, +}: { + nationality: string; + storedValue: string; + onInterimChange: (full: string) => void; + onNormalized: (full: string) => void; + error?: string; +}) { + const nat = getPhoneNat(nationality); + const preset = PHONE_PRESETS[nat]; + const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat)); + const prevStoredRef = useRef(storedValue); + + useEffect(() => { + if (storedValue !== prevStoredRef.current) { + prevStoredRef.current = storedValue; + setLocalInput(stripPhonePrefix(storedValue || '', nat)); + } + }, [storedValue, nat]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + setLocalInput(raw); + onInterimChange(buildFullNumber(raw, nat)); + }; + + const handleBlur = () => { + const full = buildFullNumber(localInput, nat); + setLocalInput(stripPhonePrefix(full, nat)); + onNormalized(full); + }; + + return ( +
+
+
+ {preset.flag} + {preset.code} +
+ +
+ {error ? ( +

{error}

+ ) : ( +

Format: {preset.hint}

+ )} +
+ ); +} + +// ─── passenger zod schema ────────────────────────────────────────────────────── + const passengerSchema = z.object({ name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().min(1, 'Phone number is required'), + phone: z.string(), email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), @@ -358,6 +481,10 @@ const passengerSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); } } + const phoneError = validatePhone(data.phone, data.nationality); + if (phoneError) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] }); + } const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; if (isNonEthiopian) { if (!data.passportNumber || data.passportNumber.trim().length === 0) { @@ -769,14 +896,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} @@ -850,14 +976,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6527a88cd..989516839 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; -const NATIONALITY_TO_CURRENCY: Record = { - ETHIOPIAN: 'ETB', - DJIBOUTIAN: 'DJF', -}; export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const displayCurrency: 'ETB' | 'DJF' | 'USD' = - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD'; + const displayCurrency = 'ETB' as const; // Keep payment store in sync so the mutation picks up the right currency. useEffect(() => { @@ -54,7 +50,22 @@ export default function PaymentPage() { }, }); - // Calculate total amount + // Fetch actual booking amount from API when a payment method is selected + const amountCurrency = selectedMethodCurrency || displayCurrency; + + const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], + queryFn: async () => { + const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; + console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod }); + const response: any = await apiClient.get(url); + console.log('[BookingAmount] Response:', response); + return response; + }, + enabled: !!selectedMethod && !!bookingId, + }); + + // Fallback: estimate from local store while API hasn't responded yet const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( (sum) => sum + (outboundSchedule.baseFareAdult || 0), 0, @@ -69,8 +80,12 @@ export default function PaymentPage() { (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); - - const totalAmount = baseFare; + + // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency + const totalAmount = bookingAmountData != null + ? Math.round(bookingAmountData.amount * 100) + : baseFare; + const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -248,7 +263,12 @@ export default function PaymentPage() {
Total - {displayCurrency} {(totalAmount / 100).toFixed(2)} + + {loadingAmount && ( + + )} + {confirmedCurrency} {(totalAmount / 100).toFixed(2)} +
@@ -259,15 +279,19 @@ export default function PaymentPage() { )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 10eab03e0..2f053d98c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -144,8 +144,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -161,10 +161,9 @@ export default function ResultsPage() { const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - const fareCurrency: string = - coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const fareCurrency = 'ETB'; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -186,6 +185,7 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, + seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, }; // For round trip, store outbound and wait for inbound selection @@ -222,17 +222,13 @@ export default function ResultsPage() { // Calculate lowest fare and display currency from coach types / faresByClass. // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; - let displayCurrency = schedule.displayCurrency || 'ETB'; + const displayCurrency = 'ETB'; if (schedule.coachTypes?.length) { const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); - const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); + const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; - const firstWithCurrency = allClasses.find(c => c.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); - const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0)); } else if (schedule.combinedMinFareDisplay) { lowestFare = schedule.combinedMinFareDisplay; } @@ -551,14 +547,14 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; - const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return (
- {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} + {(cls.baseFareMinor / 100).toFixed(2)} - {cls.displayCurrency ?? coachCurrency} + {coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 655f0a7c6..67422ad69 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -62,11 +62,7 @@ export default function ReviewPage() { // Prefer the currency already stored on the selected schedule (set from search results). // Fall back to deriving from nationality so the review page is never left with a stale value. - const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; - const displayCurrency: string = - (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? - 'USD'; + const displayCurrency = 'ETB'; useEffect(() => { if (!seatHold?.expiresAt) return; @@ -201,19 +197,34 @@ export default function ReviewPage() { return; } - // Get seat class ID - let seatClassId = 'default-seat-class-id'; - let returnSeatClassId = 'default-seat-class-id'; + // Get seat class ID by name-matching against the /seat-classes list + let seatClassId = ''; + let returnSeatClassId = ''; try { - const seatClasses: any = await apiClient.get('/seat-classes'); - console.log('Seat classes:', seatClasses); + const seatClasses: any[] = await apiClient.get('/seat-classes'); if (seatClasses && seatClasses.length > 0) { - seatClassId = seatClasses[0].id; - returnSeatClassId = seatClasses[0].id; + const outboundClassName = isRoundTrip + ? (outboundSchedule as any)?.seatClassName + : (selectedSchedule as any)?.seatClassName; + const returnClassName = isRoundTrip + ? (inboundSchedule as any)?.seatClassName + : outboundClassName; + + const findByName = (name: string) => + seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id; + + seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id; + returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id; + console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId }); } } catch (err) { console.error('Failed to fetch seat classes:', err); } + + if (!seatClassId) { + alert('Unable to determine seat class. Please go back and re-select your seats.'); + return; + } let bookingData: any; if (isAuthenticated) { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 4aaef69a1..93262f5b8 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -54,6 +54,10 @@ export interface SelectedSchedule { displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; + seatClassName?: string; + selectedCoachTypeId?: string; + selectedCoachTypeCode?: string; + selectedCoachTypeName?: string; } export interface SeatHold { diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index f05db069b..37dfa5ce9 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,392 +1,301 @@ import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; -interface VoucherData { +interface ScheduleInfo { + trainNumber: string; + trainName?: string; + origin: { name: string; code: string; city: string }; + destination: { name: string; code: string; city: string }; + departureAt: string; + arrivalAt: string; + seatClass?: string; +} + +interface PassengerVoucherData { bookingRef: string; + ticketNumber: string; + passengerName: string; + dateOfBirth?: string; + nationality?: string; + seatNumber?: string; + outboundSeatNumber?: string; + inboundSeatNumber?: string; status: string; - passengers: Array<{ - fullName: string; - category: string; - seat?: { - number: string; - coach: string; - seatClass: string; - }; - }>; - schedule: { - trainNumber: string; - trainName?: string; - origin: { - name: string; - code: string; - city: string; - }; - destination: { - name: string; - code: string; - city: string; - }; - departureAt: string; - arrivalAt: string; - }; - totalMinor: number; + outboundSchedule: ScheduleInfo; + inboundSchedule?: ScheduleInfo; + isRoundTrip: boolean; + fareMinor: number; currency: string; - bookingType: string; createdAt: string; } -export const generateVoucherPDF = async (booking: VoucherData) => { - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); +// ─── shared drawing helpers ─────────────────────────────────────────────────── +const PRIMARY = [20, 113, 76] as const; +const DARK = [51, 51, 51] as const; +const MED = [102, 102, 102] as const; +const LIGHT = [200, 200, 200] as const; + +async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const margin = 15; - let yPos = margin; - // Colors - const primaryColor = [20, 113, 76]; // EDR Green - const darkGray = [51, 51, 51]; - const mediumGray = [102, 102, 102]; - const lightGray = [200, 200, 200]; - - // ============ HEADER ============ - // Company branding strip - doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFillColor(...PRIMARY); doc.rect(0, 0, pageWidth, 30, 'F'); - // Load and add logo try { - const logoImg = await fetch('/edr-logo.png'); + const logoImg = await fetch('/edr-logo.png'); const logoBlob = await logoImg.blob(); const logoDataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(logoBlob); }); - - // Create image to get dimensions const img = new Image(); - await new Promise((resolve) => { - img.onload = resolve; - img.src = logoDataUrl; - }); - - // Calculate aspect ratio and dimensions - const logoHeight = 18; - const logoWidth = (img.width / img.height) * logoHeight; - - // Add logo on left side with proper aspect ratio - doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight); - - // Company name next to logo + await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const logoH = 18; + const logoW = (img.width / img.height) * logoH; + doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH); doc.setTextColor(255, 255, 255); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); - - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', margin + logoWidth + 5, 20); - } catch (error) { - console.error('Failed to load logo:', error); - // Fallback: just show text centered + doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', margin + logoW + 5, 20); + } catch { doc.setTextColor(255, 255, 255); - doc.setFontSize(24); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' }); + doc.setFontSize(22); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' }); } + return 40; +} - yPos = 40; - - // ============ TITLE & STATUS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' }); - - yPos += 10; - - // Status badge (simplified) - const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status; - const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8]; - - doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]); - doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F'); +function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number { + const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status; + const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8]; + doc.setFillColor(color[0], color[1], color[2]); + doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F'); doc.setTextColor(255, 255, 255); - doc.setFontSize(9); - doc.setFont('helvetica', 'bold'); - doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'bold'); + doc.text(label, pageWidth / 2, y + 1, { align: 'center' }); + return y + 12; +} - yPos += 12; - - // ============ QR CODE ============ - // Generate QR code data URL - const canvas = document.createElement('canvas'); - const QRCode = (await import('qrcode')).default; - - const qrSize = 35; // 35mm = 3.5cm - await QRCode.toCanvas(canvas, booking.bookingRef, { - width: 300, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF', - }, - }); - - const qrDataUrl = canvas.toDataURL('image/png'); - - // Place QR code at top-right - const qrX = pageWidth - margin - qrSize; - const qrY = yPos; - - doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); - - // ============ BOOKING REFERENCE ============ +function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number { doc.setFillColor(245, 245, 245); - doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); - - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('BOOKING REFERENCE', margin + 5, yPos + 6); - - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFontSize(18); - doc.setFont('helvetica', 'bold'); - doc.text(booking.bookingRef, margin + 5, yPos + 14); + doc.rect(margin, y, pageWidth - margin * 2, 22, 'F'); - yPos += 25; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('BOOKING REFERENCE', margin + 5, y + 6); + doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(bookingRef, margin + 5, y + 14); - // ============ JOURNEY DETAILS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('JOURNEY DETAILS', margin, yPos); - - yPos += 8; + const rightX = pageWidth - margin - 5; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' }); + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(ticketNumber, rightX, y + 14, { align: 'right' }); - // Route box - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.setLineWidth(0.5); - doc.rect(margin, yPos, pageWidth - margin * 2, 40); + return y + 28; +} + +function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y); + y += 7; + + doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5); + doc.rect(margin, y, pageWidth - margin * 2, 40); // Origin - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('FROM', margin + 5, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.origin.code, margin + 5, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.origin.name, margin + 5, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.origin.city, margin + 5, yPos + 25); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('FROM', margin + 5, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.origin.code, margin + 5, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.origin.name, margin + 5, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.origin.city, margin + 5, y + 25); - // Departure time - const departureDate = new Date(booking.schedule.departureAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38); + const dep = new Date(schedule.departureAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38); // Arrow - doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setLineWidth(1); - const arrowStartX = pageWidth / 2 - 10; - const arrowEndX = pageWidth / 2 + 10; - const arrowY = yPos + 20; - - // Draw arrow line - doc.line(arrowStartX, arrowY, arrowEndX, arrowY); - - // Draw arrow head manually with lines - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2); - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2); + doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8); + const ax = pageWidth / 2, ay = y + 20; + doc.line(ax - 10, ay, ax + 10, ay); + doc.line(ax + 10, ay, ax + 7, ay - 2); + doc.line(ax + 10, ay, ax + 7, ay + 2); // Destination - const destX = pageWidth - margin - 50; - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TO', destX, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.destination.code, destX, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.destination.name, destX, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.destination.city, destX, yPos + 25); + const dx = pageWidth - margin - 50; + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TO', dx, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.destination.code, dx, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.destination.name, dx, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.destination.city, dx, y + 25); - // Arrival time - const arrivalDate = new Date(booking.schedule.arrivalAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38); + const arr = new Date(schedule.arrivalAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38); - yPos += 48; + y += 47; - // Train info - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); - - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TRAIN', margin + 5, yPos + 5); - - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9); - - if (booking.schedule.trainName) { - doc.setFont('helvetica', 'normal'); - doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9); + // Train info bar + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 12, 'F'); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + 5, y + 5); + doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.trainNumber + (schedule.trainName ? ` — ${schedule.trainName}` : ''), margin + 20, y + 9); + if (schedule.seatClass) { + doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED); + doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' }); } - yPos += 18; + return y + 18; +} - // ============ PASSENGERS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PASSENGERS', margin, yPos); - - yPos += 8; +function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER DETAILS', margin, y); + y += 7; - // Passenger table - const passengerData = booking.passengers.map((p, idx) => [ - (idx + 1).toString(), - p.fullName, - p.category, - p.seat?.number || '-', - p.seat?.coach || '-', - p.seat?.seatClass || '-', - ]); + const rows: [string, string][] = [ + ['Full Name', data.passengerName || '—'], + ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], + ['Nationality', data.nationality || '—'], + ]; + + if (data.isRoundTrip) { + rows.push(['Outbound Seat', data.outboundSeatNumber || '—']); + rows.push(['Return Seat', data.inboundSeatNumber || '—']); + } else { + rows.push(['Seat', data.seatNumber || '—']); + } autoTable(doc, { - startY: yPos, - head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], - body: passengerData, - theme: 'striped', - headStyles: { - fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], - textColor: [255, 255, 255], - fontSize: 9, - fontStyle: 'bold', - }, - bodyStyles: { - fontSize: 9, - textColor: [darkGray[0], darkGray[1], darkGray[2]], - }, - alternateRowStyles: { - fillColor: [250, 250, 250], + startY: y, + body: rows, + theme: 'plain', + styles: { fontSize: 9, cellPadding: 3 }, + columnStyles: { + 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 }, + 1: { textColor: [DARK[0], DARK[1], DARK[2]] }, }, + alternateRowStyles: { fillColor: [248, 248, 248] }, margin: { left: margin, right: margin }, }); - yPos = (doc as any).lastAutoTable.finalY + 10; + return (doc as any).lastAutoTable.finalY + 8; +} - // ============ PAYMENT SUMMARY ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PAYMENT SUMMARY', margin, yPos); - - yPos += 8; +function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 20, 'F'); + doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('Fare', margin + 5, y + 7); + doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' }); + doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold'); + doc.text('✓ PAID', margin + 5, y + 15); + return y + 26; +} - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F'); - - doc.setFontSize(10); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('Total Amount', margin + 5, yPos + 7); - - doc.setFontSize(16); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' }); - - doc.setFontSize(9); - doc.setTextColor(34, 197, 94); - doc.setFont('helvetica', 'bold'); - doc.text('✓ PAID', margin + 5, yPos + 15); - - yPos += 28; - - // ============ INSTRUCTIONS ============ +function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number { doc.setFillColor(252, 211, 77); - doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); - - doc.setFontSize(9); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8); - doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11); - doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15); + doc.rect(margin, y, pageWidth - margin * 2, 18, 'F'); + doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); + doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11); + doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15); + return y + 24; +} - // ============ FOOTER ============ - const footerY = pageHeight - 25; - - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.line(margin, footerY, pageWidth - margin, footerY); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); +function drawFooter(doc: jsPDF, createdAt: string): void { + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const footerY = pageHeight - 22; + + doc.setDrawColor(...LIGHT); + doc.line(15, footerY, pageWidth - 15, footerY); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' }); - doc.setFontSize(7); - doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); +} - // Watermark (removed rotation as it may cause issues) - doc.setTextColor(240, 240, 240); - doc.setFontSize(50); - doc.setFont('helvetica', 'bold'); - doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' }); +// ─── public API ────────────────────────────────────────────────────────────── - // Save PDF - doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageW = doc.internal.pageSize.getWidth(); + const margin = 15; + + let y = await drawHeader(doc, margin); + + // Title + doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' }); + y += 10; + + y = drawStatusBadge(doc, data.status, y, pageW); + y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW); + y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); + + if (data.isRoundTrip && data.inboundSchedule) { + y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW); + } + + y = drawPassengerDetails(doc, data, y, margin); + y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); + drawInstructions(doc, y, margin, pageW); + drawFooter(doc, data.createdAt); + + const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + doc.save(`Voucher_${safeName}.pdf`); +}; + +// ─── legacy combined voucher (kept for backward compat) ────────────────────── + +interface VoucherData { + bookingRef: string; + status: string; + passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>; + schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string }; + totalMinor: number; + currency: string; + bookingType: string; + createdAt: string; +} + +export const generateVoucherPDF = async (booking: VoucherData): Promise => { + for (let i = 0; i < booking.passengers.length; i++) { + const p = booking.passengers[i]; + await generatePassengerVoucherPDF({ + bookingRef: booking.bookingRef, + ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + passengerName: p.fullName, + seatNumber: p.seat?.number, + status: booking.status, + outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, + isRoundTrip: false, + fareMinor: Math.round(booking.totalMinor / booking.passengers.length), + currency: booking.currency, + createdAt: booking.createdAt, + }); + // small delay so browsers don't block multiple sequential downloads + if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } }; diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index d367971d0..66232dfcc 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -131,6 +131,12 @@ export class WebhooksController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" }) async receiveDMoney(@Body() payload: DMoneyWebhookPayload) { + this.logger.log( + `D-Money webhook hit: merchOrderId=${payload?.merch_order_id ?? "n/a"} ` + + `paymentOrderId=${payload?.payment_order_id ?? "n/a"} ` + + `tradeStatus=${payload?.trade_status ?? "n/a"}`, + ); + this.logger.log(`D-Money webhook payload: ${JSON.stringify(payload)}`); try { await this.dMoney.handle(payload); } catch (err) {