mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Initial commit of edr-passenger-api alpha version
This commit is contained in:
@@ -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=
|
||||
|
||||
1
apps/edr-passenger-api/.eslintrc.js
Normal file
1
apps/edr-passenger-api/.eslintrc.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('@edr/eslint-config/nestjs');
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
2
apps/edr-passenger-api/prisma/reset-admin.d.ts
vendored
Normal file
2
apps/edr-passenger-api/prisma/reset-admin.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=reset-admin.d.ts.map
|
||||
1
apps/edr-passenger-api/prisma/reset-admin.d.ts.map
Normal file
1
apps/edr-passenger-api/prisma/reset-admin.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"reset-admin.d.ts","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":""}
|
||||
49
apps/edr-passenger-api/prisma/reset-admin.js
Normal file
49
apps/edr-passenger-api/prisma/reset-admin.js
Normal 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
|
||||
1
apps/edr-passenger-api/prisma/reset-admin.js.map
Normal file
1
apps/edr-passenger-api/prisma/reset-admin.js.map
Normal 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"}
|
||||
16
apps/edr-passenger-api/prisma/reset-admin.ts
Normal file
16
apps/edr-passenger-api/prisma/reset-admin.ts
Normal 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());
|
||||
573
apps/edr-passenger-api/prisma/schema.prisma
Normal file
573
apps/edr-passenger-api/prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
2
apps/edr-passenger-api/prisma/seed.d.ts
vendored
Normal file
2
apps/edr-passenger-api/prisma/seed.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=seed.d.ts.map
|
||||
1
apps/edr-passenger-api/prisma/seed.d.ts.map
Normal file
1
apps/edr-passenger-api/prisma/seed.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""}
|
||||
73
apps/edr-passenger-api/prisma/seed.js
Normal file
73
apps/edr-passenger-api/prisma/seed.js
Normal 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
|
||||
1
apps/edr-passenger-api/prisma/seed.js.map
Normal file
1
apps/edr-passenger-api/prisma/seed.js.map
Normal 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"}
|
||||
48
apps/edr-passenger-api/prisma/seed.ts
Normal file
48
apps/edr-passenger-api/prisma/seed.ts
Normal 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());
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/edr-passenger-api/src/common/jwt.guard.ts
Normal file
5
apps/edr-passenger-api/src/common/jwt.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard('jwt') {}
|
||||
17
apps/edr-passenger-api/src/common/jwt.strategy.ts
Normal file
17
apps/edr-passenger-api/src/common/jwt.strategy.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { createValidationPipe } from "@edr/api-common";
|
||||
6
apps/edr-passenger-api/src/common/prisma.module.ts
Normal file
6
apps/edr-passenger-api/src/common/prisma.module.ts
Normal 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 {}
|
||||
8
apps/edr-passenger-api/src/common/prisma.service.ts
Normal file
8
apps/edr-passenger-api/src/common/prisma.service.ts
Normal 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(); }
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
18
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal file
18
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal 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); }
|
||||
}
|
||||
14
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal file
14
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal 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;
|
||||
}
|
||||
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal file
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
42
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal file
42
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
22
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal file
22
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' } }); }
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
Normal file
18
apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
Normal 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(); }
|
||||
}
|
||||
20
apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
Normal file
20
apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
Normal 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[];
|
||||
}
|
||||
6
apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
Normal file
6
apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
Normal 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 {}
|
||||
26
apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
Normal file
26
apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
16
apps/edr-passenger-api/src/modules/live/live.controller.ts
Normal file
16
apps/edr-passenger-api/src/modules/live/live.controller.ts
Normal 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(); }
|
||||
}
|
||||
11
apps/edr-passenger-api/src/modules/live/live.dto.ts
Normal file
11
apps/edr-passenger-api/src/modules/live/live.dto.ts
Normal 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;
|
||||
}
|
||||
6
apps/edr-passenger-api/src/modules/live/live.module.ts
Normal file
6
apps/edr-passenger-api/src/modules/live/live.module.ts
Normal 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 {}
|
||||
35
apps/edr-passenger-api/src/modules/live/live.service.ts
Normal file
35
apps/edr-passenger-api/src/modules/live/live.service.ts
Normal 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' } }); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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, ' ')); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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' } }); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}` }; }
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal file
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
13
apps/edr-passenger-api/src/modules/promos/promos.dto.ts
Normal file
13
apps/edr-passenger-api/src/modules/promos/promos.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
20
apps/edr-passenger-api/src/modules/promos/promos.service.ts
Normal file
20
apps/edr-passenger-api/src/modules/promos/promos.service.ts
Normal 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) } });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
18
apps/edr-passenger-api/src/modules/search/search.dto.ts
Normal file
18
apps/edr-passenger-api/src/modules/search/search.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
50
apps/edr-passenger-api/src/modules/search/search.service.ts
Normal file
50
apps/edr-passenger-api/src/modules/search/search.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
9
apps/edr-passenger-api/src/modules/seats/seats.dto.ts
Normal file
9
apps/edr-passenger-api/src/modules/seats/seats.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
11
apps/edr-passenger-api/src/modules/stations/stations.dto.ts
Normal file
11
apps/edr-passenger-api/src/modules/stations/stations.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user