Initial commit of edr-passenger-api alpha version

This commit is contained in:
Stephanos A
2026-05-13 16:58:49 +03:00
parent 199a3eba11
commit 39ba561d8f
113 changed files with 3602 additions and 1035 deletions

View File

@@ -1,17 +1,23 @@
# App
NODE_ENV=development
PORT=3002
PORT=4000
# Database
DB_HOST=localhost
DB_PORT=5434
DB_NAME=edr_passenger
DB_USER=postgres
DB_PASSWORD=
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger
# JWT (provided by external auth package — placeholder only)
JWT_SECRET=
# CORS
FRONTEND_URL=http://localhost:3000
PORTAL_URL=http://localhost:3001
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# JWT
JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d
# SendGrid
SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
# Telebirr
TELEBIRR_API_URL=https://api.telebirr.com
TELEBIRR_APP_ID=
TELEBIRR_APP_KEY=

View File

@@ -0,0 +1 @@
module.exports = require('@edr/eslint-config/nestjs');

View File

@@ -10,6 +10,7 @@ RUN pnpm install --frozen-lockfile --filter @edr/passenger-api...
FROM deps AS build
COPY apps/edr-passenger-api ./apps/edr-passenger-api
RUN pnpm --filter @edr/passenger-api run prisma:generate
RUN pnpm --filter @edr/passenger-api build
FROM node:20-alpine AS runtime
@@ -21,6 +22,7 @@ COPY --from=deps /app/node_modules ./../../node_modules
COPY --from=deps /app/apps/edr-passenger-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-passenger-api/dist ./dist
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
COPY --from=build /app/apps/edr-passenger-api/prisma ./prisma
EXPOSE 3002
EXPOSE 4000
CMD ["node", "dist/main.js"]

View File

@@ -1,8 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"plugins": ["@nestjs/swagger"],
"tsConfigPath": "tsconfig.build.json",
"watchAssets": true
}
}

View File

@@ -1,69 +1,71 @@
{
"name": "@edr/passenger-api",
"version": "0.0.0",
"version": "1.0.0",
"private": true,
"description": "EDR Passenger Management API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/config": "^4.0.0",
"@nestjs/microservices": "^11.0.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^5.8.0",
"@sendgrid/mail": "^8.1.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.13.0",
"class-validator": "^0.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.2",
"@nestjs/testing": "^10.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^5.8.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}

View File

@@ -0,0 +1,690 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('PASSENGER', 'ADMIN', 'STAFF');
-- CreateEnum
CREATE TYPE "TripStatus" AS ENUM ('SCHEDULED', 'BOARDING', 'EN_ROUTE', 'ARRIVED', 'CANCELLED', 'DELAYED');
-- CreateEnum
CREATE TYPE "SeatKind" AS ENUM ('STANDARD', 'PREMIUM', 'ACCESSIBLE');
-- CreateEnum
CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'HELD', 'BOOKED', 'BLOCKED');
-- CreateEnum
CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY', 'BUSINESS', 'FIRST');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "WalletLedgerType" AS ENUM ('CREDIT', 'DEBIT');
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('BOOKING', 'PAYMENT', 'DISRUPTION', 'PROMOTION', 'SYSTEM');
-- CreateEnum
CREATE TYPE "StopStatus" AS ENUM ('COMPLETED', 'APPROACHING', 'CURRENT', 'UPCOMING');
-- CreateEnum
CREATE TYPE "SupportConversationStatus" AS ENUM ('OPEN', 'RESOLVED', 'CLOSED');
-- CreateEnum
CREATE TYPE "SupportSender" AS ENUM ('USER', 'BOT', 'AGENT');
-- CreateEnum
CREATE TYPE "LoyaltyTier" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM');
-- CreateEnum
CREATE TYPE "LoyaltyLedgerReason" AS ENUM ('TRIP_COMPLETED', 'REWARD_REDEEMED', 'PROMO_BONUS', 'MANUAL_ADJUSTMENT', 'EXPIRY');
-- CreateEnum
CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Passenger" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TravelerProfile" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"relationship" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"nationalId" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TravelerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Station" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"city" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
"lat" DECIMAL(9,6) NOT NULL,
"lng" DECIMAL(9,6) NOT NULL,
CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrainService" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"name" TEXT NOT NULL,
"operatorId" TEXT NOT NULL DEFAULT 'op_edr',
CONSTRAINT "TrainService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Trip" (
"id" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"departureAt" TIMESTAMP(3) NOT NULL,
"arrivalAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"status" "TripStatus" NOT NULL DEFAULT 'SCHEDULED',
"stopsCount" INTEGER NOT NULL DEFAULT 0,
"onTimePercent" INTEGER NOT NULL DEFAULT 100,
"carbonRating" TEXT NOT NULL DEFAULT 'A',
CONSTRAINT "Trip_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TripStopTime" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"plannedArrivalAt" TIMESTAMP(3),
"plannedDepartureAt" TIMESTAMP(3),
"actualArrivalAt" TIMESTAMP(3),
"status" "StopStatus" NOT NULL DEFAULT 'UPCOMING',
CONSTRAINT "TripStopTime_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TripLiveStatus" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"state" TEXT NOT NULL,
"currentLocationLabel" TEXT,
"progressPercent" INTEGER NOT NULL DEFAULT 0,
"delayMinutes" INTEGER NOT NULL DEFAULT 0,
"currentSpeedKph" INTEGER,
"platformLabel" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TripLiveStatus_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Coach" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"serviceClass" "ServiceClass" NOT NULL,
CONSTRAINT "Coach_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Seat" (
"id" TEXT NOT NULL,
"coachId" TEXT NOT NULL,
"row" INTEGER NOT NULL,
"col" TEXT NOT NULL,
"label" TEXT NOT NULL,
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
"heldUntil" TIMESTAMP(3),
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeatHold" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"seatIds" TEXT[],
"fareQuoteId" TEXT,
"passengerId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SeatHold_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FareRule" (
"id" TEXT NOT NULL,
"tripId" TEXT,
"route" TEXT,
"serviceClass" "ServiceClass" NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"refundable" BOOLEAN NOT NULL DEFAULT true,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"totalMinor" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingSeat" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"idDocumentType" TEXT,
"idDocumentNumber" TEXT,
CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "PaymentMethodType" NOT NULL,
"displayName" TEXT NOT NULL,
"maskedHint" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentIntent" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" "PaymentMethodType" NOT NULL,
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"clientAction" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentIntent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
"qrPayload" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"validatedAt" TIMESTAMP(3),
"validatorId" TEXT,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"pointsBalance" INTEGER NOT NULL DEFAULT 0,
"tier" "LoyaltyTier" NOT NULL DEFAULT 'BRONZE',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyLedgerEntry" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"delta" INTEGER NOT NULL,
"reason" "LoyaltyLedgerReason" NOT NULL,
"bookingId" TEXT,
"balanceAfter" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyLedgerEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyReward" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"costPoints" INTEGER NOT NULL,
"available" BOOLEAN NOT NULL DEFAULT true,
"description" TEXT,
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WalletAccount" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"balanceMinor" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WalletAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WalletLedgerEntry" (
"id" TEXT NOT NULL,
"walletId" TEXT NOT NULL,
"type" "WalletLedgerType" NOT NULL,
"amountMinor" INTEGER NOT NULL,
"balanceAfterMinor" INTEGER NOT NULL,
"description" TEXT NOT NULL,
"relatedBookingId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WalletLedgerEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"category" "NotificationCategory" NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT false,
"deepLink" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Promotion" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"subtitle" TEXT,
"code" TEXT NOT NULL,
"percentOff" INTEGER,
"amountOffMinor" INTEGER,
"validUntil" TIMESTAMP(3) NOT NULL,
"ctaLabel" TEXT,
"deepLink" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StationCrowdSignal" (
"id" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"level" TEXT NOT NULL,
"label" TEXT NOT NULL,
"statusLabel" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StationCrowdSignal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WeatherAlert" (
"id" TEXT NOT NULL,
"region" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeatherAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "MenuCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuItem" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"available" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FoodOrder" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"status" "FoodOrderStatus" NOT NULL DEFAULT 'PENDING',
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FoodOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FoodOrderItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"menuItemId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"lineTotalMinor" INTEGER NOT NULL,
CONSTRAINT "FoodOrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaqCategory" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"iconKey" TEXT,
CONSTRAINT "FaqCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaqArticle" (
"id" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"answerMarkdown" TEXT NOT NULL,
"rank" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "FaqArticle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SupportConversation" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "SupportConversationStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupportConversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SupportMessage" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"sender" "SupportSender" NOT NULL,
"text" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPreferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"smsEnabled" BOOLEAN NOT NULL DEFAULT false,
"promosEnabled" BOOLEAN NOT NULL DEFAULT true,
"biometricEnabled" BOOLEAN NOT NULL DEFAULT false,
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
"defaultPaymentMethodId" TEXT,
"autoDownloadTickets" BOOLEAN NOT NULL DEFAULT true,
"dataSharing" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT NOT NULL DEFAULT 'en',
"darkMode" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "UserPreferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"platform" "DevicePlatform" NOT NULL,
"name" TEXT NOT NULL,
"pushToken" TEXT,
"trusted" BOOLEAN NOT NULL DEFAULT false,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedRoute" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"fromStationId" TEXT NOT NULL,
"toStationId" TEXT NOT NULL,
"fromName" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"tripCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedRoute_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
-- CreateIndex
CREATE UNIQUE INDEX "TrainService_number_key" ON "TrainService"("number");
-- CreateIndex
CREATE UNIQUE INDEX "TripStopTime_tripId_sequence_key" ON "TripStopTime"("tripId", "sequence");
-- CreateIndex
CREATE UNIQUE INDEX "TripLiveStatus_tripId_key" ON "TripLiveStatus"("tripId");
-- CreateIndex
CREATE UNIQUE INDEX "Coach_tripId_label_key" ON "Coach"("tripId", "label");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
-- CreateIndex
CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
-- CreateIndex
CREATE UNIQUE INDEX "WalletAccount_passengerId_key" ON "WalletAccount"("passengerId");
-- CreateIndex
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "TrainService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Seat" ADD CONSTRAINT "Seat_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletAccount" ADD CONSTRAINT "WalletAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=reset-admin.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"reset-admin.d.ts","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,49 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt"));
const prisma = new client_1.PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('admin123', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash, role: 'ADMIN' },
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
});
console.log('✅ Admin ready:', user.email);
}
main().catch(console.error).finally(() => prisma.$disconnect());
//# sourceMappingURL=reset-admin.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"reset-admin.js","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE;QAC1C,MAAM,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;QACvC,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;KACxH,CAAC,CAAC;IACH,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5C,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}

View File

@@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('admin123', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash, role: 'ADMIN' },
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
});
console.log('✅ Admin ready:', user.email);
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,573 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
PASSENGER
ADMIN
STAFF
}
enum TripStatus {
SCHEDULED
BOARDING
EN_ROUTE
ARRIVED
CANCELLED
DELAYED
}
enum SeatKind {
STANDARD
PREMIUM
ACCESSIBLE
}
enum SeatStatus {
AVAILABLE
HELD
BOOKED
BLOCKED
}
enum ServiceClass {
ECONOMY
BUSINESS
FIRST
}
enum BookingStatus {
DRAFT
PENDING_PAYMENT
CONFIRMED
CANCELLED
COMPLETED
NO_SHOW
}
enum PaymentMethodType {
TELEBIRR
CBE_BIRR
EBIRR
CARD
WALLET
}
enum PaymentIntentStatus {
REQUIRES_ACTION
PROCESSING
SUCCEEDED
FAILED
CANCELLED
}
enum WalletLedgerType {
CREDIT
DEBIT
}
enum NotificationCategory {
BOOKING
PAYMENT
DISRUPTION
PROMOTION
SYSTEM
}
enum StopStatus {
COMPLETED
APPROACHING
CURRENT
UPCOMING
}
enum SupportConversationStatus {
OPEN
RESOLVED
CLOSED
}
enum SupportSender {
USER
BOT
AGENT
}
enum LoyaltyTier {
BRONZE
SILVER
GOLD
PLATINUM
}
enum LoyaltyLedgerReason {
TRIP_COMPLETED
REWARD_REDEEMED
PROMO_BONUS
MANUAL_ADJUSTMENT
EXPIRY
}
enum FoodOrderStatus {
PENDING
PREPARING
READY
DELIVERED
CANCELLED
}
enum DevicePlatform {
IOS
ANDROID
WEB
}
model User {
id String @id @default(uuid())
email String @unique
phone String @unique
fullName String
passwordHash String
role UserRole @default(PASSENGER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger?
sessions Session[]
devices Device[]
preferences UserPreferences?
}
model Session {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Passenger {
id String @id @default(uuid())
userId String @unique
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
bookings Booking[]
loyalty LoyaltyAccount?
wallet WalletAccount?
notifications Notification[]
travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
}
model TravelerProfile {
id String @id @default(uuid())
passengerId String
fullName String
relationship String
dateOfBirth DateTime?
nationalId String?
notes String?
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
}
model Station {
id String @id @default(uuid())
code String @unique
name String
city String
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originTrips Trip[] @relation("OriginTrips")
destinationTrips Trip[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
}
model TrainService {
id String @id @default(uuid())
number String @unique
name String
operatorId String @default("op_edr")
trips Trip[]
}
model Trip {
id String @id @default(uuid())
serviceId String
originStationId String
destinationStationId String
departureAt DateTime
arrivalAt DateTime
durationMinutes Int
status TripStatus @default(SCHEDULED)
stopsCount Int @default(0)
onTimePercent Int @default(100)
carbonRating String @default("A")
service TrainService @relation(fields: [serviceId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coaches Coach[]
bookings Booking[]
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
}
model TripStopTime {
id String @id @default(uuid())
tripId String
stationId String
sequence Int
plannedArrivalAt DateTime?
plannedDepartureAt DateTime?
actualArrivalAt DateTime?
status StopStatus @default(UPCOMING)
trip Trip @relation(fields: [tripId], references: [id])
station Station @relation(fields: [stationId], references: [id])
@@unique([tripId, sequence])
}
model TripLiveStatus {
id String @id @default(uuid())
tripId String @unique
state String
currentLocationLabel String?
progressPercent Int @default(0)
delayMinutes Int @default(0)
currentSpeedKph Int?
platformLabel String?
updatedAt DateTime @updatedAt
trip Trip @relation(fields: [tripId], references: [id])
}
model Coach {
id String @id @default(uuid())
tripId String
label String
serviceClass ServiceClass
trip Trip @relation(fields: [tripId], references: [id])
seats Seat[]
@@unique([tripId, label])
}
model Seat {
id String @id @default(uuid())
coachId String
row Int
col String
label String
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
@@unique([coachId, row, col])
}
model SeatHold {
id String @id @default(uuid())
tripId String
seatIds String[]
fareQuoteId String?
passengerId String
expiresAt DateTime
createdAt DateTime @default(now())
}
model FareRule {
id String @id @default(uuid())
tripId String?
route String?
serviceClass ServiceClass
baseFareMinor Int
currency String @default("ETB")
refundable Boolean @default(true)
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
}
model Booking {
id String @id @default(uuid())
bookingRef String @unique
passengerId String
tripId String
status BookingStatus @default(DRAFT)
currency String @default("ETB")
totalMinor Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
trip Trip @relation(fields: [tripId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
foodOrders FoodOrder[]
}
model BookingSeat {
id String @id @default(uuid())
bookingId String
seatId String
passengerName String
idDocumentType String?
idDocumentNumber String?
booking Booking @relation(fields: [bookingId], references: [id])
seat Seat @relation(fields: [seatId], references: [id])
}
model PaymentMethod {
id String @id @default(uuid())
userId String
type PaymentMethodType
displayName String
maskedHint String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
}
model PaymentIntent {
id String @id @default(uuid())
bookingId String @unique
amountMinor Int
currency String @default("ETB")
method PaymentMethodType
status PaymentIntentStatus @default(REQUIRES_ACTION)
providerRef String?
clientAction Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id])
}
model Ticket {
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("CONFIRMED")
qrPayload String
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
}
model LoyaltyAccount {
id String @id @default(uuid())
passengerId String @unique
pointsBalance Int @default(0)
tier LoyaltyTier @default(BRONZE)
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
ledger LoyaltyLedgerEntry[]
rewards LoyaltyReward[]
}
model LoyaltyLedgerEntry {
id String @id @default(uuid())
accountId String
delta Int
reason LoyaltyLedgerReason
bookingId String?
balanceAfter Int
createdAt DateTime @default(now())
account LoyaltyAccount @relation(fields: [accountId], references: [id])
}
model LoyaltyReward {
id String @id @default(uuid())
accountId String
title String
costPoints Int
available Boolean @default(true)
description String?
account LoyaltyAccount @relation(fields: [accountId], references: [id])
}
model WalletAccount {
id String @id @default(uuid())
passengerId String @unique
balanceMinor Int @default(0)
currency String @default("ETB")
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
ledger WalletLedgerEntry[]
}
model WalletLedgerEntry {
id String @id @default(uuid())
walletId String
type WalletLedgerType
amountMinor Int
balanceAfterMinor Int
description String
relatedBookingId String?
createdAt DateTime @default(now())
wallet WalletAccount @relation(fields: [walletId], references: [id])
}
model Notification {
id String @id @default(uuid())
passengerId String
title String
body String
category NotificationCategory
read Boolean @default(false)
deepLink String?
metadata Json?
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
}
model Promotion {
id String @id @default(uuid())
title String
subtitle String?
code String @unique
percentOff Int?
amountOffMinor Int?
validUntil DateTime
ctaLabel String?
deepLink String?
active Boolean @default(true)
createdAt DateTime @default(now())
}
model StationCrowdSignal {
id String @id @default(uuid())
stationId String
level String
label String
statusLabel String
updatedAt DateTime @updatedAt
station Station @relation(fields: [stationId], references: [id])
}
model WeatherAlert {
id String @id @default(uuid())
region String
severity String
title String
message String
validUntil DateTime
createdAt DateTime @default(now())
}
model MenuCategory {
id String @id @default(uuid())
name String
items MenuItem[]
}
model MenuItem {
id String @id @default(uuid())
tripId String
categoryId String
name String
priceMinor Int
currency String @default("ETB")
available Boolean @default(true)
trip Trip @relation(fields: [tripId], references: [id])
category MenuCategory @relation(fields: [categoryId], references: [id])
}
model FoodOrder {
id String @id @default(uuid())
bookingId String
status FoodOrderStatus @default(PENDING)
totalMinor Int
currency String @default("ETB")
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
items FoodOrderItem[]
}
model FoodOrderItem {
id String @id @default(uuid())
orderId String
menuItemId String
name String
quantity Int
lineTotalMinor Int
order FoodOrder @relation(fields: [orderId], references: [id])
}
model FaqCategory {
id String @id @default(uuid())
title String
iconKey String?
articles FaqArticle[]
}
model FaqArticle {
id String @id @default(uuid())
categoryId String
question String
answerMarkdown String
rank Int @default(0)
category FaqCategory @relation(fields: [categoryId], references: [id])
}
model SupportConversation {
id String @id @default(uuid())
userId String
status SupportConversationStatus @default(OPEN)
createdAt DateTime @default(now())
messages SupportMessage[]
}
model SupportMessage {
id String @id @default(uuid())
conversationId String
sender SupportSender
text String
createdAt DateTime @default(now())
conversation SupportConversation @relation(fields: [conversationId], references: [id])
}
model UserPreferences {
id String @id @default(uuid())
userId String @unique
pushEnabled Boolean @default(true)
emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false)
promosEnabled Boolean @default(true)
biometricEnabled Boolean @default(false)
twoFactorEnabled Boolean @default(false)
defaultPaymentMethodId String?
autoDownloadTickets Boolean @default(true)
dataSharing Boolean @default(false)
locale String @default("en")
darkMode Boolean @default(false)
user User @relation(fields: [userId], references: [id])
}
model Device {
id String @id @default(uuid())
userId String
platform DevicePlatform
name String
pushToken String?
trusted Boolean @default(false)
lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
model SavedRoute {
id String @id @default(uuid())
passengerId String
fromStationId String
toStationId String
fromName String
toName String
tripCount Int @default(0)
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
}

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=seed.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,73 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt"));
const prisma = new client_1.PrismaClient();
async function main() {
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
const trip = await prisma.trip.create({
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
});
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']]) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
const seats = [];
for (let row = 1; row <= 10; row++) {
for (const col of ['A', 'B', 'C', 'D'])
seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
}
await prisma.seat.createMany({ data: seats });
}
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
const hash = await bcrypt.hash('password123', 10);
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
console.log('✅ Seed complete');
}
main().catch(console.error).finally(() => prisma.$disconnect());
//# sourceMappingURL=seed.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"seed.js","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAChL,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAC/K,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,iBAAiB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAE3M,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE,CAAC,CAAC;IAE3I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,IAAI,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,EAAE,eAAe,EAAE,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE;KAC/N,CAAC,CAAC;IAEH,KAAK,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,CAAC,CAAU,EAAE,CAAC;QAC1E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QACjG,MAAM,KAAK,GAAG,EAAE,CAAC;QACjB,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;YACnC,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,EAAE,EAAE,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAE9I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IAC3M,IAAI,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAClF,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACzE,MAAM,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;QACjH,MAAM,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;IACnG,CAAC;IACD,MAAM,MAAM,CAAC,eAAe,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAE7G,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAEjT,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtL,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,sBAAsB,EAAE,EAAE,CAAC,CAAC;IAC1H,MAAM,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,+BAA+B,EAAE,cAAc,EAAE,4EAA4E,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtN,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;AACjC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}

View File

@@ -0,0 +1,48 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
const trip = await prisma.trip.create({
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
});
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']] as const) {
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
const seats = [];
for (let row = 1; row <= 10; row++) {
for (const col of ['A', 'B', 'C', 'D']) seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
}
await prisma.seat.createMany({ data: seats });
}
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
const hash = await bcrypt.hash('password123', 10);
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
console.log('✅ Seed complete');
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -1,36 +1,51 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import { TicketsModule } from "./modules/tickets/tickets.module";
import { SchedulesModule } from "./modules/schedules/schedules.module";
import { PassengersModule } from "./modules/passengers/passengers.module";
import { SeatsModule } from "./modules/seats/seats.module";
import { StationsModule } from "./modules/stations/stations.module";
import { PaymentsModule } from "./modules/payments/payments.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { SearchModule } from './modules/search/search.module';
import { SeatsModule } from './modules/seats/seats.module';
import { BookingsModule } from './modules/bookings/bookings.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { TicketsModule } from './modules/tickets/tickets.module';
import { PassengersModule } from './modules/passengers/passengers.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { PromosModule } from './modules/promos/promos.module';
import { LiveModule } from './modules/live/live.module';
import { SupportModule } from './modules/support/support.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
}),
TicketsModule,
SchedulesModule,
PassengersModule,
SeatsModule,
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
AuthModule,
StationsModule,
FleetModule,
SchedulesModule,
SearchModule,
SeatsModule,
BookingsModule,
PaymentsModule,
TicketsModule,
PassengersModule,
NotificationsModule,
LoyaltyModule,
WalletModule,
PromosModule,
LiveModule,
SupportModule,
DashboardModule,
],
})
export class AppModule {}

View File

@@ -1 +1,52 @@
export { HttpExceptionFilter } from "@edr/api-common";
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
const message =
typeof messageRaw === 'string'
? messageRaw
: ((messageRaw as { message?: string }).message ?? 'Unexpected error');
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
(exception as Error)?.stack,
);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
response.status(status).json({
success: false,
statusCode: status,
message,
error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -1 +1,12 @@
export { ResponseTransformInterceptor } from "@edr/api-common";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
return next.handle().pipe(
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId };
}
}

View File

@@ -1 +0,0 @@
export { createValidationPipe } from "@edr/api-common";

View File

@@ -0,0 +1,6 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}

View File

@@ -1,7 +1,9 @@
import { registerAs } from "@nestjs/config";
import { registerAs } from '@nestjs/config';
export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3002", 10),
apiPrefix: "api",
export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
portalUrl: process.env.PORTAL_URL ?? 'http://localhost:3001',
}));

View File

@@ -1,18 +1,5 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { registerAs } from '@nestjs/config';
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5434", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_passenger",
entities: [__dirname + "/../**/*.entity.{ts,js}"],
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
synchronize: process.env.NODE_ENV === "development",
logging: process.env.NODE_ENV === "development",
}),
);
export default registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));

View File

@@ -1,35 +1,62 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
HttpExceptionFilter,
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { AppModule } from "./app.module";
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: [
process.env.FRONTEND_URL ?? 'http://localhost:3000',
process.env.PORTAL_URL ?? 'http://localhost:3001',
],
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
.setDescription("API for the EDR Passenger Management application")
.setVersion("0.1.0")
.addBearerAuth()
.setTitle('EDR Passenger API')
.setDescription(
'Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.',
)
.setVersion('1.0.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, 'JWT-auth')
.addTag('Auth', 'Registration and login')
.addTag('Stations', 'Station directory')
.addTag('Fleet', 'Train services and coaches')
.addTag('Schedule', 'Trips and fare rules')
.addTag('Search', 'Trip search and fare quotes')
.addTag('Seats', 'Seat maps and holds')
.addTag('Booking', 'Booking lifecycle')
.addTag('Payment', 'Payment intents and refunds')
.addTag('Tickets', 'QR ticket generation and validation')
.addTag('Passenger', 'Profiles, traveler profiles, saved routes')
.addTag('Notifications', 'Push and email notifications')
.addTag('Loyalty', 'Points, tiers, and rewards')
.addTag('Wallet', 'Wallet balance and ledger')
.addTag('Promotions', 'Promo codes and campaigns')
.addTag('Live Tracking', 'Real-time trip status and crowd signals')
.addTag('Support', 'FAQ and chat support')
.addTag('Dashboard', 'Home dashboard aggregate')
.addServer('http://localhost:4000', 'Development')
.addServer('https://api.edr-platform.com', 'Production')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
SwaggerModule.setup('api-docs', app, document, {
customSiteTitle: 'EDR Passenger API',
swaggerOptions: { persistAuthorization: true, docExpansion: 'none', filter: true },
});
const port = parseInt(process.env.PORT ?? "3002", 10);
const port = process.env.PORT ?? 4000;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[passenger-api] listening on http://localhost:${port}`);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -0,0 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register new user' })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@ApiOperation({ summary: 'Login and get JWT' })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
}
export class LoginDto {
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: 'password123' }) @IsString() password: string;
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from '../../common/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (c: ConfigService) => ({
secret: c.get('JWT_SECRET'),
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwt: JwtService) {}
async register(dto: RegisterDto) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
});
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: { fullName: dto.fullName, email: dto.email, phone: dto.phone, passwordHash },
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true },
});
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
}
private signToken(userId: string, email: string, role: string, passengerId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
return { token, user: { id: userId, email, role, passengerId } };
}
}

View File

@@ -0,0 +1,16 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './bookings.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@Controller('bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class BookingsController {
constructor(private service: BookingsService) {}
@Post() @ApiOperation({ summary: 'Create booking from seat hold' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); }
@Get(':bookingRef') @ApiOperation({ summary: 'Get booking by reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Delete(':bookingRef')@ApiOperation({ summary: 'Cancel booking' }) cancel(@Param('bookingRef') ref: string) { return this.service.cancel(ref); }
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PassengerInputDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;
@ApiProperty() @IsString() email: string;
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { SeatsModule } from '../seats/seats.module';
import { SearchModule } from '../search/search.module';
@Module({ imports: [SeatsModule, SearchModule], controllers: [BookingsController], providers: [BookingsService], exports: [BookingsService] })
export class BookingsModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SearchService } from '../search/search.service';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
@Injectable()
export class BookingsService {
constructor(private prisma: PrismaService, private seatsService: SeatsService, private eventEmitter: EventEmitter2, private searchService: SearchService) {}
async create(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
const booking = await this.prisma.booking.create({
data: { bookingRef: generateRef(), passengerId: dto.passengerId, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } },
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
});
this.eventEmitter.emit('booking.created', { booking });
return booking;
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } });
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalFare: booking.totalMinor / 100,
createdAt: booking.createdAt,
trip: {
number: booking.trip.service.number,
origin: { id: booking.trip.originStation.id, name: booking.trip.originStation.name, code: booking.trip.originStation.code, city: booking.trip.originStation.city },
destination: { id: booking.trip.destinationStation.id, name: booking.trip.destinationStation.name, code: booking.trip.destinationStation.code, city: booking.trip.destinationStation.city },
departureAt: booking.trip.departureAt,
arrivalAt: booking.trip.arrivalAt,
},
passengers: booking.seats.map((bs) => ({
fullName: bs.passengerName,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
};
}
async cancel(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CONFIRMED') throw new BadRequestException('Use refund for confirmed bookings');
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
return this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
}
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) { await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); }
}
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Dashboard')
@Controller('dashboard')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class DashboardController {
constructor(private service: DashboardService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({ controllers: [DashboardController], providers: [DashboardService] })
export class DashboardModule {}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
this.prisma.booking.findFirst({
where: { passengerId, status: 'CONFIRMED', trip: { departureAt: { gte: now } } },
include: { trip: { include: { originStation: true, destinationStation: true, service: true, liveStatus: true } }, seats: { include: { seat: { include: { coach: true } } }, take: 1 }, ticket: true },
orderBy: { createdAt: 'asc' },
}),
this.prisma.walletAccount.findUnique({ where: { passengerId } }),
this.prisma.promotion.count({ where: { active: true, validUntil: { gte: now } } }),
this.prisma.weatherAlert.findMany({ where: { validUntil: { gte: now } }, take: 3 }),
this.prisma.stationCrowdSignal.findMany({ include: { station: true }, take: 5 }),
this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' }, take: 5 }),
]);
const hour = now.getHours();
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
const seat = upcomingBooking?.seats[0];
return {
user: { firstName, greetingKey },
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.trip.originStation.name, to: upcomingBooking.trip.destinationStation.name,
trainName: upcomingBooking.trip.service.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
departureAt: upcomingBooking.trip.departureAt,
punctualityLabel: (upcomingBooking.trip.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
activePromotionsCount: promos,
weatherAlerts: weatherAlerts.map((w) => ({ id: w.id, title: w.title, message: w.message, severity: w.severity })),
stationSignals: stationSignals.map((s) => ({ stationId: s.stationId, stationName: s.station.name, level: s.level, statusLabel: s.statusLabel })),
savedRoutes: savedRoutes.map((r) => ({ id: r.id, fromName: r.fromName, toName: r.toName, tripCount: r.tripCount })),
};
}
}

View File

@@ -0,0 +1,18 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@Controller('fleet')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class FleetController {
constructor(private service: FleetService) {}
@Get('services') @ApiOperation({ summary: 'List train services' }) getServices() { return this.service.getServices(); }
@Post('services') @ApiOperation({ summary: 'Create train service' }) createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); }
@Post('coaches') @ApiOperation({ summary: 'Add coach to trip' }) createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Post('seats/batch')@ApiOperation({ summary: 'Batch-create seats for coach' }) createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
@Get('analytics') @ApiOperation({ summary: 'Fleet analytics' }) getAnalytics() { return this.service.getAnalytics(); }
}

View File

@@ -0,0 +1,20 @@
import { IsString, IsEnum, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTrainServiceDto {
@ApiProperty({ example: '301' }) @IsString() number: string;
@ApiProperty({ example: 'Express 301' }) @IsString() name: string;
}
export class CreateCoachDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'A' }) @IsString() label: string;
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
}
export class CreateSeatBatchDto {
@ApiProperty() @IsString() coachId: string;
@ApiProperty({ example: 10 }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'] }) cols: string[];
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
@Module({ controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
export class FleetModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); }
createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); }
createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); }
async createSeatBatch(dto: CreateSeatBatchDto) {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) for (const col of dto.cols) seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}` });
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([
this.prisma.trainService.count(), this.prisma.trip.count(),
this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]);
return { totalServices, totalTrips, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
}
}

View File

@@ -0,0 +1,16 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LiveService } from './live.service';
import { UpdateLiveStatusDto } from './live.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Live Tracking')
@Controller('live')
export class LiveController {
constructor(private service: LiveService) {}
@Get('trips/:tripId') @ApiOperation({ summary: 'Get live status for a trip' }) getTripLiveStatus(@Param('tripId') id: string) { return this.service.getTripLiveStatus(id); }
@Get('trips/:tripId/stops') @ApiOperation({ summary: 'Get stop timeline for a trip' }) getStopTimeline(@Param('tripId') id: string) { return this.service.getStopTimeline(id); }
@Patch('trips/:tripId/status')@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live trip status (staff/system)' }) updateLiveStatus(@Param('tripId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
@Get('crowd-signals') @ApiOperation({ summary: 'Get station crowd signals' }) getCrowdSignals() { return this.service.getStationCrowdSignals(); }
@Get('weather-alerts') @ApiOperation({ summary: 'Get active weather alerts' }) getWeatherAlerts() { return this.service.getWeatherAlerts(); }
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateLiveStatusDto {
@ApiPropertyOptional({ example: 'EN_ROUTE' }) @IsOptional() @IsString() state?: string;
@ApiPropertyOptional({ example: 'Between Dire Dawa and Dewele' }) @IsOptional() @IsString() currentLocationLabel?: string;
@ApiPropertyOptional({ example: 45 }) @IsOptional() @IsInt() @Min(0) @Max(100) progressPercent?: number;
@ApiPropertyOptional({ example: 10 }) @IsOptional() @IsInt() @Min(0) delayMinutes?: number;
@ApiPropertyOptional({ example: 120 }) @IsOptional() @IsInt() @Min(0) currentSpeedKph?: number;
@ApiPropertyOptional({ example: 'Platform 2' }) @IsOptional() @IsString() platformLabel?: string;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { LiveController } from './live.controller';
import { LiveService } from './live.service';
@Module({ controllers: [LiveController], providers: [LiveService] })
export class LiveModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class LiveService {
constructor(private prisma: PrismaService) {}
async getTripLiveStatus(tripId: string) {
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
include: { service: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!trip) throw new NotFoundException('Trip not found');
const live = trip.liveStatus;
const nextStop = trip.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
return {
tripId: trip.id, trainName: trip.service.name,
fromStationName: trip.originStation.name, toStationName: trip.destinationStation.name,
state: live?.state ?? trip.status, currentLocationLabel: live?.currentLocationLabel,
progressPercent: live?.progressPercent ?? 0, delayMinutes: live?.delayMinutes ?? 0,
currentSpeedKph: live?.currentSpeedKph, platformLabel: live?.platformLabel,
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? trip.departureAt,
};
}
updateLiveStatus(tripId: string, data: any) {
return this.prisma.tripLiveStatus.upsert({ where: { tripId }, update: data, create: { tripId, state: data.state ?? 'SCHEDULED', ...data } });
}
getStopTimeline(tripId: string) { return this.prisma.tripStopTime.findMany({ where: { tripId }, include: { station: true }, orderBy: { sequence: 'asc' } }); }
getStationCrowdSignals() { return this.prisma.stationCrowdSignal.findMany({ include: { station: true } }); }
getWeatherAlerts() { return this.prisma.weatherAlert.findMany({ where: { validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LoyaltyService } from './loyalty.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Loyalty')
@Controller('loyalty')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class LoyaltyController {
constructor(private service: LoyaltyService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({ controllers: [LoyaltyController], providers: [LoyaltyService] })
export class LoyaltyModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class LoyaltyService {
constructor(private prisma: PrismaService) {}
async getAccount(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!account) throw new NotFoundException('Loyalty account not found');
const tiers = ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM'];
const thresholds: Record<string, number> = { BRONZE: 0, SILVER: 2000, GOLD: 5000, PLATINUM: 10000 };
const idx = tiers.indexOf(account.tier);
const nextTier = tiers[idx + 1] ?? null;
const nextThreshold = nextTier ? thresholds[nextTier] : null;
return {
...account, nextTier,
points: account.pointsBalance,
nextTierPoints: nextThreshold ?? account.pointsBalance,
pointsToNextTier: nextThreshold ? nextThreshold - account.pointsBalance : 0,
tierProgressPercent: nextThreshold ? +((account.pointsBalance - thresholds[account.tier]) / (nextThreshold - thresholds[account.tier]) * 100).toFixed(2) : 100,
};
}
async getRewards(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) throw new NotFoundException('Loyalty account not found');
return this.prisma.loyaltyReward.findMany({ where: { accountId: account.id, available: true } });
}
async redeemReward(passengerId: string, rewardId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) throw new NotFoundException('Loyalty account not found');
const reward = await this.prisma.loyaltyReward.findUnique({ where: { id: rewardId } });
if (!reward?.available) throw new NotFoundException('Reward not available');
if (account.pointsBalance < reward.costPoints) throw new BadRequestException('Insufficient points');
const newBalance = account.pointsBalance - reward.costPoints;
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: newBalance } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: -reward.costPoints, reason: 'REWARD_REDEEMED', balanceAfter: newBalance } });
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class NotificationsController {
constructor(private service: NotificationsService) {}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) { return this.service.getForPassenger(id); }
@Patch(':id/read')
@ApiOperation({ summary: 'Mark notification as read' })
markRead(@Param('id') id: string) { return this.service.markRead(id); }
@Patch(':passengerId/read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
markAllRead(@Param('passengerId') id: string) { return this.service.markAllRead(id); }
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NotificationCategoryEnum {
BOOKING = 'BOOKING',
PAYMENT = 'PAYMENT',
DISRUPTION = 'DISRUPTION',
PROMOTION = 'PROMOTION',
SYSTEM = 'SYSTEM',
}
export class SendNotificationDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ example: 'Platform Change' }) @IsString() title: string;
@ApiProperty({ example: 'Your train departs from Platform 3' }) @IsString() body: string;
@ApiProperty({ enum: NotificationCategoryEnum }) @IsEnum(NotificationCategoryEnum) category: NotificationCategoryEnum;
@ApiPropertyOptional({ example: 'edr://tickets/tkt_01' }) @IsOptional() @IsString() deepLink?: string;
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
}

View File

@@ -1,9 +1,6 @@
import { Module } from "@nestjs/common";
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationsService } from "./notifications.service";
@Module({
providers: [NotificationsService],
exports: [NotificationsService],
})
@Module({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] })
export class NotificationsModule {}

View File

@@ -1,14 +1,45 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import * as sgMail from '@sendgrid/mail';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
/**
* Dispatch a notification to a passenger (booking confirmation, schedule change, etc.).
* TODO: wire to email/SMS provider via a mailer service.
*/
async send(recipient: string, subject: string, body: string): Promise<void> {
this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`);
constructor(private prisma: PrismaService) {
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
}
}
private sanitize(value: string): string {
return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
async send(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } });
const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } });
if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body));
return notification;
}
getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); }
markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); }
async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; }
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
private async sendEmail(to: string, subject: string, text: string) {
if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; }
try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); }
catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); }
}
}

View File

@@ -1,20 +0,0 @@
import { IsDateString, IsEmail, IsOptional, IsString } from "class-validator";
export class CreatePassengerDto {
@IsString()
fullName!: string;
@IsEmail()
email!: string;
@IsString()
phone!: string;
@IsOptional()
@IsString()
nationalId?: string;
@IsOptional()
@IsDateString()
dateOfBirth?: string;
}

View File

@@ -1,20 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
@Entity({ name: "passengers" })
export class Passenger extends BaseEntity {
@Column({ name: "full_name", type: "varchar", length: 256 })
fullName!: string;
@Column({ name: "email", type: "varchar", length: 256, unique: true })
email!: string;
@Column({ name: "phone", type: "varchar", length: 32 })
phone!: string;
@Column({ name: "national_id", type: "varchar", length: 64, nullable: true })
nationalId?: string | null;
@Column({ name: "date_of_birth", type: "date", nullable: true })
dateOfBirth?: string | null;
}

View File

@@ -1,37 +1,19 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreatePassengerDto } from "./dto/create-passenger.dto";
import { PassengersService } from "./passengers.service";
@ApiTags("passengers")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("passengers")
@ApiTags('Passenger')
@Controller('passengers')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class PassengersController {
constructor(private readonly passengersService: PassengersService) {}
@Post()
@ApiOperation({ summary: "Register a new passenger" })
create(@Body() dto: CreatePassengerDto) {
return this.passengersService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all passengers" })
findAll() {
return this.passengersService.findAll();
}
@Get(":id")
@ApiOperation({ summary: "Get a passenger by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.passengersService.findById(id);
}
constructor(private service: PassengersService) {}
@Get(':id/profile') @ApiOperation({ summary: 'Get passenger profile' }) getProfile(@Param('id') id: string) { return this.service.getProfile(id); }
@Get(':id/stats') @ApiOperation({ summary: 'Get passenger stats' }) getStats(@Param('id') id: string) { return this.service.getStats(id); }
@Post('traveler-profiles') @ApiOperation({ summary: 'Add traveler profile (family member)' }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); }
@Get(':id/traveler-profiles') @ApiOperation({ summary: 'Get traveler profiles for passenger' }) getTravelerProfiles(@Param('id') id: string) { return this.service.getTravelerProfiles(id); }
@Post('saved-routes') @ApiOperation({ summary: 'Save a route' }) createSavedRoute(@Body() dto: CreateSavedRouteDto) { return this.service.createSavedRoute(dto); }
@Get(':id/saved-routes') @ApiOperation({ summary: 'Get saved routes' }) getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); }
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsDateString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTravelerProfileDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ example: 'Sara Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
export class CreateSavedRouteDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() fromStationId: string;
@ApiProperty() @IsString() toStationId: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() fromName: string;
@ApiProperty({ example: 'Dire Dawa' }) @IsString() toName: string;
}

View File

@@ -1,15 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service';
import { Passenger } from "./entities/passenger.entity";
import { PassengersController } from "./passengers.controller";
import { PassengersRepository } from "./passengers.repository";
import { PassengersService } from "./passengers.service";
@Module({
imports: [TypeOrmModule.forFeature([Passenger])],
controllers: [PassengersController],
providers: [PassengersService, PassengersRepository],
exports: [PassengersService],
})
@Module({ controllers: [PassengersController], providers: [PassengersService] })
export class PassengersModule {}

View File

@@ -1,21 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Passenger } from "./entities/passenger.entity";
@Injectable()
export class PassengersRepository extends BaseRepository<Passenger> {
constructor(
@InjectRepository(Passenger)
repository: Repository<Passenger>,
) {
super(repository);
}
/** Find a passenger by their unique email. */
findByEmail(email: string): Promise<Passenger | null> {
return this.repository.findOne({ where: { email } });
}
}

View File

@@ -1,29 +1,57 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreatePassengerDto } from "./dto/create-passenger.dto";
import { Passenger } from "./entities/passenger.entity";
import { PassengersRepository } from "./passengers.repository";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
@Injectable()
export class PassengersService {
constructor(private readonly passengersRepository: PassengersRepository) {}
constructor(private prisma: PrismaService) {}
/** Register a new passenger. */
create(dto: CreatePassengerDto): Promise<Passenger> {
return this.passengersRepository.create(dto);
async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
},
});
if (!p) throw new NotFoundException('Passenger not found');
return {
id: p.id,
fullName: p.user.fullName,
email: p.user.email,
phone: p.user.phone,
createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: {
number: b.trip.service.number,
origin: { id: b.trip.originStation.id, name: b.trip.originStation.name, code: b.trip.originStation.code, city: b.trip.originStation.city },
destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city },
departureAt: b.trip.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass } })),
})),
};
}
/** List every passenger (alphabetical). */
findAll(): Promise<Passenger[]> {
return this.passengersRepository.findAll({ order: { fullName: "ASC" } });
async getStats(passengerId: string) {
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }),
this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }),
this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }),
]);
const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100;
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
}
/** Get a single passenger by ID. */
async findById(id: string): Promise<Passenger> {
const passenger = await this.passengersRepository.findById(id);
if (!passenger) {
throw new NotFoundException(`Passenger ${id} not found`);
}
return passenger;
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
}
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
}

View File

@@ -1,37 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "payments" })
export class Payment extends BaseEntity {
@Column({ name: "ticket_id", type: "uuid" })
ticketId!: string;
@Column({ name: "amount", type: "numeric", precision: 10, scale: 2 })
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({
name: "status",
type: "enum",
enum: Passenger.PaymentStatus,
default: Passenger.PaymentStatus.Pending,
})
status!: Passenger.PaymentStatus;
@Column({ name: "provider", type: "varchar", length: 64 })
provider!: string;
@Column({
name: "provider_transaction_id",
type: "varchar",
length: 256,
nullable: true,
})
providerTransactionId?: string | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,10 @@
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 200));
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
}
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }

View File

@@ -1,17 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PaymentsService } from "./payments.service";
@ApiTags("payments")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("payments")
@ApiTags('Payment')
@Controller('payments')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get("ticket/:ticketId")
@ApiOperation({ summary: "List payments for a ticket" })
findByTicket(@Param("ticketId", ParseUUIDPipe) ticketId: string) {
return this.paymentsService.findByTicket(ticketId);
}
constructor(private service: PaymentsService) {}
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
export class InitiatePaymentDto {
@ApiProperty() @IsString() bookingId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string;
}
export class RefundDto {
@ApiProperty() @IsString() bookingId: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class AddPaymentMethodDto {
@ApiProperty() @IsString() userId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
}

View File

@@ -1,14 +1,8 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { Payment } from "./entities/payment.entity";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
@Module({
imports: [TypeOrmModule.forFeature([Payment])],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
export class PaymentsModule {}

View File

@@ -1,21 +1,81 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Payment } from "./entities/payment.entity";
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
) {}
/** List payments associated with a ticket. */
findByTicket(ticketId: string): Promise<Payment[]> {
return this.paymentsRepository.find({
where: { ticketId },
order: { createdAt: "DESC" },
async initiatePayment(dto: InitiatePaymentDto) {
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
let result;
if (dto.method === 'WALLET') {
result = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
const newBalance = wallet.balanceMinor - booking.totalMinor;
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
return { success: true, providerRef: `WALLET-${Date.now()}` };
});
} else {
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
}
const status = result.success ? 'SUCCEEDED' : 'FAILED';
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: dto.bookingId },
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
});
if (result.success) {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
await this.ticketsService.generate(dto.bookingId);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
}
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
}
async refund(dto: RefundDto) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } });
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } });
}
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); }
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
const points = Math.floor(amountMinor / 100);
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) return;
const newBalance = account.pointsBalance + points;
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
}
}

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Promotions')
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
@Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
@Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
}

View File

@@ -0,0 +1,13 @@
import { IsString, IsOptional, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { PromosController } from './promos.controller';
import { PromosService } from './promos.service';
@Module({ controllers: [PromosController], providers: [PromosService] })
export class PromosModule {}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
}
create(dto: CreatePromotionDto) {
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
}
}

View File

@@ -1,35 +0,0 @@
import { Passenger } from "@edr/types";
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
export class CreateScheduleDto {
@IsString()
trainCode!: string;
@IsUUID()
originStationId!: string;
@IsUUID()
destinationStationId!: string;
@IsDateString()
departureTime!: string;
@IsDateString()
arrivalTime!: string;
@IsNumber()
@Min(0)
basePrice!: number;
@IsOptional()
@IsEnum(Passenger.ScheduleStatus)
status?: Passenger.ScheduleStatus;
}

View File

@@ -1,32 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "schedules" })
export class Schedule extends BaseEntity {
@Column({ name: "train_code", type: "varchar", length: 32 })
trainCode!: string;
@Column({ name: "origin_station_id", type: "uuid" })
originStationId!: string;
@Column({ name: "destination_station_id", type: "uuid" })
destinationStationId!: string;
@Column({ name: "departure_time", type: "timestamptz" })
departureTime!: Date;
@Column({ name: "arrival_time", type: "timestamptz" })
arrivalTime!: Date;
@Column({
name: "status",
type: "enum",
enum: Passenger.ScheduleStatus,
default: Passenger.ScheduleStatus.Scheduled,
})
status!: Passenger.ScheduleStatus;
@Column({ name: "base_price", type: "numeric", precision: 10, scale: 2 })
basePrice!: number;
}

View File

@@ -1,37 +1,21 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreateScheduleDto } from "./dto/create-schedule.dto";
import { SchedulesService } from "./schedules.service";
@ApiTags("schedules")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("schedules")
@ApiTags('Schedule')
@Controller('schedule')
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Post()
@ApiOperation({ summary: "Publish a new train schedule" })
create(@Body() dto: CreateScheduleDto) {
return this.schedulesService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all schedules" })
findAll() {
return this.schedulesService.findAll();
}
@Get(":id")
@ApiOperation({ summary: "Get a schedule by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.schedulesService.findById(id);
}
constructor(private service: SchedulesService) {}
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
@Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); }
@Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTripDto {
@ApiProperty() @IsString() serviceId: string;
@ApiProperty() @IsString() originStationId: string;
@ApiProperty() @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11T08:30:00Z' }) @IsDateString() departureAt: string;
@ApiProperty({ example: '2026-05-11T20:00:00Z' }) @IsDateString() arrivalAt: string;
@ApiPropertyOptional() @IsOptional() @IsInt() stopsCount?: number;
}
export class CreateFareRuleDto {
@ApiPropertyOptional() @IsOptional() @IsString() tripId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() route?: string;
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
@ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string;
}
export class UpdateTripStatusDto {
@ApiProperty({ example: 'EN_ROUTE' }) @IsString() status: string;
}

View File

@@ -1,15 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { Schedule } from "./entities/schedule.entity";
import { SchedulesController } from "./schedules.controller";
import { SchedulesRepository } from "./schedules.repository";
import { SchedulesService } from "./schedules.service";
@Module({
imports: [TypeOrmModule.forFeature([Schedule])],
controllers: [SchedulesController],
providers: [SchedulesService, SchedulesRepository],
exports: [SchedulesService],
})
@Module({ controllers: [SchedulesController], providers: [SchedulesService] })
export class SchedulesModule {}

View File

@@ -1,16 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Schedule } from "./entities/schedule.entity";
@Injectable()
export class SchedulesRepository extends BaseRepository<Schedule> {
constructor(
@InjectRepository(Schedule)
repository: Repository<Schedule>,
) {
super(repository);
}
}

View File

@@ -1,35 +1,39 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateScheduleDto } from "./dto/create-schedule.dto";
import { Schedule } from "./entities/schedule.entity";
import { SchedulesRepository } from "./schedules.repository";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
@Injectable()
export class SchedulesService {
constructor(private readonly schedulesRepository: SchedulesRepository) {}
constructor(private prisma: PrismaService) {}
/** Publish a new train schedule. */
create(dto: CreateScheduleDto): Promise<Schedule> {
return this.schedulesRepository.create({
...dto,
departureTime: new Date(dto.departureTime),
arrivalTime: new Date(dto.arrivalTime),
async createTrip(dto: CreateTripDto) {
const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt);
return this.prisma.trip.create({
data: { serviceId: dto.serviceId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60000), stopsCount: dto.stopsCount ?? 0 },
include: { service: true, originStation: true, destinationStation: true },
});
}
/** List every published schedule. */
findAll(): Promise<Schedule[]> {
return this.schedulesRepository.findAll({
order: { departureTime: "ASC" },
});
async getTrip(id: string) {
const trip = await this.prisma.trip.findUnique({ where: { id }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } });
if (!trip) throw new NotFoundException('Trip not found');
return trip;
}
/** Get a single schedule by ID. */
async findById(id: string): Promise<Schedule> {
const schedule = await this.schedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Schedule ${id} not found`);
}
return schedule;
updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); }
createFareRule(dto: CreateFareRuleDto) {
return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } });
}
async getFare(tripId: string, serviceClass: string) {
const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const route = `${trip.originStation.code}-${trip.destinationStation.code}`;
const rule = await this.prisma.fareRule.findFirst({
where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] },
orderBy: { validFrom: 'desc' },
});
return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass };
}
}

View File

@@ -0,0 +1,12 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
@ApiTags('Search')
@Controller('search')
export class SearchController {
constructor(private service: SearchService) {}
@Post() @ApiOperation({ summary: 'Search trips' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); }
@Post('fare-quote')@ApiOperation({ summary: 'Get fare quote' }) getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); }
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsDateString, IsInt, IsOptional, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class SearchTripsDto {
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11' }) @IsDateString() date: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengers?: number;
}
export class FareQuoteDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'ECONOMY' }) @IsString() serviceClass: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({ controllers: [SearchController], providers: [SearchService], exports: [SearchService] })
export class SearchModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
const POINTS_TO_MINOR = 10;
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
const trips = await this.prisma.trip.findMany({
where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } },
include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } },
});
return trips.map((trip) => {
const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats);
const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length;
return {
id: trip.id,
number: trip.service.number,
origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city },
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: { ECONOMY: avail('ECONOMY'), BUSINESS: avail('BUSINESS'), FIRST: avail('FIRST') },
fares: { ECONOMY: this.defaultFare('ECONOMY') / 100, BUSINESS: this.defaultFare('BUSINESS') / 100, FIRST: this.defaultFare('FIRST') / 100 },
};
});
}
async getFareQuote(dto: FareQuoteDto) {
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const count = dto.passengerCount ?? 1;
const baseFareMinor = this.defaultFare(dto.serviceClass) * count;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) discountMinor = promo.percentOff ? Math.round(baseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(baseFareMinor * 0.05);
return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' };
}
private defaultFare(serviceClass: string): number {
return ({ ECONOMY: 45000, BUSINESS: 90000, FIRST: 135000 } as any)[serviceClass] ?? 45000;
}
}

View File

@@ -1,26 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "seats" })
export class Seat extends BaseEntity {
@Column({ name: "schedule_id", type: "uuid" })
scheduleId!: string;
@Column({ name: "seat_number", type: "varchar", length: 16 })
seatNumber!: string;
@Column({ name: "seat_class", type: "enum", enum: Passenger.SeatClass })
seatClass!: Passenger.SeatClass;
@Column({
name: "status",
type: "enum",
enum: Passenger.SeatStatus,
default: Passenger.SeatStatus.Available,
})
status!: Passenger.SeatStatus;
@Column({ name: "price", type: "numeric", precision: 10, scale: 2 })
price!: number;
}

View File

@@ -1,17 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { SeatsService } from "./seats.service";
@ApiTags("seats")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("seats")
@ApiTags('Seats')
@Controller('seats')
export class SeatsController {
constructor(private readonly seatsService: SeatsService) {}
@Get("schedule/:scheduleId")
@ApiOperation({ summary: "List seats for a schedule" })
findBySchedule(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.seatsService.findBySchedule(scheduleId);
}
constructor(private service: SeatsService) {}
@Get('seatmap/:tripId') @ApiOperation({ summary: 'Get seat map for a trip' })
getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); }
@Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Hold seats for 15 minutes' })
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
@Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class HoldSeatsDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ type: [String] }) @IsArray() seatIds: string[];
@ApiProperty({ required: false }) fareQuoteId?: string;
}

View File

@@ -1,14 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { Seat } from "./entities/seat.entity";
import { SeatsController } from "./seats.controller";
import { SeatsService } from "./seats.service";
@Module({
imports: [TypeOrmModule.forFeature([Seat])],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],
})
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
export class SeatsModule {}

View File

@@ -1,21 +1,50 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Seat } from "./entities/seat.entity";
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class SeatsService {
constructor(
@InjectRepository(Seat)
private readonly seatsRepository: Repository<Seat>,
) {}
constructor(private prisma: PrismaService) {}
/** List every seat on a given schedule, ordered by seat number. */
findBySchedule(scheduleId: string): Promise<Seat[]> {
return this.seatsRepository.find({
where: { scheduleId },
order: { seatNumber: "ASC" },
async getSeatMap(tripId: string, coachId?: string) {
const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } });
return {
coaches: coaches.map((coach) => ({
id: coach.id,
name: `Coach ${coach.label}`,
type: coach.serviceClass,
seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
})),
};
}
async holdSeats(dto: HoldSeatsDto) {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
return tx.seatHold.create({ data: { tripId: dto.tripId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
});
return { id: hold.id, tripId: dto.tripId, seatIds: dto.seatIds, expiresAt };
}
async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
await this.prisma.seatHold.delete({ where: { id: holdId } });
return { released: true };
}
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
}
}

View File

@@ -1,23 +0,0 @@
import { IsNumber, IsOptional, IsString } from "class-validator";
export class CreateStationDto {
@IsString()
code!: string;
@IsString()
name!: string;
@IsString()
city!: string;
@IsString()
country!: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
}

View File

@@ -1,35 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
@Entity({ name: "stations" })
export class Station extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 16, unique: true })
code!: string;
@Column({ name: "name", type: "varchar", length: 128 })
name!: string;
@Column({ name: "city", type: "varchar", length: 128 })
city!: string;
@Column({ name: "country", type: "varchar", length: 64 })
country!: string;
@Column({
name: "latitude",
type: "numeric",
precision: 9,
scale: 6,
nullable: true,
})
latitude?: number | null;
@Column({
name: "longitude",
type: "numeric",
precision: 9,
scale: 6,
nullable: true,
})
longitude?: number | null;
}

View File

@@ -1,37 +1,15 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreateStationDto } from "./dto/create-station.dto";
import { StationsService } from "./stations.service";
@ApiTags("stations")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("stations")
@ApiTags('Stations')
@Controller('stations')
export class StationsController {
constructor(private readonly stationsService: StationsService) {}
@Post()
@ApiOperation({ summary: "Register a new station" })
create(@Body() dto: CreateStationDto) {
return this.stationsService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all stations" })
findAll() {
return this.stationsService.findAll();
}
@Get(":id")
@ApiOperation({ summary: "Get a station by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.stationsService.findById(id);
}
constructor(private service: StationsService) {}
@Get() @ApiOperation({ summary: 'List all stations' }) findAll() { return this.service.findAll(); }
@Get(':id') @ApiOperation({ summary: 'Get station by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create station' })
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsNumber, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStationDto {
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
}

View File

@@ -1,14 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { StationsController } from './stations.controller';
import { StationsService } from './stations.service';
import { Station } from "./entities/station.entity";
import { StationsController } from "./stations.controller";
import { StationsService } from "./stations.service";
@Module({
imports: [TypeOrmModule.forFeature([Station])],
controllers: [StationsController],
providers: [StationsService],
exports: [StationsService],
})
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
export class StationsModule {}

View File

@@ -1,34 +1,15 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { CreateStationDto } from "./dto/create-station.dto";
import { Station } from "./entities/station.entity";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateStationDto } from './stations.dto';
@Injectable()
export class StationsService {
constructor(
@InjectRepository(Station)
private readonly stationsRepository: Repository<Station>,
) {}
/** Register a new station. */
create(dto: CreateStationDto): Promise<Station> {
const entity = this.stationsRepository.create(dto);
return this.stationsRepository.save(entity);
}
/** List every station (alphabetical). */
findAll(): Promise<Station[]> {
return this.stationsRepository.find({ order: { name: "ASC" } });
}
/** Get a single station by ID. */
async findById(id: string): Promise<Station> {
const station = await this.stationsRepository.findOne({ where: { id } });
if (!station) {
throw new NotFoundException(`Station ${id} not found`);
}
return station;
constructor(private prisma: PrismaService) {}
findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); }
async findOne(id: string) {
const s = await this.prisma.station.findUnique({ where: { id } });
if (!s) throw new NotFoundException('Station not found');
return s;
}
create(dto: CreateStationDto) { return this.prisma.station.create({ data: dto }); }
}

View File

@@ -0,0 +1,15 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SupportService } from './support.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Support')
@Controller('support')
export class SupportController {
constructor(private service: SupportService) {}
@Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); }
@Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); }
@Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); }
@Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); }
@Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
@Module({ controllers: [SupportController], providers: [SupportService] })
export class SupportModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class SupportService {
constructor(private prisma: PrismaService) {}
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
if (!conv) throw new NotFoundException('Conversation not found');
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
return message;
}
async getConversation(conversationId: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
if (!conv) throw new NotFoundException('Conversation not found');
return conv;
}
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
}
}

View File

@@ -1,35 +0,0 @@
import { Passenger } from "@edr/types";
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
export class CreateTicketDto {
@IsString()
reference!: string;
@IsUUID()
passengerId!: string;
@IsUUID()
scheduleId!: string;
@IsUUID()
seatId!: string;
@IsNumber()
@Min(0)
pricePaid!: number;
@IsDateString()
issuedAt!: string;
@IsOptional()
@IsEnum(Passenger.TicketStatus)
status?: Passenger.TicketStatus;
}

View File

@@ -1,29 +0,0 @@
import { Passenger } from "@edr/types";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
export class FilterTicketDto {
@IsOptional()
@IsEnum(Passenger.TicketStatus)
status?: Passenger.TicketStatus;
@IsOptional()
@IsUUID()
passengerId?: string;
@IsOptional()
@IsUUID()
scheduleId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -1,32 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "tickets" })
export class Ticket extends BaseEntity {
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
reference!: string;
@Column({ name: "passenger_id", type: "uuid" })
passengerId!: string;
@Column({ name: "schedule_id", type: "uuid" })
scheduleId!: string;
@Column({ name: "seat_id", type: "uuid" })
seatId!: string;
@Column({
name: "status",
type: "enum",
enum: Passenger.TicketStatus,
default: Passenger.TicketStatus.Reserved,
})
status!: Passenger.TicketStatus;
@Column({ name: "price_paid", type: "numeric", precision: 10, scale: 2 })
pricePaid!: number;
@Column({ name: "issued_at", type: "timestamptz" })
issuedAt!: Date;
}

Some files were not shown because too many files have changed in this diff Show More