Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-06-24 14:23:32 +03:00
60 changed files with 3482 additions and 275 deletions

View File

@@ -33,14 +33,17 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv": "^17.4.2",

View File

@@ -1,11 +1,11 @@
-- DropForeignKey
ALTER TABLE "Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- AlterTable
ALTER TABLE "Passenger" ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateTable (TicketSeat already exists from init; IF NOT EXISTS makes this idempotent)
CREATE TABLE IF NOT EXISTS "TicketSeat" (
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
@@ -15,19 +15,40 @@ CREATE TABLE IF NOT EXISTS "TicketSeat" (
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId");
-- AddForeignKey
ALTER TABLE "Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey"
FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -17,14 +17,21 @@ CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId
-- ────────────────────────────────────────────────────────────
-- 2. Populate iamUserId for existing agent records
-- Match via User.email → iam.users.email
-- Match via User.email → iam.users.email (skip if iam schema absent)
-- ────────────────────────────────────────────────────────────
UPDATE passenger."Agent" a
SET "iamUserId" = iu.id
FROM passenger."User" u
JOIN iam.users iu ON iu.email = u.email
WHERE a."userId" = u.id
AND a."iamUserId" IS NULL;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'iam' AND table_name = 'users'
) THEN
UPDATE passenger."Agent" a
SET "iamUserId" = iu.id
FROM passenger."User" u
JOIN iam.users iu ON iu.email = u.email
WHERE a."userId" = u.id
AND a."iamUserId" IS NULL;
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely

View File

@@ -127,7 +127,7 @@ ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- DropIndex (created later in 20260627_add_journey_booking_id; IF EXISTS guards against ordering)
-- DropIndex
DROP INDEX IF EXISTS "Journey_bookingId_idx";
-- AlterTable
@@ -239,7 +239,16 @@ ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey"
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Journey_bookingId_fkey moved to 20260627_add_journey_booking_id (column added there)
-- 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;

View File

@@ -0,0 +1,153 @@
-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema)
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Agent_iamUserId_key'
AND conrelid = 'passenger."Agent"'::regclass
) THEN
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
-- Drop old Agent.userId FK and column if they still exist
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
DROP INDEX IF EXISTS passenger."Agent_userId_key";
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
-- Drop old Passenger.userId FK (column stays as plain nullable string)
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- TravelPackage
CREATE TABLE IF NOT EXISTS passenger."TravelPackage" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"outboundScheduleId" TEXT NOT NULL,
"returnScheduleId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"boardingTime" TIMESTAMP(3) NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"totalCapacity" INTEGER NOT NULL,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"includedServices" JSONB NOT NULL,
"coachConfiguration" TEXT,
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
"busTransferRoute" TEXT,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code");
CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom");
-- PackagePriceTier
CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"seatType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"availableSeats" INTEGER NOT NULL DEFAULT 0,
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType");
-- PackageBooking
CREATE TABLE IF NOT EXISTS passenger."PackageBooking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT NOT NULL,
"passengerId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
"passengerCount" INTEGER NOT NULL DEFAULT 1,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"displayCurrency" TEXT,
"displayTotalMinor" INTEGER,
"promoCode" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef");
CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status");
-- PackageBookingPassenger
CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"idDocumentType" TEXT,
"idDocumentNumber" TEXT,
"passportNumber" TEXT,
"passportCountry" TEXT,
"seatLabel" TEXT,
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
);
-- PackagePaymentIntent
CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" (
"id" TEXT NOT NULL,
"packageBookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId");
-- Foreign keys
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey"
FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey"
FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePriceTier"
ADD CONSTRAINT "PackagePriceTier_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_priceTierId_fkey"
FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_passengerId_fkey"
FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBookingPassenger"
ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePaymentIntent"
ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey"
FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- Create PackageStatus enum
DO $$ BEGIN
CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Drop default, cast column to enum, restore default
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE passenger."TravelPackage"
ALTER COLUMN "status" TYPE passenger."PackageStatus"
USING "status"::passenger."PackageStatus";
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus";

View File

@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "passenger"."ExcessBaggageCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"excessWeightKg" INTEGER NOT NULL,
"feePerKgMinor" INTEGER NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"waivedBy" TEXT,
"waivedReason" TEXT,
"contactPhone" TEXT,
"contactEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status");
-- AddForeignKey
ALTER TABLE "passenger"."ExcessBaggageCharge"
ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Seed default paymentToken using gen_random_uuid() for any rows that may exist
UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = '';

View File

@@ -263,6 +263,8 @@ model User {
sessions Session[]
passenger Passenger?
passenger Passenger?
@@schema("passenger")
}
@@ -294,7 +296,7 @@ model Passenger {
notifications Notification[]
travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
packageBookings PackageBooking[]
packageBookings PackageBooking[]
@@index([userId])
@@index([iamUserId])
@@schema("passenger")
@@ -547,6 +549,7 @@ model Booking {
modifications BookingModification[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
journey Journey?
@@index([passengerId, status])
@@ -1189,6 +1192,31 @@ model BaggageBooking {
@@schema("passenger")
}
model ExcessBaggageCharge {
id String @id @default(uuid())
bookingId String
agentId String
excessWeightKg Int
feePerKgMinor Int
totalMinor Int
currency String @default("ETB")
status String @default("PENDING") // PENDING | PAID | EXPIRED | WAIVED | CASH_COLLECTED
paymentToken String @unique @default(uuid())
expiresAt DateTime
paidAt DateTime?
waivedBy String?
waivedReason String?
contactPhone String?
contactEmail String?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([paymentToken])
@@index([status])
@@schema("passenger")
}
model AuditLog {
id String @id @default(uuid())
iamUserId String?

View File

@@ -33,6 +33,7 @@ const ds = new DataSource({
(async () => {
await ds.initialize();
await ds.query('CREATE SCHEMA IF NOT EXISTS iam');
// The IAM migrations rely on uuid_generate_v4() but never CREATE the extension themselves.
await ds.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
const applied = await ds.runMigrations({ transaction: 'each' });

View File

@@ -4,6 +4,8 @@ import {
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
@@ -59,9 +61,16 @@ import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.module';
import { SystemConfigModule } from './modules/system-config/system-config.module';
import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module';
@Module({
imports: [
ThrottlerModule.forRoot([
{ name: 'auth', ttl: 60_000, limit: 5 },
{ name: 'strict', ttl: 60_000, limit: 20 },
{ name: 'default', ttl: 60_000, limit: 100 },
]),
ConfigModule.forRoot({
isGlobal: true,
load: [
@@ -120,8 +129,11 @@ import { PackagesModule } from './modules/packages/packages.module';
CurrenciesModule,
SystemConfigModule,
PackagesModule,
ExcessBaggageModule,
HealthModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,
],
@@ -139,7 +151,15 @@ export class AppModule implements OnApplicationBootstrap {
} catch (err) {
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
}
await this.edrPassengerOrgSeeder.run();
await this.passengerStaffUsersSeeder.run();
try {
await this.edrPassengerOrgSeeder.run();
} catch (err) {
console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
}
try {
await this.passengerStaffUsersSeeder.run();
} catch (err) {
console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
}
}
}

View File

@@ -46,11 +46,11 @@ export function buildIamTypeOrmOptions(): TypeOrmModuleOptions {
`${iamDist}/entities/**/*.entity.{ts,js}`,
`${apiDist}/entities/**/*.entity.{ts,js}`,
],
synchronize: false, // schema is owned by IAM migrations — never auto-sync
migrationsRun: false, // migrations are run by the IAM package CLI (dev) / IAM team (prod)
synchronize: false,
migrationsRun: false,
autoLoadEntities: false,
migrationsTableName: 'typeorm_migrations',
retryAttempts: 0, // fail fast in dev if the iam schema / DB is unreachable
retryAttempts: process.env.IAM_ENABLED === 'true' ? 3 : 0,
logging: ['error'],
};
}

View File

@@ -44,6 +44,9 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
@@ -120,9 +123,10 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- Complete audit trail per leg for compliance and reporting
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
### Booking Type Matrix
@@ -262,9 +266,14 @@ Choose the right endpoint and bookingType:
\`GET /payments/{paymentId}/status\` to confirm payment and retrieve tickets with QR codes
## Rate Limiting
- Auth endpoints: 5 requests/minute
- General endpoints: 100 requests/minute
- Webhook endpoints: No limit
| Tier | Limit | Applied to |
|---|---|---|
| auth | 5 req/min | \`/auth\` (all), \`/fayda/verification\` (all) |
| strict | 20 req/min | \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\` |
| default | 100 req/min | All other endpoints |
Exempt from rate limiting: \`/health/*\`, \`/internal/payments/*\`, payment webhook handlers.
## Error Handling
All errors follow standard format:
@@ -312,13 +321,14 @@ Payment providers send notifications to:
.addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API")
.addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations")
.addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking")
.addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management")
.addTag("Health", "Liveness (GET /health), readiness with DB check (GET /health/ready), and app info (GET /health/info). All probes are public and exempt from rate limiting.")
.addTag("Internal Payments", "Service-to-service payment event handler (mark-paid). Requires service auth token. Exempt from rate limiting.")
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management. Boarding pass email+SMS sent automatically on gate validation.")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles. Rate limited: 20 req/min.")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds. Rate limited: 20 req/min.")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation. Exempt from rate limiting.")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
@@ -329,9 +339,9 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
.addTag("Tickets", "QR/barcode generation, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), audit trails. Boarding pass email+SMS sent automatically on every successful validation.")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger. Rate limited: 20 req/min.")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Patch, Delete, Param, Request, Query, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
@@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@Controller('auth')
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
export class AuthController {
constructor(private passengerAuthService: PassengerAuthService) {}
@@ -66,4 +68,54 @@ export class AuthController {
}
// TODO: admin user management endpoints — implement when admin module is ready
@Get('users')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all users (admin)' })
listUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.passengerAuthService.listUsers({
search, role, status,
page: page ? +page : 1,
pageSize: pageSize ? +pageSize : 20,
});
}
@Post('users')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create user (admin)' })
createUser(@Body() body: any) {
return this.passengerAuthService.createUser(body);
}
@Patch('users/:id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update user (admin)' })
updateUser(@Param('id') id: string, @Body() body: any) {
return this.passengerAuthService.updateUser(id, body);
}
@Delete('users/:id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete user (admin)' })
deleteUser(@Param('id') id: string) {
return this.passengerAuthService.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password (admin)' })
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
}
}

View File

@@ -193,6 +193,209 @@ export class PassengerAuthService {
};
}
async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
const offset = (page - 1) * pageSize;
const params: any[] = [];
const conditions: string[] = [];
if (filters.search) {
params.push(`%${filters.search}%`);
conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`);
}
if (filters.role) {
params.push(`%${filters.role}%`);
conditions.push(`r.key ILIKE $${params.length}`);
}
if (filters.status) {
const active = filters.status === 'ACTIVE';
params.push(active);
conditions.push(`u.is_active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const baseQuery = `
FROM iam.users u
LEFT JOIN iam.user_roles ur ON ur.user_id = u.id
LEFT JOIN iam.roles r ON r.id = ur.role_id
${where}
`;
const countParams = [...params];
const [rows, countRows] = await Promise.all([
this.dataSource.query(
`SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at,
r.key as role_key, r.name as role_name
${baseQuery}
ORDER BY u.created_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset],
),
this.dataSource.query(
`SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`,
countParams,
),
]);
const items = rows.map((u: any) => ({
id: u.id,
email: u.email,
fullName: u.name?.en ?? u.name?.am ?? '',
role: u.role_key ?? '',
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
lastLogin: u.metadata?.lastLogin ?? null,
createdAt: u.created_at,
}));
return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize };
}
async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) {
const existing = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
[data.email],
);
if (existing.length) throw new ConflictException('Email already registered');
// Derive username from email local-part; ensure uniqueness by appending a short suffix if taken
const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
const taken = await this.dataSource.query<{ username: string }[]>(
`SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`,
[`${baseUsername}%`],
);
const takenSet = new Set(taken.map((r) => r.username));
let username = baseUsername;
let suffix = 1;
while (takenSet.has(username)) {
username = `${baseUsername}${suffix++}`;
}
// Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js)
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
const passwordHash = await hashPassword(data.password);
await this.dataSource.query(
`INSERT INTO iam.users (email, username, name, user_type, status, is_active)
VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`,
[
data.email,
username,
JSON.stringify({ en: data.fullName, am: data.fullName }),
data.status === 'INACTIVE' ? 'pending' : 'accepted',
data.status !== 'INACTIVE',
],
);
// Insert credential with correct column `password` and is_active = true
// so the IAM login SQL (find-user-for-login.sql) can find and verify it
const newUser = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email],
);
if (newUser.length) {
await this.dataSource.query(
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
[newUser[0].id],
);
await this.dataSource.query(
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
[newUser[0].id, passwordHash],
);
}
// Assign the selected role in iam.user_roles
const rows = await this.dataSource.query(
`SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`,
[data.email],
);
const u = rows[0];
if (data.role && u) {
try {
const roleRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
[data.role],
);
if (roleRows.length) {
await this.dataSource.query(
`INSERT INTO iam.user_roles (user_id, role_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[u.id, roleRows[0].id],
);
}
} catch {
// non-fatal — role assignment failure should not block user creation
}
}
return {
id: u.id, email: u.email,
fullName: data.fullName, role: data.role,
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
createdAt: u.created_at,
};
}
async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) {
const rows = await this.dataSource.query(
`SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`,
[id],
);
if (!rows.length) throw new ConflictException('User not found');
const existing = rows[0];
const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name;
const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active;
await this.dataSource.query(
`UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`,
[JSON.stringify(name), isActive, id],
);
// Update role: remove existing user_roles then assign the new one
if (data.role) {
try {
const roleRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
[data.role],
);
if (roleRows.length) {
await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]);
await this.dataSource.query(
`INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, roleRows[0].id],
);
}
} catch {
// non-fatal
}
}
return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' };
}
async deleteUser(id: string) {
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]);
return { success: true };
}
async resetUserPassword(id: string, tempPassword: string) {
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
const passwordHash = await hashPassword(tempPassword);
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
await this.dataSource.query(
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
[id],
);
// Insert new active credential
await this.dataSource.query(
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
[id, passwordHash],
);
return { success: true, message: 'Password reset successfully' };
}
private async compensateIamSignup(email: string): Promise<void> {
try {
const rows = await this.dataSource.query<{ id: string }[]>(

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { Throttle } from '@nestjs/throttler';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -9,6 +10,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@Controller('bookings')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class BookingsController {
constructor(
private service: BookingsService,

View File

@@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
WaiveChargeDto,
InitiateExcessPaymentDto,
} from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@Controller('agents/excess-baggage')
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
export class ExcessBaggageAgentController {
constructor(private service: ExcessBaggageService) {}
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Body() dto: LogExcessBaggageDto) {
return this.service.logCharge(dto);
}
@Get()
@ApiOperation({ summary: 'List all excess baggage charges (admin/supervisor)' })
getAll(
@Query('status') status?: string,
@Query('bookingRef') bookingRef?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
status,
bookingRef,
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
@ApiTags('Excess Baggage')
@Controller('excess-baggage')
export class ExcessBaggagePublicController {
constructor(private service: ExcessBaggageService) {}
@Get('pay/:token')
@ApiOperation({ summary: 'Retrieve charge details by payment token (public)' })
getByToken(@Param('token') token: string) {
return this.service.getByToken(token);
}
@Post('pay/:token/initiate')
@ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' })
initiatePayment(
@Param('token') token: string,
@Body() dto: InitiateExcessPaymentDto,
) {
return this.service.initiatePayment(token, dto);
}
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LogExcessBaggageDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
@IsOptional() collectCash?: boolean;
}
export class WaiveChargeDto {
@ApiProperty() @IsString() waivedBy: string;
@ApiPropertyOptional() @IsOptional() @IsString() waivedReason?: string;
}
export class InitiateExcessPaymentDto {
@ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] })
@IsString() method: string;
@ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string;
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ExcessBaggageService } from './excess-baggage.service';
import {
ExcessBaggageAgentController,
ExcessBaggagePublicController,
} from './excess-baggage.controller';
import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [HttpModule, PaymentsModule, NotificationsModule],
controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController],
providers: [ExcessBaggageService],
exports: [ExcessBaggageService],
})
export class ExcessBaggageModule {}

View File

@@ -0,0 +1,252 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
import {
LogExcessBaggageDto,
WaiveChargeDto,
InitiateExcessPaymentDto,
} from './excess-baggage.dto';
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
ProviderMethod,
ProviderPaymentStatus,
} from '@edr/types';
import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes
@Injectable()
export class ExcessBaggageService {
private readonly logger = new Logger(ExcessBaggageService.name);
constructor(
private prisma: PrismaService,
private paymentClient: PaymentClientService,
private notifications: NotificationsService,
) {}
async logCharge(dto: LogExcessBaggageDto) {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: {
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
}
// Resolve fee per kg from BaggageAllowance via seat class
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
if (coachTypeId) {
const seatClass = await this.prisma.seatClass.findFirst({
where: { coachTypeId },
});
if (seatClass) {
const allowance = await this.prisma.baggageAllowance.findFirst({
where: { seatClassId: seatClass.id },
});
if (allowance) feePerKgMinor = allowance.excessFeePerKg;
}
}
const totalMinor = feePerKgMinor * dto.excessWeightKg;
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
const paidAt = dto.collectCash ? new Date() : null;
const charge = await this.prisma.excessBaggageCharge.create({
data: {
bookingId: dto.bookingId,
agentId: dto.agentId,
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,
status,
expiresAt,
paidAt,
contactPhone,
contactEmail,
},
});
if (!dto.collectCash) {
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
}
return charge;
}
private async sendPaymentLink(
charge: any,
booking: any,
phone: string | null,
email: string | null,
) {
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`;
const amountStr = (charge.totalMinor / 100).toFixed(2);
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
const recipient = phone ?? email ?? booking.passengerId;
try {
await this.notifications['deliverSms'](recipient, msg);
} catch (err) {
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
}
if (email) {
try {
await this.notifications['deliverEmail'](
recipient,
`EDR — Excess baggage payment required (${booking.bookingRef})`,
msg,
);
} catch (err) {
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
}
}
}
async getCharge(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, status: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
return charge;
}
async getByToken(token: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { paymentToken: token },
include: { booking: { select: { bookingRef: true, scheduleId: true } } },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) {
if (charge.status === 'PENDING') {
await this.prisma.excessBaggageCharge.update({
where: { id: charge.id },
data: { status: 'EXPIRED' },
});
}
throw new BadRequestException('This payment link has expired');
}
if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') {
throw new BadRequestException('This charge has already been paid');
}
if (charge.status === 'WAIVED') {
throw new BadRequestException('This charge has been waived');
}
return charge;
}
async initiatePayment(token: string, dto: InitiateExcessPaymentDto) {
const charge = await this.getByToken(token);
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`;
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType,
referenceId: charge.id,
orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`,
amountMinor: charge.totalMinor / 100,
currency: charge.currency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform as any,
returnUrl,
failureUrl: returnUrl,
});
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markPaid(charge.id, snapshot.providerTxnId);
}
return {
chargeId: charge.id,
status: snapshot.status,
clientAction: snapshot.clientAction,
merchantOrderId: snapshot.merchantOrderId,
};
}
async markPaid(chargeId: string, providerTxnId?: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
return this.prisma.excessBaggageCharge.update({
where: { id: chargeId },
data: { status: 'PAID', paidAt: new Date() },
});
}
async waiveCharge(id: string, dto: WaiveChargeDto) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
throw new BadRequestException('Cannot waive a charge that has already been paid');
}
return this.prisma.excessBaggageCharge.update({
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
}
async resendLink(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, passengerId: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status !== 'PENDING') {
throw new BadRequestException('Can only resend link for PENDING charges');
}
// Extend expiry by 30 minutes from now
const updatedCharge = await this.prisma.excessBaggageCharge.update({
where: { id },
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail);
return { sent: true };
}
async getAll(filters: {
status?: string;
bookingRef?: string;
page?: number;
pageSize?: number;
}) {
const { status, bookingRef, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
const [items, total] = await Promise.all([
this.prisma.excessBaggageCharge.findMany({
where,
include: { booking: { select: { bookingRef: true, status: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.excessBaggageCharge.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,59 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PrismaService } from '../../common/prisma.service';
@ApiTags('Health')
@Controller('health')
@SkipThrottle()
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@IsPublic()
@ApiOperation({ summary: 'Liveness probe' })
liveness() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
@Get('ready')
@IsPublic()
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
async readiness() {
const start = Date.now();
try {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
timestamp: new Date().toISOString(),
checks: { database: { status: 'ok', latencyMs: Date.now() - start } },
};
} catch (err) {
return {
status: 'error',
timestamp: new Date().toISOString(),
checks: {
database: {
status: 'error',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : 'Unknown error',
},
},
};
}
}
@Get('info')
@IsPublic()
@ApiOperation({ summary: 'App info — version, environment, uptime' })
info() {
return {
name: 'edr-passenger-api',
version: process.env.npm_package_version ?? '1.0.0',
environment: process.env.NODE_ENV ?? 'development',
uptimeSeconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -439,6 +439,126 @@ export class NotificationsService {
</html>`;
}
async sendBoardingPassNotification(params: {
passengerId: string | null;
contactEmail: string | null;
contactPhone: string | null;
bookingRef: string;
leg: string | null;
booking: any;
ticket: any;
}): Promise<void> {
const { passengerId, contactEmail, contactPhone, bookingRef, leg, booking, ticket } = params;
// Resolve contact — prefer IAM user record, fall back to booking contact fields
let email: string | null = contactEmail ?? null;
let phone: string | null = contactPhone ?? null;
if (passengerId) {
const resolved = await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null);
const resolvedPhone = await this.getRecipientAddress(passengerId, 'SMS').catch(() => null);
if (resolved) email = resolved;
if (resolvedPhone) phone = resolvedPhone;
}
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
const origin = s.originStation?.name ?? '';
const dest = s.destinationStation?.name ?? '';
const train = s.train?.name ?? s.train?.number ?? '';
const dep = fmt(s.departureAt);
const arr = fmt(s.arrivalAt);
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
name: bs.passengerName ?? '',
coach: bs.seat?.coach?.number ?? '-',
seat: bs.seat?.seatNumber ?? '-',
cls: bs.seat?.coach?.coachType?.name ?? '-',
}));
const seatLines = seats.map(s => ` ${s.name} — Coach ${s.coach}, Seat ${s.seat} (${s.cls})`).join('\n');
const smsText =
`EDR Boarding Pass${legLabel}\n` +
`Ref: ${bookingRef}\n` +
`${origin}${dest}\n` +
`Train: ${train} | Dep: ${dep}\n` +
(seatLines ? `${seatLines}\n` : '') +
`Barcode: ${ticket.barcodePayload}`;
if (phone) {
await this.smsClient.sendSms({ to: phone, message: smsText }).catch((e) =>
this.logger.error(`Boarding pass SMS failed for ${bookingRef}: ${e?.message}`),
);
}
if (email) {
const seatRows = seats
.map(
(s) =>
`<tr>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.name}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.coach}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.seat}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.cls}</td>
</tr>`,
)
.join('');
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
<p style="margin:8px 0 0;">Boarding Pass${legLabel}</p>
</div>
<div style="padding:24px;">
<p>Booking reference: <strong>${bookingRef}</strong></p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px 0;color:#666;">From</td><td style="text-align:right;"><strong>${origin}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">To</td><td style="text-align:right;"><strong>${dest}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">Train</td><td style="text-align:right;">${train}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Departs</td><td style="text-align:right;">${dep}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Arrives</td><td style="text-align:right;">${arr}</td></tr>
</table>
<h3 style="margin:16px 0 8px;">Passengers</h3>
<table style="width:100%;border-collapse:collapse;">
<tr style="color:#666;text-align:left;">
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
</tr>
${seatRows}
</table>
<div style="text-align:center;margin:24px 0;">
<p style="color:#666;margin:0 0 8px;">QR code for gate scanning</p>
<img src="${ticket.qrPayload}" alt="Boarding pass QR" width="180" height="180"
style="border:1px solid #eee;padding:8px;background:#fff;" />
<p style="color:#666;font-size:12px;margin:8px 0 0;">Barcode: <strong>${ticket.barcodePayload}</strong></p>
</div>
</div>
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
const textFallback =
`EDR Boarding Pass${legLabel}\nRef: ${bookingRef}\n${origin}${dest}\n` +
`Train: ${train} | Departs: ${dep} | Arrives: ${arr}\n${seatLines}\n` +
`Barcode: ${ticket.barcodePayload}`;
await this.emailClient
.sendEmail({ to: email, subject: `EDR Boarding Pass — ${bookingRef}${legLabel}`, text: textFallback, html })
.catch((e) => this.logger.error(`Boarding pass email failed for ${bookingRef}: ${e?.message}`));
}
}
@OnEvent('payment.failed')
async onPaymentFailed(payload: any) {
const booking = payload.booking;

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -52,6 +52,14 @@ export class PackagesController {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
return this.service.update(id, dto);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -60,6 +68,30 @@ export class PackagesController {
return this.service.activate(id);
}
@Post(':id/tiers')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
return this.service.addTier(id, dto);
}
@Patch('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
return this.service.updateTier(tierId, dto);
}
@Delete('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) {
return this.service.deleteTier(tierId);
}
@Post('book')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -16,6 +16,13 @@ export class CreatePriceTierDto {
@IsInt() @Min(0) availableSeats: number;
}
export class UpdatePriceTierDto {
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() label?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number;
@ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) availableSeats?: number;
}
export class CreatePackageDto {
@ApiProperty({ example: 'KULUBBI-2025' })
@IsString() code: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto';
import { Currency } from '@prisma/client';
function generateRef(): string {
@@ -70,6 +70,53 @@ export class PackagesService {
});
}
async update(id: string, dto: Partial<CreatePackageDto>) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({
where: { id },
data: {
...(dto.code && { code: dto.code }),
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }),
...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }),
...(dto.originStationId && { originStationId: dto.originStationId }),
...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }),
...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }),
...(dto.departureTime && { departureTime: new Date(dto.departureTime) }),
...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }),
...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }),
...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }),
...(dto.includedServices && { includedServices: dto.includedServices }),
...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }),
...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }),
...(dto.validFrom && { validFrom: new Date(dto.validFrom) }),
...(dto.validUntil && { validUntil: new Date(dto.validUntil) }),
},
include: { priceTiers: true },
});
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } });
}
async updateTier(tierId: string, dto: UpdatePriceTierDto) {
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
if (!tier) throw new NotFoundException('Price tier not found');
return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto });
}
async deleteTier(tierId: string) {
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
if (!tier) throw new NotFoundException('Price tier not found');
if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings');
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,6 +10,7 @@ import { PrismaService } from '../../common/prisma.service';
@ApiTags('Passengers')
@Controller('passengers')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PassengersController {
constructor(
private service: PassengersService,

View File

@@ -7,6 +7,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { SkipThrottle } from "@nestjs/throttler";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentsService } from "./payments.service";
@@ -20,6 +21,7 @@ import { PaymentsService } from "./payments.service";
@ApiTags("Internal Payments")
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
@SkipThrottle()
export class InternalPaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}

View File

@@ -18,6 +18,7 @@ import {
ApiProduces,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { SkipThrottle, Throttle } from "@nestjs/throttler";
import { Response } from "express";
import { PaymentsService } from "./payments.service";
import {
@@ -34,6 +35,7 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Payment")
@Controller("payments")
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}

View File

@@ -61,5 +61,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentEventsConsumer,
ServiceAuthGuard,
],
exports: [PaymentClientService],
})
export class PaymentsModule {}

View File

@@ -242,9 +242,26 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const [holdMinutes, cutoffHours] = await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
]);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { departureAt: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
const cutoffMs = cutoffHours * 60 * 60 * 1000;
if (msUntilDeparture <= cutoffMs) {
throw new BadRequestException(
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
);
}
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },

View File

@@ -3,10 +3,12 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
} as const;
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
};
@Injectable()

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -16,6 +17,7 @@ interface OfflineValidation {
export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -308,6 +310,7 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -325,6 +328,7 @@ export class TicketsService {
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -359,6 +363,7 @@ export class TicketsService {
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -389,6 +394,7 @@ export class TicketsService {
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -398,9 +404,33 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
}).then((enriched) => {
if (!enriched) return;
this.notifications.sendBoardingPassNotification({
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
contactEmail: (enriched as any).contactEmail ?? null,
contactPhone: (enriched as any).contactPhone ?? null,
bookingRef: enriched.bookingRef,
leg,
booking: enriched,
ticket,
}).catch(() => null);
}).catch(() => null);
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },

View File

@@ -15,6 +15,7 @@ import {
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
@@ -37,6 +38,7 @@ interface RequestWithUser {
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
export class VerifaydaController {
constructor(private readonly service: VerifaydaService) {}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { WalletService } from './wallet.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('wallet')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class WalletController {
constructor(private service: WalletService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }

View File

@@ -31,6 +31,7 @@ export default function BookingsPage() {
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
@@ -63,12 +64,12 @@ export default function BookingsPage() {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setDeleteConfirmOpen(false);
setBookingToDelete(null);
setDeleteError(null);
setSuccessMessage('Booking deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteConfirmOpen(false);
alert(`Error: ${error.message || 'Failed to delete booking'}`);
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking');
},
});
@@ -182,7 +183,7 @@ export default function BookingsPage() {
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
},
{ label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -369,11 +370,12 @@ export default function BookingsPage() {
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }}
onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }}
title="Delete Booking"
message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
error={deleteError ?? undefined}
/>
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function ExcessBaggageLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Send } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING',
PAID: 'CONFIRMED',
CASH_COLLECTED: 'CONFIRMED',
EXPIRED: 'CANCELLED',
WAIVED: 'CANCELLED',
};
export default function ExcessBaggagePage() {
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' });
const [waiveModal, setWaiveModal] = useState<any>(null);
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }),
});
const waiveMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setWaiveModal(null);
setWaiveReason('');
setWaiveError(null);
},
onError: (e: any) => setWaiveError(e?.response?.data?.message || e?.message || 'Failed to waive'),
});
const resendMutation = useMutation({
mutationFn: (id: string) => excessBaggageApi.resendLink(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
});
const columns = [
{
key: 'booking', label: 'Booking',
render: (c: any) => (
<div className="text-sm">
<div className="font-mono font-semibold">{c.booking?.bookingRef ?? '—'}</div>
<div className="text-muted-foreground">{formatDateTime(c.createdAt)}</div>
</div>
),
},
{
key: 'weight', label: 'Excess / Charge',
render: (c: any) => (
<div className="text-sm">
<div className="font-semibold">{c.excessWeightKg} kg</div>
<div className="text-muted-foreground">{formatCurrency(c.feePerKgMinor, c.currency)}/kg</div>
</div>
),
},
{
key: 'total', label: 'Total',
render: (c: any) => <span className="font-semibold">{formatCurrency(c.totalMinor, c.currency)}</span>,
},
{
key: 'status', label: 'Status',
render: (c: any) => (
<Badge variant="status" status={STATUS_VARIANT[c.status] ?? 'PENDING'}>
{c.status.replace('_', ' ')}
</Badge>
),
},
{
key: 'contact', label: 'Contact',
render: (c: any) => (
<div className="text-sm text-muted-foreground">
{c.contactPhone && <div>{c.contactPhone}</div>}
{c.contactEmail && <div>{c.contactEmail}</div>}
{!c.contactPhone && !c.contactEmail && '—'}
</div>
),
},
{
key: 'expires', label: 'Expires',
render: (c: any) => (
<span className={`text-sm ${new Date(c.expiresAt) < new Date() && c.status === 'PENDING' ? 'text-red-500' : 'text-muted-foreground'}`}>
{formatDateTime(c.expiresAt)}
</span>
),
},
];
const actions = [
{
label: 'Resend Link',
icon: Send,
variant: 'secondary' as const,
onClick: (c: any) => resendMutation.mutate(c.id),
hidden: (c: any) => c.status !== 'PENDING',
},
{
label: 'Waive',
icon: RefreshCw,
variant: 'secondary' as const,
onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); },
hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Excess Baggage</h1>
<p className="text-muted-foreground">Track and manage excess baggage charges at boarding</p>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Booking Ref</label>
<input
className="input"
placeholder="Search by booking ref…"
value={filters.bookingRef}
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}
>
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="CASH_COLLECTED">Cash Collected</option>
<option value="EXPIRED">Expired</option>
<option value="WAIVED">Waived</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No excess baggage charges found"
/>
{/* Waive Modal */}
<Modal
isOpen={!!waiveModal}
onClose={() => setWaiveModal(null)}
title="Waive Charge"
size="sm"
>
{waiveModal && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Waiving charge of{' '}
<span className="font-semibold text-foreground">
{formatCurrency(waiveModal.totalMinor, waiveModal.currency)}
</span>{' '}
for booking <span className="font-mono font-semibold">{waiveModal.booking?.bookingRef}</span>.
</p>
<div>
<label className="label">Reason (optional)</label>
<input
className="input"
placeholder="e.g. Medical exemption, scale error…"
value={waiveReason}
onChange={(e) => setWaiveReason(e.target.value)}
/>
</div>
{waiveError && <p className="text-sm text-red-600 dark:text-red-400">{waiveError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setWaiveModal(null)}>Cancel</ActionButton>
<ActionButton
loading={waiveMutation.isPending}
onClick={() => waiveMutation.mutate({ id: waiveModal.id, reason: waiveReason })}
>
Confirm Waive
</ActionButton>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function HealthLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) router.push('/login');
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600" />
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,361 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import {
Activity,
Database,
Info,
RefreshCw,
CheckCircle2,
XCircle,
Clock,
Server,
Cpu,
Globe,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
// Dedicated bare client — no auth token, no 401 redirect interceptor.
// Health probes are public. We unwrap the { success, data } envelope explicitly.
const healthClient = axios.create({ baseURL: API_URL });
async function fetchHealth(path: string) {
const res = await healthClient.get<{ success: boolean; data: any }>(path);
return res.data?.data ?? res.data;
}
function StatusDot({ ok }: { ok: boolean | null }) {
if (ok === null)
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-muted animate-pulse" />;
return ok ? (
<span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500 shadow-[0_0_6px_2px_rgba(16,185,129,0.4)]" />
) : (
<span className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_6px_2px_rgba(239,68,68,0.4)]" />
);
}
function StatusBadge({ ok }: { ok: boolean | null }) {
if (ok === null)
return <span className="edr-badge bg-muted text-muted-foreground">Checking</span>;
return ok ? (
<span className="edr-badge edr-badge-success">Healthy</span>
) : (
<span className="edr-badge bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">Degraded</span>
);
}
function MetricRow({ label, value, icon: Icon }: { label: string; value: string; icon: any }) {
return (
<div className="flex items-center justify-between py-2.5 border-b border-border last:border-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="h-4 w-4 shrink-0" />
{label}
</div>
<span className="text-sm font-medium text-foreground">{value}</span>
</div>
);
}
export default function HealthPage() {
const { data: liveness, isFetching: l1, dataUpdatedAt: t1, refetch: r1, error: e1 } = useQuery({
queryKey: ['health-liveness'],
queryFn: () => fetchHealth('/health'),
refetchInterval: 30_000,
retry: 1,
});
const { data: readiness, isFetching: l2, dataUpdatedAt: t2, refetch: r2, error: e2 } = useQuery({
queryKey: ['health-readiness'],
queryFn: () => fetchHealth('/health/ready'),
refetchInterval: 30_000,
retry: 1,
});
const { data: info, isFetching: l3, dataUpdatedAt: t3, refetch: r3 } = useQuery({
queryKey: ['health-info'],
queryFn: () => fetchHealth('/health/info'),
refetchInterval: 60_000,
retry: 1,
});
const livenessOk = e1 ? false : liveness ? liveness.status === 'ok' : null;
const readinessOk = e2 ? false : readiness ? readiness.status === 'ok' : null;
const dbOk = readiness?.checks?.database?.status === 'ok';
const overallOk =
livenessOk === null || readinessOk === null ? null : livenessOk && readinessOk;
const fmt = (ms: number) => new Date(ms).toLocaleTimeString();
const fmtUptime = (s: number) => {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h}h ${m}m ${sec}s`;
};
function refetchAll() { r1(); r2(); r3(); }
return (
<div className="space-y-6 animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">System Health</h1>
<p className="text-muted-foreground text-sm mt-0.5">
Live status of the EDR Passenger API auto-refreshes every 30 s
</p>
</div>
<button
onClick={refetchAll}
className={cn(
'btn btn-secondary flex items-center gap-2 text-sm',
(l1 || l2 || l3) && 'opacity-60 cursor-not-allowed',
)}
disabled={l1 || l2 || l3}
>
<RefreshCw className={cn('h-4 w-4', (l1 || l2 || l3) && 'animate-spin')} />
Refresh
</button>
</div>
{/* Overall banner */}
<div
className={cn(
'flex items-center gap-4 rounded-xl border px-6 py-4 transition-colors',
overallOk === null
? 'border-border bg-muted/40'
: overallOk
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800/40 dark:bg-emerald-900/10'
: 'border-red-200 bg-red-50 dark:border-red-800/40 dark:bg-red-900/10',
)}
>
{overallOk === null ? (
<Activity className="h-7 w-7 text-muted-foreground animate-pulse" />
) : overallOk ? (
<CheckCircle2 className="h-7 w-7 text-emerald-600 dark:text-emerald-400" />
) : (
<XCircle className="h-7 w-7 text-red-600 dark:text-red-400" />
)}
<div>
<p className={cn(
'font-semibold text-base',
overallOk === null
? 'text-muted-foreground'
: overallOk
? 'text-emerald-700 dark:text-emerald-300'
: 'text-red-700 dark:text-red-300',
)}>
{overallOk === null
? 'Checking system status…'
: overallOk
? 'All systems operational'
: 'Service degraded'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
EDR Passenger API · {API_URL}
</p>
</div>
</div>
{/* Probe cards */}
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{/* Liveness */}
<div className="card space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Activity className="h-5 w-5 text-green-700 dark:text-green-400" />
</div>
<div>
<p className="font-semibold text-foreground text-sm">Liveness</p>
<p className="text-xs text-muted-foreground">GET /health</p>
</div>
</div>
<StatusDot ok={livenessOk} />
</div>
<div className="flex items-center justify-between">
<StatusBadge ok={livenessOk} />
{t1 > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> {fmt(t1)}
</span>
)}
</div>
{e1 && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono break-all">
{(e1 as any)?.message ?? 'Request failed'}
</p>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
Confirms the process is alive and accepting connections. Checked every 30 s.
</p>
</div>
{/* Readiness */}
<div className="card space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<Database className="h-5 w-5 text-blue-700 dark:text-blue-400" />
</div>
<div>
<p className="font-semibold text-foreground text-sm">Readiness</p>
<p className="text-xs text-muted-foreground">GET /health/ready</p>
</div>
</div>
<StatusDot ok={readinessOk} />
</div>
<div className="flex items-center justify-between">
<StatusBadge ok={readinessOk} />
{t2 > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> {fmt(t2)}
</span>
)}
</div>
{e2 && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono break-all">
{(e2 as any)?.message ?? 'Request failed'}
</p>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
Runs a live database ping. Latency:{' '}
<span className="font-medium text-foreground">
{readiness?.checks?.database?.latencyMs != null
? `${readiness.checks.database.latencyMs} ms`
: '—'}
</span>
</p>
</div>
{/* App Info */}
<div className="card space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Info className="h-5 w-5 text-purple-700 dark:text-purple-400" />
</div>
<div>
<p className="font-semibold text-foreground text-sm">App Info</p>
<p className="text-xs text-muted-foreground">GET /health/info</p>
</div>
</div>
<StatusDot ok={info ? true : null} />
</div>
<div className="flex items-center justify-between">
<StatusBadge ok={info ? true : null} />
{t3 > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> {fmt(t3)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Version, environment, and uptime. Refreshed every 60 s.
</p>
</div>
</div>
{/* Detailed panels */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Database detail */}
<div className="card space-y-1">
<div className="flex items-center gap-2 mb-4">
<Database className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Database</h2>
<div className="ml-auto">
<StatusBadge ok={readiness ? dbOk : null} />
</div>
</div>
<MetricRow
label="Status"
value={readiness?.checks?.database?.status ?? '—'}
icon={CheckCircle2}
/>
<MetricRow
label="Latency"
value={
readiness?.checks?.database?.latencyMs != null
? `${readiness.checks.database.latencyMs} ms`
: '—'
}
icon={Activity}
/>
{readiness?.checks?.database?.error && (
<div className="mt-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/40 px-4 py-3">
<p className="text-xs text-red-700 dark:text-red-300 font-mono break-all">
{readiness.checks.database.error}
</p>
</div>
)}
</div>
{/* App info detail */}
<div className="card space-y-1">
<div className="flex items-center gap-2 mb-4">
<Server className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Application</h2>
</div>
<MetricRow label="Name" value={info?.name ?? '—'} icon={Server} />
<MetricRow label="Version" value={info?.version ?? '—'} icon={Info} />
<MetricRow label="Environment" value={info?.environment ?? '—'} icon={Globe} />
<MetricRow
label="Uptime"
value={info?.uptimeSeconds != null ? fmtUptime(info.uptimeSeconds) : '—'}
icon={Cpu}
/>
<MetricRow
label="Last checked"
value={t3 > 0 ? new Date(t3).toLocaleString() : '—'}
icon={Clock}
/>
</div>
</div>
{/* Rate limits reference */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Rate Limits</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-2 pr-6 font-medium">Tier</th>
<th className="pb-2 pr-6 font-medium">Limit</th>
<th className="pb-2 font-medium">Applied to</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{[
{ tier: 'auth', limit: '5 req / min', scope: '/auth, /fayda/verification' },
{ tier: 'strict', limit: '20 req / min', scope: '/bookings, /passengers, /payments, /wallet' },
{ tier: 'default', limit: '100 req / min', scope: 'All other endpoints' },
{ tier: 'exempt', limit: '—', scope: '/health/*, /internal/payments/*, payment webhooks' },
].map((row) => (
<tr key={row.tier}>
<td className="py-2.5 pr-6">
<span className={cn(
'edr-badge',
row.tier === 'auth' && 'edr-badge-danger',
row.tier === 'strict' && 'edr-badge-warning',
row.tier === 'default' && 'edr-badge-success',
row.tier === 'exempt' && 'edr-badge-info',
)}>
{row.tier}
</span>
</td>
<td className="py-2.5 pr-6 font-mono text-foreground">{row.limit}</td>
<td className="py-2.5 text-muted-foreground">{row.scope}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,44 +1,186 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, MapPin } from 'lucide-react';
import { Train, MapPin, Users, Clock } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { liveApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function Page() {
const [filters, setFilters] = useState({ search: '' });
export default function LiveTrackingPage() {
const { data: trips, isLoading } = useQuery({
queryKey: ['live-trips'],
queryFn: liveApi.getTrips,
refetchInterval: 30000,
});
const { data: crowdSignals } = useQuery({
queryKey: ['crowd-signals'],
queryFn: liveApi.getCrowdSignals,
refetchInterval: 60000,
});
const tripsArray = Array.isArray(trips) ? trips : (trips as any)?.items || [];
const signalsArray = Array.isArray(crowdSignals) ? crowdSignals : (crowdSignals as any)?.items || [];
const columns = [
{
key: 'train',
label: 'Train',
render: (trip: any) => (
<div className="flex items-center gap-2">
<Train className="h-4 w-4 text-emerald-600 shrink-0" />
<div>
<div className="font-medium">{trip.schedule?.train?.name || trip.trainName || 'N/A'}</div>
<div className="text-xs text-muted-foreground font-mono">{trip.schedule?.train?.number || trip.trainNumber || ''}</div>
</div>
</div>
),
},
{
key: 'route',
label: 'Route',
render: (trip: any) => (
<div className="text-sm">
<div>{trip.schedule?.originStation?.name || trip.origin || 'N/A'}</div>
<div className="text-muted-foreground"> {trip.schedule?.destinationStation?.name || trip.destination || 'N/A'}</div>
</div>
),
},
{
key: 'location',
label: 'Location',
render: (trip: any) => (
<div className="flex items-center gap-1 text-sm">
<MapPin className="h-3 w-3 text-muted-foreground shrink-0" />
<span>{trip.currentStation?.name || trip.lastKnownStation || 'En route'}</span>
</div>
),
},
{
key: 'departure',
label: 'Departure',
render: (trip: any) => (
<span className="text-sm font-mono">
{trip.schedule?.departureAt ? formatDateTime(trip.schedule.departureAt) : 'N/A'}
</span>
),
},
{
key: 'passengers',
label: 'Passengers',
render: (trip: any) => (
<div className="flex items-center gap-1">
<Users className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{trip.passengerCount ?? trip.bookedSeats ?? '—'}</span>
</div>
),
},
{
key: 'status',
label: 'Status',
render: (trip: any) => (
<Badge variant="status" status={
trip.status === 'EN_ROUTE' ? 'CONFIRMED' :
trip.status === 'DELAYED' ? 'PENDING' :
trip.status === 'CANCELLED' ? 'CANCELLED' : 'CONFIRMED'
}>
{(trip.status || 'SCHEDULED').replace(/_/g, ' ')}
</Badge>
),
},
{
key: 'delay',
label: 'Delay',
render: (trip: any) => {
const delay = trip.delayMinutes ?? trip.delay;
if (!delay) return <span className="text-sm text-emerald-600">On time</span>;
return (
<div className="flex items-center gap-1 text-amber-600">
<Clock className="h-3 w-3" />
<span className="text-sm font-medium">+{delay} min</span>
</div>
);
},
},
];
const crowdColumns = [
{
key: 'station',
label: 'Station',
render: (s: any) => <span className="font-medium">{s.station?.name || s.stationName || 'N/A'}</span>,
},
{
key: 'level',
label: 'Crowd Level',
render: (s: any) => (
<Badge variant="status" status={
s.level === 'HIGH' || s.crowdLevel === 'HIGH' ? 'CANCELLED' :
s.level === 'MEDIUM' || s.crowdLevel === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'
}>
{s.level || s.crowdLevel || 'LOW'}
</Badge>
),
},
{
key: 'count',
label: 'Estimated Count',
render: (s: any) => <span className="text-sm">{s.estimatedCount ?? s.count ?? '—'}</span>,
},
{
key: 'updatedAt',
label: 'Last Updated',
render: (s: any) => <span className="text-sm text-muted-foreground">{s.updatedAt ? formatDateTime(s.updatedAt) : 'N/A'}</span>,
},
];
const enRoute = tripsArray.filter((t: any) => t.status === 'EN_ROUTE' || t.status === 'BOARDING').length;
const delayed = tripsArray.filter((t: any) => t.delayMinutes > 0 || t.delay > 0).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Live Tracking</h1>
<p className="text-muted-foreground">Real-time train tracking and status</p>
</div>
<ActionButton icon={Plus}>Add New</ActionButton>
<div>
<h1 className="text-2xl font-bold text-foreground">Live Tracking</h1>
<p className="text-muted-foreground">Real-time train tracking and station crowd signals</p>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card">
<p className="text-sm text-muted-foreground">Active Trips</p>
<p className="text-2xl font-bold mt-1">{tripsArray.length}</p>
</div>
<div className="card">
<p className="text-sm text-muted-foreground">En Route / Boarding</p>
<p className="text-2xl font-bold mt-1 text-emerald-600">{enRoute}</p>
</div>
<div className="card">
<p className="text-sm text-muted-foreground">Delayed</p>
<p className="text-2xl font-bold mt-1 text-amber-600">{delayed}</p>
</div>
</div>
<div className="card">
<p className="text-center text-muted-foreground py-12">
Live Tracking module - Connect to API endpoint
</p>
<h2 className="text-lg font-semibold mb-4">Active Trips</h2>
<DataTable
data={tripsArray}
columns={columns}
loading={isLoading}
emptyMessage="No active trips found"
/>
</div>
{signalsArray.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold mb-4">Station Crowd Signals</h2>
<DataTable
data={signalsArray}
columns={crowdColumns}
loading={false}
emptyMessage="No crowd signals"
/>
</div>
)}
</div>
);
}

View File

@@ -1,21 +1,102 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Send } from 'lucide-react';
import Table from '@/components/ui/Table';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
const templates = [
{ id: '1', name: 'Booking Confirmation', channel: 'EMAIL', subject: 'Your booking is confirmed', active: true },
{ id: '2', name: 'Payment Receipt', channel: 'EMAIL', subject: 'Payment received', active: true },
{ id: '3', name: 'Trip Reminder', channel: 'SMS', body: 'Your trip is tomorrow', active: true },
{ id: '4', name: 'Cancellation Notice', channel: 'PUSH', body: 'Your booking has been cancelled', active: false },
];
import ActionButton from '@/components/ui/ActionButton';
import { notificationsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function NotificationsPage() {
const [showModal, setShowModal] = useState(false);
const [activeTab, setActiveTab] = useState<'templates' | 'send'>('templates');
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates');
const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' });
const [sendError, setSendError] = useState<string | null>(null);
const [sendSuccess, setSendSuccess] = useState(false);
const queryClient = useQueryClient();
const { data: templates, isLoading: templatesLoading } = useQuery({
queryKey: ['notification-templates'],
queryFn: notificationsApi.getTemplates,
enabled: activeTab === 'templates',
});
const { data: historyData, isLoading: historyLoading } = useQuery({
queryKey: ['notification-history'],
queryFn: () => notificationsApi.getHistory({ take: 50 }),
enabled: activeTab === 'history',
});
const createTemplateMutation = useMutation({
mutationFn: notificationsApi.createTemplate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
setShowModal(false);
},
});
const sendMutation = useMutation({
mutationFn: notificationsApi.send,
onSuccess: () => {
setSendSuccess(true);
setSendError(null);
setSendForm({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' });
setTimeout(() => setSendSuccess(false), 4000);
},
onError: (e: any) => setSendError(e?.response?.data?.message || e?.message || 'Failed to send'),
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
setSendError(null);
await sendMutation.mutateAsync(sendForm);
};
const templatesArray = Array.isArray(templates) ? templates : (templates as any)?.items || [];
const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || [];
const templateColumns = [
{ key: 'name', label: 'Template Name', render: (t: any) => <span className="font-medium">{t.name}</span> },
{ key: 'channel', label: 'Channel', render: (t: any) => <Badge>{t.channel || t.type}</Badge> },
{
key: 'subject',
label: 'Subject / Body',
render: (t: any) => <span className="text-sm text-muted-foreground truncate max-w-xs block">{t.subject || t.body || t.content || '—'}</span>,
},
{
key: 'active',
label: 'Status',
render: (t: any) => (
<Badge variant="status" status={t.isActive !== false ? 'CONFIRMED' : 'CANCELLED'}>
{t.isActive !== false ? 'Active' : 'Inactive'}
</Badge>
),
},
{ key: 'createdAt', label: 'Created', render: (t: any) => <span className="text-sm text-muted-foreground">{formatDateTime(t.createdAt)}</span> },
];
const historyColumns = [
{ key: 'channel', label: 'Channel', render: (n: any) => <Badge>{n.channel || n.type || 'EMAIL'}</Badge> },
{ key: 'title', label: 'Title', render: (n: any) => <span className="font-medium">{n.title || n.subject || '—'}</span> },
{
key: 'recipient',
label: 'Recipient',
render: (n: any) => <span className="text-sm text-muted-foreground">{n.passenger?.email || n.passenger?.phone || n.recipientEmail || n.recipientPhone || '—'}</span>,
},
{
key: 'status',
label: 'Status',
render: (n: any) => (
<Badge variant="status" status={n.status === 'SENT' || n.isRead !== undefined ? 'CONFIRMED' : 'PENDING'}>
{n.status || 'SENT'}
</Badge>
),
},
{ key: 'createdAt', label: 'Sent At', render: (n: any) => <span className="text-sm text-muted-foreground">{formatDateTime(n.createdAt)}</span> },
];
return (
<div className="space-y-6">
@@ -24,109 +105,122 @@ export default function NotificationsPage() {
<h1 className="text-2xl font-bold text-foreground">Notifications</h1>
<p className="text-muted-foreground">Manage notification templates and send messages</p>
</div>
<button onClick={() => setShowModal(true)} className="btn btn-primary flex items-center gap-2">
<Plus className="h-4 w-4" />
New Template
</button>
{activeTab === 'templates' && (
<ActionButton icon={Plus} onClick={() => setShowModal(true)}>New Template</ActionButton>
)}
</div>
<div className="flex gap-2 border-b border-border">
<button
onClick={() => setActiveTab('templates')}
className={`px-4 py-2 font-medium ${activeTab === 'templates' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Templates
</button>
<button
onClick={() => setActiveTab('send')}
className={`px-4 py-2 font-medium ${activeTab === 'send' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Send Notification
</button>
{(['templates', 'send', 'history'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 font-medium capitalize ${activeTab === tab ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
{tab === 'send' ? 'Send Notification' : tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{activeTab === 'templates' ? (
{activeTab === 'templates' && (
<div className="card">
<Table
data={templates}
columns={[
{ key: 'name', label: 'Template Name' },
{ key: 'channel', label: 'Channel', render: (item) => (
<Badge>{item.channel}</Badge>
)},
{ key: 'subject', label: 'Subject/Body', render: (item) => item.subject || item.body },
{ key: 'active', label: 'Status', render: (item) => (
<Badge variant="status" status={item.active ? 'CONFIRMED' : 'CANCELLED'}>
{item.active ? 'Active' : 'Inactive'}
</Badge>
)},
]}
<DataTable
data={templatesArray}
columns={templateColumns}
loading={templatesLoading}
emptyMessage="No notification templates found"
/>
</div>
) : (
)}
{activeTab === 'send' && (
<div className="card">
<form className="space-y-4">
<div>
<label className="label">Recipient Type</label>
<select className="input">
<option>All Passengers</option>
<option>Specific Passenger</option>
<option>Booking Reference</option>
</select>
{sendSuccess && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">
Notification sent successfully
</div>
<div>
<label className="label">Channel</label>
<select className="input">
<option>Email</option>
<option>SMS</option>
<option>Push Notification</option>
</select>
)}
<form onSubmit={handleSend} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Recipient Type</label>
<select className="input" value={sendForm.recipientType} onChange={(e) => setSendForm({ ...sendForm, recipientType: e.target.value })}>
<option value="ALL">All Passengers</option>
<option value="SPECIFIC">Specific Passenger</option>
<option value="BOOKING">Booking Reference</option>
</select>
</div>
<div>
<label className="label">Channel</label>
<select className="input" value={sendForm.channel} onChange={(e) => setSendForm({ ...sendForm, channel: e.target.value })}>
<option value="EMAIL">Email</option>
<option value="SMS">SMS</option>
<option value="PUSH">Push Notification</option>
</select>
</div>
</div>
<div>
<label className="label">Subject</label>
<input type="text" className="input" placeholder="Enter subject" />
<input type="text" className="input" placeholder="Enter subject" value={sendForm.subject} onChange={(e) => setSendForm({ ...sendForm, subject: e.target.value })} required />
</div>
<div>
<label className="label">Message</label>
<textarea className="input" rows={6} placeholder="Enter message content"></textarea>
<textarea className="input" rows={6} placeholder="Enter message content" value={sendForm.message} onChange={(e) => setSendForm({ ...sendForm, message: e.target.value })} required />
</div>
<button type="submit" className="btn btn-primary flex items-center gap-2">
<Send className="h-4 w-4" />
Send Notification
</button>
{sendError && <p className="text-sm text-red-600 dark:text-red-400">{sendError}</p>}
<ActionButton type="submit" icon={Send} loading={sendMutation.isPending}>Send Notification</ActionButton>
</form>
</div>
)}
{activeTab === 'history' && (
<div className="card">
<DataTable
data={historyArray}
columns={historyColumns}
loading={historyLoading}
emptyMessage="No notification history found"
/>
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Notification Template">
<form className="space-y-4">
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await createTemplateMutation.mutateAsync({
name: fd.get('name') as string,
channel: fd.get('channel') as string,
subject: fd.get('subject') as string,
body: fd.get('body') as string,
});
}}
className="space-y-4"
>
<div>
<label className="label">Template Name</label>
<input type="text" className="input" placeholder="Enter template name" />
<label className="label">Template Name *</label>
<input type="text" name="name" className="input" placeholder="e.g., Booking Confirmation" required />
</div>
<div>
<label className="label">Channel</label>
<select className="input">
<option>Email</option>
<option>SMS</option>
<option>Push Notification</option>
<select name="channel" className="input">
<option value="EMAIL">Email</option>
<option value="SMS">SMS</option>
<option value="PUSH">Push Notification</option>
</select>
</div>
<div>
<label className="label">Subject</label>
<input type="text" className="input" placeholder="Enter subject" />
<input type="text" name="subject" className="input" placeholder="Enter subject" />
</div>
<div>
<label className="label">Body</label>
<textarea className="input" rows={4} placeholder="Enter template body"></textarea>
<label className="label">Body *</label>
<textarea name="body" className="input" rows={4} placeholder="Enter template body" required />
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="btn btn-secondary">
Cancel
</button>
<button type="submit" className="btn btn-primary">
Create Template
</button>
<ActionButton type="button" variant="secondary" onClick={() => setShowModal(false)}>Cancel</ActionButton>
<ActionButton type="submit" loading={createTemplateMutation.isPending}>Create Template</ActionButton>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PackagesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,536 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const toLocal = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
const emptyForm = {
code: '', name: '', description: '',
outboundScheduleId: '', returnScheduleId: '',
originStationId: '', destinationStationId: '',
boardingTime: '', departureTime: '', arrivalTime: '',
totalCapacity: '', coachConfiguration: '',
includedServices: '', busTransferIncluded: 'false', busTransferRoute: '',
validFrom: '', validUntil: '',
};
export default function PackagesPage() {
const [page] = useState(1);
const [form, setForm] = useState(emptyForm);
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewPackage, setViewPackage] = useState<any>(null);
const [activateConfirm, setActivateConfirm] = useState<any>(null);
const [tiersPackage, setTiersPackage] = useState<any>(null);
const [editingTier, setEditingTier] = useState<any>(null);
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
const [tierError, setTierError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['packages', page],
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
});
const { data: stationsData } = useQuery({
queryKey: ['stations-all'],
queryFn: () => stationsApi.getAll(),
});
const { data: schedulesData } = useQuery({
queryKey: ['schedules-all'],
queryFn: () => schedulesApi.getAll(),
});
const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []);
const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []);
const createMutation = useMutation({
mutationFn: packagesApi.create,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setModalMode(null); },
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => packagesApi.update(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setModalMode(null); },
});
const activateMutation = useMutation({
mutationFn: packagesApi.activate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setActivateConfirm(null); },
});
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({
mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data),
onSuccess: (newTier) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: [...(prev.priceTiers || []), newTier] } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setEditingTier(null);
setTierForm(emptyTierForm);
setTierError(null);
},
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to add tier'),
});
const updateTierMutation = useMutation({
mutationFn: ({ tierId, data }: { tierId: string; data: any }) => packagesApi.updateTier(tierId, data),
onSuccess: (updated) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: prev.priceTiers.map((t: any) => t.id === updated.id ? updated : t) } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setEditingTier(null);
setTierForm(emptyTierForm);
setTierError(null);
},
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to update tier'),
});
const deleteTierMutation = useMutation({
mutationFn: (tierId: string) => packagesApi.deleteTier(tierId),
onSuccess: (_, tierId) => {
setTiersPackage((prev: any) => prev ? { ...prev, priceTiers: prev.priceTiers.filter((t: any) => t.id !== tierId) } : prev);
queryClient.invalidateQueries({ queryKey: ['packages'] });
setDeleteTierConfirm(null);
},
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
});
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
setTierError(null);
};
const handleTierSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) };
if (editingTier) {
await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload });
} else {
await addTierMutation.mutateAsync({ packageId: tiersPackage.id, data: payload });
}
};
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setModalMode('create');
};
const openEdit = (pkg: any) => {
setForm({
code: pkg.code ?? '',
name: pkg.name ?? '',
description: pkg.description ?? '',
outboundScheduleId: pkg.outboundScheduleId ?? '',
returnScheduleId: pkg.returnScheduleId ?? '',
originStationId: pkg.originStationId ?? '',
destinationStationId: pkg.destinationStationId ?? '',
boardingTime: toLocal(pkg.boardingTime),
departureTime: toLocal(pkg.departureTime),
arrivalTime: toLocal(pkg.arrivalTime),
totalCapacity: String(pkg.totalCapacity ?? ''),
coachConfiguration: pkg.coachConfiguration ?? '',
includedServices: (pkg.includedServices ?? []).join('\n'),
busTransferIncluded: pkg.busTransferIncluded ? 'true' : 'false',
busTransferRoute: pkg.busTransferRoute ?? '',
validFrom: toLocal(pkg.validFrom),
validUntil: toLocal(pkg.validUntil),
});
setEditingId(pkg.id);
setModalMode('edit');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const services = form.includedServices.split('\n').map((s) => s.trim()).filter(Boolean);
const payload = {
code: form.code,
name: form.name,
description: form.description || undefined,
outboundScheduleId: form.outboundScheduleId,
returnScheduleId: form.returnScheduleId,
originStationId: form.originStationId,
destinationStationId: form.destinationStationId,
boardingTime: form.boardingTime,
departureTime: form.departureTime,
arrivalTime: form.arrivalTime,
totalCapacity: parseInt(form.totalCapacity),
coachConfiguration: form.coachConfiguration || undefined,
includedServices: services,
busTransferIncluded: form.busTransferIncluded === 'true',
busTransferRoute: form.busTransferRoute || undefined,
validFrom: form.validFrom,
validUntil: form.validUntil,
priceTiers: [],
};
if (modalMode === 'edit' && editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const field = (key: keyof typeof form) => ({
value: form[key],
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value })),
});
const scheduleLabel = (s: any) => {
const from = s.originStation?.name ?? s.originStationId ?? '?';
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString() : '';
return `${from}${to}${dep ? ' | ' + dep : ''}`;
};
const columns = [
{ key: 'code', label: 'Package',
render: (pkg: any) => (
<div className="text-sm">
<div>{pkg.code}</div>
<div className="text-muted-foreground">{pkg.name}</div>
</div>
), },
{
key: 'capacity', label: 'Capacity',
render: (p: any) => (
<div className="text-sm">
<div>{p.totalCapacity} seats</div>
{p.priceTiers?.length > 0 && <div className="text-muted-foreground">{p.priceTiers.length} tiers</div>}
</div>
),
},
{
key: 'validity', label: 'Valid Period',
render: (p: any) => (
<div className="text-sm">
<div>{new Date(p.validFrom).toLocaleDateString()}</div>
<div className="text-muted-foreground"> {new Date(p.validUntil).toLocaleDateString()}</div>
</div>
),
},
{
key: 'status', label: 'Status',
render: (p: any) => (
<Badge variant="status" status={p.status === 'ACTIVE' ? 'CONFIRMED' : p.status === 'DRAFT' ? 'PENDING' : 'CANCELLED'}>
{p.status}
</Badge>
),
},
{
key: 'createdAt', label: 'Created',
render: (p: any) => <span className="text-sm text-muted-foreground">{formatDateTime(p.createdAt)}</span>,
},
];
const actions = [
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Tiers', icon: Layers, variant: 'secondary' as const,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
},
{
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p),
hidden: (p: any) => p.status === 'ACTIVE',
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Packages</h1>
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
</div>
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No packages found"
/>
{/* View Modal */}
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
{viewPackage && (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
<div className="col-span-2"><span className="label">Name</span><p className="font-medium">{viewPackage.name}</p></div>
{viewPackage.description && <div className="col-span-2"><span className="label">Description</span><p>{viewPackage.description}</p></div>}
<div><span className="label">Total Capacity</span><p>{viewPackage.totalCapacity}</p></div>
<div><span className="label">Coach Config</span><p>{viewPackage.coachConfiguration || '—'}</p></div>
<div><span className="label">Boarding</span><p>{formatDateTime(viewPackage.boardingTime)}</p></div>
<div><span className="label">Departure</span><p>{formatDateTime(viewPackage.departureTime)}</p></div>
<div><span className="label">Arrival</span><p>{formatDateTime(viewPackage.arrivalTime)}</p></div>
<div><span className="label">Bus Transfer</span><p>{viewPackage.busTransferIncluded ? `Yes — ${viewPackage.busTransferRoute || ''}` : 'No'}</p></div>
<div><span className="label">Valid From</span><p>{new Date(viewPackage.validFrom).toLocaleDateString()}</p></div>
<div><span className="label">Valid Until</span><p>{new Date(viewPackage.validUntil).toLocaleDateString()}</p></div>
</div>
{viewPackage.includedServices?.length > 0 && (
<div>
<span className="label">Included Services</span>
<ul className="mt-1 list-disc list-inside space-y-0.5">
{viewPackage.includedServices.map((s: string, i: number) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{viewPackage.priceTiers?.length > 0 && (
<div>
<span className="label">Price Tiers</span>
<div className="mt-1 space-y-1">
{viewPackage.priceTiers.map((t: any) => (
<div key={t.id} className="flex justify-between rounded border border-border px-3 py-2">
<span>{t.label} ({t.seatType})</span>
<span className="font-semibold">{formatCurrency(t.priceMinor, 'ETB')} {t.bookedSeats}/{t.availableSeats} booked</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
{/* Activate Confirmation */}
<ConfirmDialog
isOpen={!!activateConfirm}
onClose={() => setActivateConfirm(null)}
onConfirm={() => activateMutation.mutate(activateConfirm.id)}
title="Activate Package"
message={`Activate "${activateConfirm?.name}"? It will become publicly available for booking.`}
confirmText="Activate"
isDanger={false}
/>
{/* Tiers Modal */}
<Modal isOpen={!!tiersPackage} onClose={() => { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg">
{tiersPackage && (
<div className="space-y-4">
{tierError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
{tierError}
</div>
)}
{/* Existing tiers list */}
<div className="space-y-2">
{(tiersPackage.priceTiers ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">No tiers yet. Add one below.</p>
)}
{(tiersPackage.priceTiers ?? []).map((t: any) => (
<div key={t.id} className="flex items-center justify-between rounded border border-border px-3 py-2">
<div>
<span className="font-medium text-sm">{t.label}</span>
<span className="ml-2 text-xs text-muted-foreground">({t.seatType})</span>
<div className="text-xs text-muted-foreground mt-0.5">
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
</div>
</div>
<div className="flex gap-2">
<ActionButton variant="secondary" icon={Edit} onClick={() => openEditTier(t)}>Edit</ActionButton>
<ActionButton
variant="danger" icon={Trash2}
onClick={() => { setTierError(null); setDeleteTierConfirm(t); }}
disabled={t.bookedSeats > 0}
>Delete</ActionButton>
</div>
</div>
))}
</div>
{/* Add / Edit tier form */}
<div className="border-t border-border pt-4">
<p className="text-sm font-semibold mb-3">{editingTier ? 'Edit Tier' : 'Add New Tier'}</p>
<form onSubmit={handleTierSubmit} className="grid grid-cols-2 gap-3">
<div>
<label className="label">Seat Type *</label>
<input className="input" placeholder="e.g., HSC" required
value={tierForm.seatType} onChange={(e) => setTierForm((f) => ({ ...f, seatType: e.target.value }))} />
</div>
<div>
<label className="label">Label *</label>
<input className="input" placeholder="e.g., Regular Seat (HSC)" required
value={tierForm.label} onChange={(e) => setTierForm((f) => ({ ...f, label: e.target.value }))} />
</div>
<div>
<label className="label">Price (minor/cents) *</label>
<input type="number" min="0" className="input" placeholder="e.g., 1023200" required
value={tierForm.priceMinor} onChange={(e) => setTierForm((f) => ({ ...f, priceMinor: e.target.value }))} />
</div>
<div>
<label className="label">Available Seats *</label>
<input type="number" min="0" className="input" placeholder="e.g., 100" required
value={tierForm.availableSeats} onChange={(e) => setTierForm((f) => ({ ...f, availableSeats: e.target.value }))} />
</div>
<div className="col-span-2 flex justify-end gap-2">
{editingTier && (
<ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton>
)}
<ActionButton type="submit" loading={addTierMutation.isPending || updateTierMutation.isPending}>
{editingTier ? 'Update Tier' : 'Add Tier'}
</ActionButton>
</div>
</form>
</div>
</div>
)}
</Modal>
{/* Delete Tier Confirmation */}
<ConfirmDialog
isOpen={!!deleteTierConfirm}
onClose={() => { setDeleteTierConfirm(null); setTierError(null); }}
onConfirm={() => deleteTierMutation.mutate(deleteTierConfirm.id)}
title="Delete Tier"
message={`Delete tier "${deleteTierConfirm?.label}"? This cannot be undone.`}
confirmText="Delete" isDanger
isLoading={deleteTierMutation.isPending}
error={tierError ?? undefined}
/>
{/* Create / Edit Modal */}
<Modal
isOpen={modalMode !== null}
onClose={() => setModalMode(null)}
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Code *</label>
<input className="input uppercase" placeholder="e.g., KULUBBI-2025" required {...field('code')} />
</div>
<div>
<label className="label">Name *</label>
<input className="input" placeholder="Package name" required {...field('name')} />
</div>
<div className="col-span-2">
<label className="label">Description</label>
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
</div>
<div>
<label className="label">Origin Station *</label>
<select className="input" required {...field('originStationId')}>
<option value="">Select station</option>
{stations.map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.code})</option>
))}
</select>
</div>
<div>
<label className="label">Destination Station *</label>
<select className="input" required {...field('destinationStationId')}>
<option value="">Select station</option>
{stations.map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.code})</option>
))}
</select>
</div>
<div>
<label className="label">Outbound Schedule *</label>
<select className="input" required {...field('outboundScheduleId')}>
<option value="">Select schedule</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>{scheduleLabel(s)}</option>
))}
</select>
</div>
<div>
<label className="label">Return Schedule *</label>
<select className="input" required {...field('returnScheduleId')}>
<option value="">Select schedule</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>{scheduleLabel(s)}</option>
))}
</select>
</div>
<div>
<label className="label">Boarding Time *</label>
<input type="datetime-local" className="input" required {...field('boardingTime')} />
</div>
<div>
<label className="label">Departure Time *</label>
<input type="datetime-local" className="input" required {...field('departureTime')} />
</div>
<div>
<label className="label">Arrival Time *</label>
<input type="datetime-local" className="input" required {...field('arrivalTime')} />
</div>
<div>
<label className="label">Total Capacity *</label>
<input type="number" min="1" className="input" placeholder="e.g., 912" required {...field('totalCapacity')} />
</div>
<div>
<label className="label">Coach Configuration</label>
<input className="input" placeholder="e.g., 1 Loco + 6HSC" {...field('coachConfiguration')} />
</div>
<div>
<label className="label">Bus Transfer</label>
<select className="input" {...field('busTransferIncluded')}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</div>
{form.busTransferIncluded === 'true' && (
<div className="col-span-2">
<label className="label">Bus Transfer Route</label>
<input className="input" placeholder="e.g., Addis Ababa → Kulubbi" {...field('busTransferRoute')} />
</div>
)}
<div>
<label className="label">Valid From *</label>
<input type="datetime-local" className="input" required {...field('validFrom')} />
</div>
<div>
<label className="label">Valid Until *</label>
<input type="datetime-local" className="input" required {...field('validUntil')} />
</div>
<div className="col-span-2">
<label className="label">Included Services (one per line)</label>
<textarea className="input" rows={3} placeholder={'Round trip train ticket\nBus transfer\nMeal on board'} {...field('includedServices')} />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -37,6 +37,7 @@ export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -48,7 +49,14 @@ export default function PassengersPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] });
setDeleteConfirm({ isOpen: false, passenger: null });
setDeleteError(null);
},
onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger');
},
});
const { data, isLoading, error } = useQuery({
@@ -125,7 +133,7 @@ export default function PassengersPage() {
const actions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -165,17 +173,18 @@ export default function PassengersPage() {
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })}
onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }}
onConfirm={async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
}}
title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete" isDanger
isLoading={deleteMutation.isPending}
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
error={deleteError ?? undefined}
/>
{/* Passenger Details Modal */}

View File

@@ -9,6 +9,7 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configMessage, setConfigMessage] = useState('');
@@ -19,6 +20,7 @@ export default function SettingsPage() {
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
})
.catch(() => {})
.finally(() => setConfigLoading(false));
@@ -28,7 +30,10 @@ export default function SettingsPage() {
setConfigSaving(true);
setConfigMessage('');
try {
await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes });
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours,
});
setConfigMessage('Saved successfully.');
} catch {
setConfigMessage('Failed to save.');
@@ -176,23 +181,42 @@ export default function SettingsPage() {
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="hold-duration">
Seat Hold Duration (minutes)
</label>
<input
id="hold-duration"
type="number"
min="1"
max="60"
className="input"
value={seatHoldMinutes}
onChange={(e) => setSeatHoldMinutes(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
</p>
</div>
<>
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="hold-duration">
Seat Hold Duration (minutes)
</label>
<input
id="hold-duration"
type="number"
min="1"
max="60"
className="input"
value={seatHoldMinutes}
onChange={(e) => setSeatHoldMinutes(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
</p>
</div>
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="hold-cutoff">
Hold Cutoff Before Departure (hours)
</label>
<input
id="hold-cutoff"
type="number"
min="0"
max="24"
className="input"
value={holdCutoffHours}
onChange={(e) => setHoldCutoffHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.
</p>
</div>
</>
)}
<div className="flex items-center gap-3">
<button

View File

@@ -10,6 +10,18 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { usersApi, BackofficeUser } from '@/lib/api/users';
const ROLES = [
{ key: 'edr_passenger_backoffice_admin', label: 'Backoffice Admin' },
{ key: 'edr_passenger_backoffice_staff', label: 'Backoffice Staff' },
{ key: 'edr_passenger_agent', label: 'Agent' },
{ key: 'edr_passenger_finance', label: 'Finance' },
{ key: 'superadmin', label: 'Super Admin' },
];
function roleLabel(key: string) {
return ROLES.find((r) => r.key === key)?.label ?? key;
}
export default function UserManagementPage() {
const [filters, setFilters] = useState({ search: '', role: '', status: '', page: 1, pageSize: 10 });
const [showModal, setShowModal] = useState(false);
@@ -118,7 +130,7 @@ export default function UserManagementPage() {
label: 'Role',
render: (user: BackofficeUser) => (
<Badge variant="status" status={user.role}>
{user.role}
{roleLabel(user.role)}
</Badge>
),
},
@@ -206,10 +218,9 @@ export default function UserManagementPage() {
onChange={(e) => setFilters({ ...filters, role: e.target.value, page: 1 })}
>
<option value="">All Roles</option>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
{ROLES.map((r) => (
<option key={r.key} value={r.key}>{r.label}</option>
))}
</select>
</div>
<div>
@@ -299,7 +310,7 @@ export default function UserManagementPage() {
title={`${editingUser ? 'Edit' : 'Add'} User`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<form key={editingUser?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Full Name *</label>
@@ -329,13 +340,12 @@ export default function UserManagementPage() {
<select
name="role"
className="input"
defaultValue={editingUser?.role || 'STAFF'}
defaultValue={editingUser?.role || 'edr_passenger_backoffice_staff'}
required
>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
{ROLES.map((r) => (
<option key={r.key} value={r.key}>{r.label}</option>
))}
</select>
</div>
<div>

View File

@@ -2,20 +2,21 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, ListCollapse, Trash2, Printer } from 'lucide-react';
import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
@@ -24,6 +25,15 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
// Excess baggage state
const [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessAgentId, setExcessAgentId] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
@@ -82,18 +92,48 @@ export default function TicketsPage() {
},
});
const excessMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: (result) => {
setExcessResult(result);
setExcessError(null);
},
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
const openExcessModal = (ticket: any) => {
setExcessTicket(ticket);
setExcessKg('');
setExcessCollectCash(false);
setExcessAgentId('');
setExcessError(null);
setExcessResult(null);
setExcessModalOpen(true);
};
const handleExcessSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!excessTicket) return;
await excessMutation.mutateAsync({
bookingId: excessTicket.bookingId,
agentId: excessAgentId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
});
};
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setDeleteConfirmOpen(false);
setTicketToDelete(null);
setDeleteError(null);
setSuccessMessage('Ticket deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteConfirmOpen(false);
alert(`Error: ${error.message || 'Failed to delete ticket'}`);
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete ticket');
},
});
@@ -176,6 +216,7 @@ export default function TicketsPage() {
const handleDeleteClick = (ticket: any) => {
setTicketToDelete(ticket);
setDeleteError(null);
setDeleteConfirmOpen(true);
};
@@ -370,6 +411,13 @@ export default function TicketsPage() {
variant: 'danger' as const,
icon: Trash2,
},
{
label: 'Excess Baggage',
onClick: openExcessModal,
variant: 'secondary' as const,
icon: Package,
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
},
];
const stations = stationsData?.items || [];
@@ -532,7 +580,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); setDeleteError(null); }}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -540,6 +588,7 @@ export default function TicketsPage() {
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
error={deleteError ?? undefined}
/>
{/* Ticket Details Modal */}
@@ -659,6 +708,78 @@ export default function TicketsPage() {
})()}
</Modal>
{/* Excess Baggage Modal */}
<Modal
isOpen={excessModalOpen}
onClose={() => { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
title="Log Excess Baggage"
size="sm"
>
{excessResult ? (
<div className="space-y-4">
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{excessResult.status === 'CASH_COLLECTED'
? '✓ Cash collected and charge recorded.'
: `✓ Payment link sent to passenger. Charge: ${formatCurrency(excessResult.totalMinor, excessResult.currency)}`}
</div>
<div className="text-sm space-y-1">
<div className="flex justify-between"><span className="text-muted-foreground">Excess weight</span><span className="font-medium">{excessResult.excessWeightKg} kg</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Amount due</span><span className="font-semibold">{formatCurrency(excessResult.totalMinor, excessResult.currency)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Status</span><span className="font-medium">{excessResult.status}</span></div>
</div>
<div className="flex justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setExcessModalOpen(false); setExcessResult(null); }}>Close</ActionButton>
</div>
</div>
) : (
<form onSubmit={handleExcessSubmit} className="space-y-4">
<div className="text-sm text-muted-foreground">
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
</div>
<div>
<label className="label">Agent ID</label>
<input
className="input"
placeholder="Enter your agent ID"
value={excessAgentId}
onChange={(e) => setExcessAgentId(e.target.value)}
required
/>
</div>
<div>
<label className="label">Excess weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 7"
value={excessKg}
onChange={(e) => setExcessKg(e.target.value)}
required
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={excessCollectCash}
onChange={(e) => setExcessCollectCash(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">Collect cash now (no payment link sent)</span>
</label>
{excessError && (
<p className="text-sm text-red-600 dark:text-red-400">{excessError}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setExcessModalOpen(false)}>Cancel</ActionButton>
<ActionButton icon={Package} loading={excessMutation.isPending} type="submit">
{excessCollectCash ? 'Collect Cash' : 'Send Payment Link'}
</ActionButton>
</div>
</form>
)}
</Modal>
{/* Export Modal */}
<Modal
isOpen={exportModalOpen}

View File

@@ -33,7 +33,8 @@ import {
Sun,
Armchair,
Grid3x3,
Banknote
Banknote,
Activity,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -52,6 +53,13 @@ const navigationSections = [
{ name: 'Bookings', href: '/bookings', icon: Ticket },
{ name: 'Passengers', href: '/passengers', icon: Users },
{ name: 'Tickets', href: '/tickets', icon: FileText },
{ name: 'Lugagges', href: '/excess-baggage', icon: Banknote },
]
},
{
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package },
]
},
{
@@ -69,41 +77,42 @@ const navigationSections = [
{
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Fares', href: '/pricing', icon: DollarSign },
{ name: 'Currencies', href: '/currencies', icon: Banknote },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Promo Codes', href: '/promos', icon: Gift },
]
},
{
title: 'Customer Services',
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift },
{ name: 'Support Center', href: '/support', icon: MessageSquare },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Promos', href: '/promos', icon: Gift },
]
},
// {
// title: 'Customer Services',
// items: [
// { name: 'Loyalty Program', href: '/loyalty', icon: Gift },
// { name: 'Support Center', href: '/support', icon: MessageSquare },
// { name: 'Notifications', href: '/notifications', icon: Bell },
// ]
// },
{
title: 'Security & Compliance',
items: [
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
{ name: 'Logs', href: '/audit', icon: AlertTriangle },
{ name: 'Fraud', href: '/fraud', icon: Shield },
{ name: 'Verifayda', href: '/verifayda', icon: UserCheck },
]
},
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3 },
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText },
{ name: 'Operational', href: '/operational-reports', icon: FileText },
]
},
{
title: 'System',
items: [
{ name: 'Agent Operations', href: '/agents', icon: Briefcase },
{ name: 'User Management', href: '/settings/users', icon: Users },
{ name: 'Agents', href: '/agents', icon: Briefcase },
{ name: 'Users', href: '/settings/users', icon: Users },
{ name: 'Settings', href: '/settings', icon: Settings },
{ name: 'Health', href: '/health', icon: Activity },
]
}
];

View File

@@ -18,8 +18,8 @@ interface ActionButtonProps {
const variants = {
primary: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
secondary: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
danger: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
secondary: 'bg-muted text-foreground hover:bg-muted/80 border border-border/60',
danger: 'bg-red-600 text-white hover:bg-red-700 shadow-sm',
success: 'bg-green-600 text-white hover:bg-green-700 shadow-sm',
export: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
};

View File

@@ -1,8 +1,9 @@
'use client';
import { AlertCircle, AlertTriangle } from 'lucide-react';
import Modal from './Modal';
import ActionButton from './ActionButton'
import { useEffect } from 'react';
import { AlertTriangle, AlertCircle, Info, Loader2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import ActionButton from './ActionButton';
interface ConfirmDialogProps {
isOpen: boolean;
@@ -31,43 +32,99 @@ export default function ConfirmDialog({
warning,
error,
}: ConfirmDialogProps) {
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
useEffect(() => {
document.body.style.overflow = isOpen ? 'hidden' : 'unset';
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="space-y-4">
<div className="flex gap-3">
{isDanger && (
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<p className="text-foreground">{message}</p>
</div>
{warning && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900 dark:text-amber-200 text-sm">Warning</p>
<p className="text-amber-800 dark:text-amber-300 text-sm mt-1">{warning}</p>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={!isLoading ? onClose : undefined}
/>
{/* Panel */}
<div className={cn(
'relative w-full max-w-md z-10 rounded-2xl shadow-2xl border border-border/50 bg-background animate-fade-up overflow-hidden',
)}>
{/* Top accent */}
<div className={cn(
'h-1 w-full',
isDanger
? 'bg-gradient-to-r from-red-500 to-red-400'
: 'bg-gradient-to-r from-[rgb(20,113,76)] to-emerald-400',
)} />
{/* Header */}
<div className="flex items-start justify-between px-6 pt-5 pb-4">
<div className="flex items-center gap-3">
<div className={cn(
'flex items-center justify-center w-10 h-10 rounded-full shrink-0',
isDanger
? 'bg-red-100 dark:bg-red-900/30'
: 'bg-emerald-100 dark:bg-emerald-900/30',
)}>
{isDanger
? <AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
: <Info className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
}
</div>
<h2 className="text-base font-semibold text-foreground">{title}</h2>
</div>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
{!isLoading && (
<button
onClick={onClose}
className="flex items-center justify-center w-7 h-7 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors ml-2 shrink-0"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Body */}
<div className="px-6 pb-2 space-y-3">
<p className="text-sm text-muted-foreground leading-relaxed">{message}</p>
{warning && (
<div className="flex gap-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">{warning}</p>
</div>
)}
{error && (
<div className="flex gap-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-xs text-red-800 dark:text-red-300 leading-relaxed">{error}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t border-border/60 mt-3">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}
</ActionButton>
<ActionButton
variant={isDanger ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
{confirmText}
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,7 +1,8 @@
'use client';
import { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ModalProps {
isOpen: boolean;
@@ -19,33 +20,57 @@ const sizeClasses = {
};
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
document.body.style.overflow = isOpen ? 'hidden' : 'unset';
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background shadow-xl flex flex-col max-h-[90vh]`}>
<div className="sticky top-0 bg-background border-b border-muted px-6 py-4 flex items-center justify-between z-10">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Panel */}
<div
ref={panelRef}
className={cn(
'relative w-full flex flex-col max-h-[90vh] z-10',
'bg-background rounded-2xl shadow-2xl border border-border/50',
'animate-fade-up',
sizeClasses[size],
)}
>
{/* Accent bar */}
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-[rgb(20,113,76)] to-emerald-400" />
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
<h2 className="text-lg font-semibold text-foreground tracking-tight">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 hover:bg-muted"
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close"
>
<X className="h-5 w-5 text-muted-foreground" />
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5">
{children}
</div>
</div>

View File

@@ -374,6 +374,43 @@ export const reportsApi = {
},
};
// Packages API
export const packagesApi = {
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/packages/all${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/packages/${id}`),
create: (data: any) => apiClient.post<any>('/packages', data),
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
};
// Excess Baggage API
export const excessBaggageApi = {
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
getCharge: (id: string) => apiClient.get<any>(`/agents/excess-baggage/${id}`),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/agents/excess-baggage${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
return Array.isArray(response) ? { items: response } : response;
},
resendLink: (id: string) => apiClient.post<any>(`/agents/excess-baggage/${id}/resend`, {}),
waive: (id: string, data: any) => apiClient.patch<any>(`/agents/excess-baggage/${id}/waive`, data),
};
// System Config API
export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/system-config'),

View File

@@ -21,18 +21,11 @@ export const usersApi = {
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
const response = await apiClient.get<any>(`/auth/users?${params.toString()}`);
// Handle different response formats
if (response && typeof response === 'object') {
if ('items' in response) {
return response as { items: BackofficeUser[]; total: number };
}
if (Array.isArray(response)) {
return { items: response as BackofficeUser[], total: response.length };
}
if (response && typeof response === 'object' && 'items' in response) {
return response as { items: BackofficeUser[]; total: number };
}
return { items: Array.isArray(response) ? response : [], total: 0 };
if (Array.isArray(response)) return { items: response as BackofficeUser[], total: response.length };
return { items: [], total: 0 };
},
getById: (id: string) => {

View File

@@ -62,11 +62,11 @@
@layer utilities {
@keyframes fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translateY(16px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-fade-up {
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
animation: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
@@ -122,23 +122,32 @@
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition: border-color 150ms, box-shadow 150ms;
}
.input:focus {
outline: none;
ring: 2px hsl(var(--ring));
border-color: transparent;
border-color: rgb(20, 113, 76);
box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.15);
}
.input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.label {
display: block;
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
margin-bottom: 0.25rem;
color: hsl(var(--muted-foreground));
margin-bottom: 0.375rem;
letter-spacing: 0.01em;
}
.gradient-edr {

View File

@@ -79,6 +79,12 @@
- `apps/edr-passenger-web/backoffice/next.config.js` (MODIFIED)
- `DEPLOYMENT.md` (MODIFIED)
## Backoffice Pages — Completion
19. All backoffice pages are now fully implemented and connected to the API:
- `live/page.tsx` — replaced stub with real LiveTrackingPage using `liveApi` (trips, crowd signals, delay/status stats)
- `notifications/page.tsx` — replaced hardcoded mock + broken `Table` import with real page using `notificationsApi` (templates list, send form, notification history tab)
## Next Actions
1. Run full CI on all target branches (`main`, `dev`, `staging`) and verify matrix job behavior.

37
pnpm-lock.yaml generated
View File

@@ -465,6 +465,9 @@ importers:
'@nestjs/swagger':
specifier: ^7.4.0
version: 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm':
specifier: ^11.0.1
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
@@ -480,6 +483,9 @@ importers:
'@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.3.tgz(c97ba831ddde82920910406ab5262991)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
amqp-connection-manager:
specifier: ^5.0.0
version: 5.0.0(amqplib@2.0.1)
@@ -489,6 +495,9 @@ importers:
axios:
specifier: ^1.7.7
version: 1.17.0
bcrypt:
specifier: ^6.0.0
version: 6.0.0
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@@ -4149,6 +4158,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -5199,6 +5211,16 @@ packages:
resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==}
engines: {node: '>=10.0.0'}
batch@0.6.1:
resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -15519,6 +15541,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 20.19.42
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -16692,6 +16718,17 @@ snapshots:
basic-ftp@5.3.1: {}
batch@0.6.1: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.8.0
node-gyp-build: 4.8.4
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2

View File

@@ -0,0 +1,59 @@
# @tria-plc/iamapi-common
Standalone IAM NestJS module extracted from the Smart Office monorepo. Provides authentication, user management, organization structure, and record elements functionality as a reusable package for Tria PLC platform services.
## Installation
```bash
npm install @tria-plc/iamapi-common --registry=https://npm.pkg.github.com
```
Or with a project-level `.npmrc`:
```
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
```
## Usage
```typescript
import { IamModule } from '@tria-plc/iamapi-common';
@Module({
imports: [IamModule],
})
export class AppModule {}
```
The `IamModule` registers all sub-modules: `AuthModule`, `UserModule`, `OrganizationStructureModule`, and `RecordElementModule`.
Database configuration, migrations, and seeding remain the responsibility of the consuming application.
## Peer Dependencies
- `@nestjs/common` ^11
- `@nestjs/core` ^11
- `@nestjs/jwt` ^11
- `@nestjs/passport` ^11
- `@nestjs/typeorm` ^11
- `@smart-office/be` ^1.0.0
- `reflect-metadata` ^0.2
- `rxjs` ^7.8
- `typeorm` ^0.3
## Publishing
Releases are published automatically to GitHub Packages when a version tag (`v*`) is pushed:
```bash
git tag v1.0.1
git push origin v1.0.1
```
## Development
```bash
pnpm install
pnpm build
```

View File

@@ -0,0 +1,124 @@
{
"name": "@tria-plc/iamapi-common",
"version": "0.7.3",
"description": "Standalone IAM NestJS module for Tria PLC platform services",
"repository": {
"type": "git",
"url": "https://github.com/Tria-plc/iamapi-common.git"
},
"author": "Tria PLC",
"license": "UNLICENSED",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"scripts"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js"
},
"./package.json": "./package.json",
"./*.js": "./dist/*.js",
"./*": {
"types": "./dist/*.d.ts",
"require": "./dist/*.js"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/*.d.ts",
"./dist/*/index.d.ts"
]
}
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"engines": {
"node": ">=20"
},
"scripts": {
"prepare": "husky || true",
"test": "echo 'No tests configured' && exit 0",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"build": "npm run clean && nest build",
"prepublishOnly": "npm run build",
"typeorm:cli": "node ./scripts/typeorm-cli.cjs",
"migration:run": "npm run typeorm:cli migration:run",
"migration:generate": "npm run build && npm run typeorm:cli migration:generate src/db/migrations/IamApiCommonMigration",
"migration:create": "npm run typeorm:cli migration:create",
"migration:revert": "npm run typeorm:cli migration:revert"
},
"peerDependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/microservices": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"@nestjs/throttler": "^6.0.0",
"@nestjs/typeorm": "^11.0.0",
"@tria-plc/api-common": "*",
"axios": "^1.9.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"typeorm": "^0.3.0"
},
"dependencies": {
"api-common": "1.2.2",
"argon2": "^0.43.0",
"dotenv": "^17.4.2",
"ethiopian-date": "^0.0.6",
"file-type": "^21.3.0",
"jose": "^5.3.0",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.12.9",
"nestjs-minio-client": "^2.2.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"typeorm-extension": "^3.9.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@nestjs/axios": "^4.0.0",
"@nestjs/cli": "^11.0.7",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/microservices": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/schematics": "^10.2.3",
"@nestjs/swagger": "^11.0.0",
"@nestjs/testing": "^11.0.8",
"@nestjs/throttler": "^6.0.0",
"@nestjs/typeorm": "^11.0.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.8",
"@semantic-release/npm": "^13.1.5",
"@tria-plc/api-common": "^1.4.3",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.17.32",
"@types/passport-jwt": "^4.0.1",
"axios": "^1.9.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"husky": "^9.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"semantic-release": "^25.0.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.0",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const { spawnSync } = require("child_process");
const path = require("path");
const tsNodeBin = require.resolve("ts-node/dist/bin.js");
const typeormCli = require.resolve("typeorm/cli.js");
const dataSource = path.resolve(__dirname, "../src/typeorm.config.ts");
const args = [
tsNodeBin,
"-r",
"tsconfig-paths/register",
typeormCli,
"-d",
dataSource,
...process.argv.slice(2),
];
const result = spawnSync(process.execPath, args, { stdio: "inherit" });
process.exit(result.status ?? 1);