mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
Initial commit of edr-passenger-api alpha version
This commit is contained in:
142
README.md
142
README.md
@@ -1 +1,141 @@
|
|||||||
# edr-platform
|
# EDR Passenger API
|
||||||
|
|
||||||
|
NestJS REST API for the Ethio-Djibouti Railway passenger platform. Handles booking lifecycle, seat inventory, payments (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, notifications, and support.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js 20, TypeScript
|
||||||
|
- **Framework**: NestJS 11
|
||||||
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
|
- **Auth**: JWT (Passport)
|
||||||
|
- **Docs**: Swagger / OpenAPI (`/api-docs`)
|
||||||
|
- **Package manager**: pnpm 9
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- pnpm >= 9 (`npm i -g pnpm`)
|
||||||
|
- PostgreSQL 15+
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies (from monorepo root)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 2. Copy and fill environment variables
|
||||||
|
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
|
||||||
|
|
||||||
|
# 3. Generate Prisma client
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:generate
|
||||||
|
|
||||||
|
# 4. Run database migrations
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:migrate
|
||||||
|
|
||||||
|
# 5. Seed the database
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:seed
|
||||||
|
|
||||||
|
# 6. Start in development mode
|
||||||
|
pnpm --filter @edr/passenger-api run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
API runs at **http://localhost:4000**
|
||||||
|
Swagger UI at **http://localhost:4000/api-docs**
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --------------------- | ------------------------------------ | -------------------------------- |
|
||||||
|
| `PORT` | HTTP port | `4000` |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | — |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | — |
|
||||||
|
| `JWT_EXPIRES_IN` | JWT expiry | `7d` |
|
||||||
|
| `FRONTEND_URL` | Allowed CORS origin (web app) | `http://localhost:3000` |
|
||||||
|
| `PORTAL_URL` | Allowed CORS origin (portal) | `http://localhost:3001` |
|
||||||
|
| `SENDGRID_API_KEY` | SendGrid key for email notifications | _(optional — logs if absent)_ |
|
||||||
|
| `SENDGRID_FROM_EMAIL` | Sender email address | `noreply@edr-platform.com` |
|
||||||
|
|
||||||
|
## API Modules
|
||||||
|
|
||||||
|
| Tag | Base path | Description |
|
||||||
|
| -------------- | ----------------- | ---------------------------------------- |
|
||||||
|
| Auth | `/auth` | Register, login, JWT |
|
||||||
|
| Stations | `/stations` | Station directory |
|
||||||
|
| Fleet | `/fleet` | Train services, coaches, seat batches |
|
||||||
|
| Schedule | `/schedule` | Trips, fare rules, status updates |
|
||||||
|
| Search | `/search` | Trip search, fare quotes |
|
||||||
|
| Seats | `/seats` | Seat maps, holds, releases |
|
||||||
|
| Booking | `/bookings` | Create, retrieve, cancel bookings |
|
||||||
|
| Payment | `/payments` | Initiate payment, refunds, methods |
|
||||||
|
| Tickets | `/tickets` | QR ticket generation and validation |
|
||||||
|
| Passenger | `/passengers` | Profiles, traveler profiles, saved routes|
|
||||||
|
| Notifications | `/notifications` | In-app notifications |
|
||||||
|
| Loyalty | `/loyalty` | Points, tiers, rewards |
|
||||||
|
| Wallet | `/wallet` | Balance, top-up, ledger |
|
||||||
|
| Promotions | `/promos` | Active promos, promo code validation |
|
||||||
|
| Live Tracking | `/live` | Real-time trip status, crowd signals |
|
||||||
|
| Support | `/support` | FAQ, chat conversations |
|
||||||
|
| Dashboard | `/dashboard` | Home screen aggregate |
|
||||||
|
|
||||||
|
## Seed Credentials
|
||||||
|
|
||||||
|
After running `prisma:seed`:
|
||||||
|
|
||||||
|
| Role | Email | Password |
|
||||||
|
| --------- | ------------------------ | ------------- |
|
||||||
|
| Passenger | `kelemu@email.com` | `password123` |
|
||||||
|
| Admin | `admin@edr-platform.com` | `admin123` |
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image (run from monorepo root)
|
||||||
|
docker build -f apps/edr-passenger-api/Dockerfile -t edr-passenger-api .
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -p 4000:4000 --env-file apps/edr-passenger-api/.env edr-passenger-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | ------------------------------ |
|
||||||
|
| `pnpm dev` | Start with hot-reload |
|
||||||
|
| `pnpm build` | Compile to `dist/` |
|
||||||
|
| `pnpm start` | Run compiled output |
|
||||||
|
| `pnpm test` | Run unit tests |
|
||||||
|
| `pnpm lint` | ESLint |
|
||||||
|
| `pnpm type-check` | TypeScript type check |
|
||||||
|
| `pnpm prisma:generate` | Regenerate Prisma client |
|
||||||
|
| `pnpm prisma:migrate` | Run pending migrations |
|
||||||
|
| `pnpm prisma:seed` | Seed the database |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── common/ # PrismaService, JwtGuard, JwtStrategy, filters, interceptors
|
||||||
|
├── config/ # app.config.ts, database.config.ts
|
||||||
|
└── modules/
|
||||||
|
├── auth/
|
||||||
|
├── stations/
|
||||||
|
├── fleet/
|
||||||
|
├── schedules/
|
||||||
|
├── search/
|
||||||
|
├── seats/
|
||||||
|
├── bookings/
|
||||||
|
├── payments/
|
||||||
|
├── tickets/
|
||||||
|
├── passengers/
|
||||||
|
├── notifications/
|
||||||
|
├── loyalty/
|
||||||
|
├── wallet/
|
||||||
|
├── promos/
|
||||||
|
├── live/
|
||||||
|
├── support/
|
||||||
|
└── dashboard/
|
||||||
|
prisma/
|
||||||
|
├── schema.prisma
|
||||||
|
├── seed.ts
|
||||||
|
└── migrations/
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# App
|
# App
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3002
|
PORT=4000
|
||||||
|
|
||||||
# Database
|
# Database (Prisma)
|
||||||
DB_HOST=localhost
|
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger
|
||||||
DB_PORT=5434
|
|
||||||
DB_NAME=edr_passenger
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=
|
|
||||||
|
|
||||||
# JWT (provided by external auth package — placeholder only)
|
# CORS
|
||||||
JWT_SECRET=
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
PORTAL_URL=http://localhost:3001
|
||||||
|
|
||||||
# Redis
|
# JWT
|
||||||
REDIS_HOST=localhost
|
JWT_SECRET=edr-platform-secret-change-in-production
|
||||||
REDIS_PORT=6379
|
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
|
FROM deps AS build
|
||||||
COPY apps/edr-passenger-api ./apps/edr-passenger-api
|
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
|
RUN pnpm --filter @edr/passenger-api build
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
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=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/dist ./dist
|
||||||
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
|
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"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"plugins": ["@nestjs/swagger"],
|
||||||
|
"tsConfigPath": "tsconfig.build.json",
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,71 @@
|
|||||||
{
|
{
|
||||||
"name": "@edr/passenger-api",
|
"name": "@edr/passenger-api",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "EDR Passenger Management API",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"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": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
|
||||||
"@edr/types": "workspace:*",
|
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@nestjs/testing": "^11.0.0",
|
"@prisma/client": "^5.8.0",
|
||||||
|
"@sendgrid/mail": "^8.1.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.0",
|
||||||
"pg": "^8.13.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20"
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"tsconfig-paths": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
"@edr/tsconfig": "workspace:*",
|
"@edr/tsconfig": "workspace:*",
|
||||||
"@nestjs/cli": "^10.4.5",
|
"@nestjs/cli": "^11.0.21",
|
||||||
"@nestjs/schematics": "^10.2.2",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^10.4.6",
|
"@nestjs/testing": "^11.1.19",
|
||||||
"@types/express": "^5.0.0",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"prisma": "^5.8.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.1.1",
|
||||||
"ts-loader": "^9.5.1",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"typescript": "^5.3.3"
|
||||||
"typescript": "^5.5.4"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import appConfig from "./config/app.config";
|
import { PrismaModule } from './common/prisma.module';
|
||||||
import databaseConfig from "./config/database.config";
|
import appConfig from './config/app.config';
|
||||||
|
import dbConfig from './config/database.config';
|
||||||
import { TicketsModule } from "./modules/tickets/tickets.module";
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { SchedulesModule } from "./modules/schedules/schedules.module";
|
import { StationsModule } from './modules/stations/stations.module';
|
||||||
import { PassengersModule } from "./modules/passengers/passengers.module";
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
import { SeatsModule } from "./modules/seats/seats.module";
|
import { SchedulesModule } from './modules/schedules/schedules.module';
|
||||||
import { StationsModule } from "./modules/stations/stations.module";
|
import { SearchModule } from './modules/search/search.module';
|
||||||
import { PaymentsModule } from "./modules/payments/payments.module";
|
import { SeatsModule } from './modules/seats/seats.module';
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }),
|
||||||
isGlobal: true,
|
ScheduleModule.forRoot(),
|
||||||
load: [appConfig, databaseConfig],
|
EventEmitterModule.forRoot(),
|
||||||
}),
|
PrismaModule,
|
||||||
TypeOrmModule.forRootAsync({
|
AuthModule,
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
|
||||||
config.get<TypeOrmModuleOptions>("database")!,
|
|
||||||
}),
|
|
||||||
TicketsModule,
|
|
||||||
SchedulesModule,
|
|
||||||
PassengersModule,
|
|
||||||
SeatsModule,
|
|
||||||
StationsModule,
|
StationsModule,
|
||||||
|
FleetModule,
|
||||||
|
SchedulesModule,
|
||||||
|
SearchModule,
|
||||||
|
SeatsModule,
|
||||||
|
BookingsModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
TicketsModule,
|
||||||
|
PassengersModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
LoyaltyModule,
|
||||||
|
WalletModule,
|
||||||
|
PromosModule,
|
||||||
|
LiveModule,
|
||||||
|
SupportModule,
|
||||||
|
DashboardModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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", () => ({
|
export default registerAs('app', () => ({
|
||||||
env: process.env.NODE_ENV ?? "development",
|
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||||
port: parseInt(process.env.PORT ?? "3002", 10),
|
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
|
||||||
apiPrefix: "api",
|
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 { registerAs } from '@nestjs/config';
|
||||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
|
||||||
|
|
||||||
export default registerAs(
|
export default registerAs('database', () => ({
|
||||||
"database",
|
url: process.env.DATABASE_URL,
|
||||||
(): 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",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,35 +1,62 @@
|
|||||||
import "reflect-metadata";
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import {
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
HttpExceptionFilter,
|
import { AppModule } from './app.module';
|
||||||
ResponseTransformInterceptor,
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
createValidationPipe,
|
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
|
||||||
} from "@edr/api-common";
|
|
||||||
|
|
||||||
import { AppModule } from "./app.module";
|
|
||||||
|
|
||||||
async function bootstrap() {
|
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.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle("EDR Passenger API")
|
.setTitle('EDR Passenger API')
|
||||||
.setDescription("API for the EDR Passenger Management application")
|
.setDescription(
|
||||||
.setVersion("0.1.0")
|
'Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.',
|
||||||
.addBearerAuth()
|
)
|
||||||
|
.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();
|
.build();
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
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);
|
await app.listen(port);
|
||||||
// eslint-disable-next-line no-console
|
console.log(`🚀 EDR Passenger API running on port ${port}`);
|
||||||
console.log(`[passenger-api] listening on http://localhost:${port}`);
|
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
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({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] })
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [NotificationsService],
|
|
||||||
exports: [NotificationsService],
|
|
||||||
})
|
|
||||||
export class NotificationsModule {}
|
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()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
constructor(private prisma: PrismaService) {
|
||||||
|
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
|
||||||
/**
|
|
||||||
* 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}`);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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 {
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
Controller,
|
import { PassengersService } from './passengers.service';
|
||||||
Get,
|
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
|
||||||
Param,
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
import { CreatePassengerDto } from "./dto/create-passenger.dto";
|
@ApiTags('Passenger')
|
||||||
import { PassengersService } from "./passengers.service";
|
@Controller('passengers')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiTags("passengers")
|
@ApiBearerAuth('JWT-auth')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
|
||||||
@Controller("passengers")
|
|
||||||
export class PassengersController {
|
export class PassengersController {
|
||||||
constructor(private readonly passengersService: PassengersService) {}
|
constructor(private service: PassengersService) {}
|
||||||
|
@Get(':id/profile') @ApiOperation({ summary: 'Get passenger profile' }) getProfile(@Param('id') id: string) { return this.service.getProfile(id); }
|
||||||
@Post()
|
@Get(':id/stats') @ApiOperation({ summary: 'Get passenger stats' }) getStats(@Param('id') id: string) { return this.service.getStats(id); }
|
||||||
@ApiOperation({ summary: "Register a new passenger" })
|
@Post('traveler-profiles') @ApiOperation({ summary: 'Add traveler profile (family member)' }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); }
|
||||||
create(@Body() dto: CreatePassengerDto) {
|
@Get(':id/traveler-profiles') @ApiOperation({ summary: 'Get traveler profiles for passenger' }) getTravelerProfiles(@Param('id') id: string) { return this.service.getTravelerProfiles(id); }
|
||||||
return this.passengersService.create(dto);
|
@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); }
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { PassengersController } from './passengers.controller';
|
||||||
|
import { PassengersService } from './passengers.service';
|
||||||
|
|
||||||
import { Passenger } from "./entities/passenger.entity";
|
@Module({ controllers: [PassengersController], providers: [PassengersService] })
|
||||||
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],
|
|
||||||
})
|
|
||||||
export class PassengersModule {}
|
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 { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreatePassengerDto } from "./dto/create-passenger.dto";
|
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
|
||||||
import { Passenger } from "./entities/passenger.entity";
|
|
||||||
import { PassengersRepository } from "./passengers.repository";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PassengersService {
|
export class PassengersService {
|
||||||
constructor(private readonly passengersRepository: PassengersRepository) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Register a new passenger. */
|
async getProfile(passengerId: string) {
|
||||||
create(dto: CreatePassengerDto): Promise<Passenger> {
|
const p = await this.prisma.passenger.findUnique({
|
||||||
return this.passengersRepository.create(dto);
|
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). */
|
async getStats(passengerId: string) {
|
||||||
findAll(): Promise<Passenger[]> {
|
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
||||||
return this.passengersRepository.findAll({ order: { fullName: "ASC" } });
|
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. */
|
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||||
async findById(id: string): Promise<Passenger> {
|
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
|
||||||
const passenger = await this.passengersRepository.findById(id);
|
|
||||||
if (!passenger) {
|
|
||||||
throw new NotFoundException(`Passenger ${id} not found`);
|
|
||||||
}
|
|
||||||
return passenger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
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('Payment')
|
||||||
|
@Controller('payments')
|
||||||
@ApiTags("payments")
|
@UseGuards(JwtGuard)
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
@ApiBearerAuth('JWT-auth')
|
||||||
@Controller("payments")
|
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private readonly paymentsService: PaymentsService) {}
|
constructor(private service: PaymentsService) {}
|
||||||
|
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
||||||
@Get("ticket/:ticketId")
|
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
||||||
@ApiOperation({ summary: "List payments for a ticket" })
|
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
|
||||||
findByTicket(@Param("ticketId", ParseUUIDPipe) ticketId: string) {
|
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
|
||||||
return this.paymentsService.findByTicket(ticketId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
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";
|
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
|
||||||
import { PaymentsController } from "./payments.controller";
|
|
||||||
import { PaymentsService } from "./payments.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([Payment])],
|
|
||||||
controllers: [PaymentsController],
|
|
||||||
providers: [PaymentsService],
|
|
||||||
exports: [PaymentsService],
|
|
||||||
})
|
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -1,21 +1,81 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Repository } from "typeorm";
|
import { SeatsService } from '../seats/seats.service';
|
||||||
|
import { TicketsService } from '../tickets/tickets.service';
|
||||||
import { Payment } from "./entities/payment.entity";
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
|
||||||
|
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Payment)
|
private prisma: PrismaService,
|
||||||
private readonly paymentsRepository: Repository<Payment>,
|
private seatsService: SeatsService,
|
||||||
|
private ticketsService: TicketsService,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List payments associated with a ticket. */
|
async initiatePayment(dto: InitiatePaymentDto) {
|
||||||
findByTicket(ticketId: string): Promise<Payment[]> {
|
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
|
||||||
return this.paymentsRepository.find({
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
where: { ticketId },
|
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
|
||||||
order: { createdAt: "DESC" },
|
|
||||||
|
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 {
|
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
Controller,
|
import { SchedulesService } from './schedules.service';
|
||||||
Get,
|
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
|
||||||
Param,
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
import { CreateScheduleDto } from "./dto/create-schedule.dto";
|
@ApiTags('Schedule')
|
||||||
import { SchedulesService } from "./schedules.service";
|
@Controller('schedule')
|
||||||
|
|
||||||
@ApiTags("schedules")
|
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
|
||||||
@Controller("schedules")
|
|
||||||
export class SchedulesController {
|
export class SchedulesController {
|
||||||
constructor(private readonly schedulesService: SchedulesService) {}
|
constructor(private service: SchedulesService) {}
|
||||||
|
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
|
||||||
@Post()
|
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
|
||||||
@ApiOperation({ summary: "Publish a new train schedule" })
|
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
|
||||||
create(@Body() dto: CreateScheduleDto) {
|
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
|
||||||
return this.schedulesService.create(dto);
|
@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' })
|
||||||
@Get()
|
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||||
@ApiOperation({ summary: "List all schedules" })
|
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
|
||||||
findAll() {
|
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
|
||||||
return this.schedulesService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id")
|
|
||||||
@ApiOperation({ summary: "Get a schedule by ID" })
|
|
||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.schedulesService.findById(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { SchedulesController } from './schedules.controller';
|
||||||
|
import { SchedulesService } from './schedules.service';
|
||||||
|
|
||||||
import { Schedule } from "./entities/schedule.entity";
|
@Module({ controllers: [SchedulesController], providers: [SchedulesService] })
|
||||||
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],
|
|
||||||
})
|
|
||||||
export class SchedulesModule {}
|
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 { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreateScheduleDto } from "./dto/create-schedule.dto";
|
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
|
||||||
import { Schedule } from "./entities/schedule.entity";
|
|
||||||
import { SchedulesRepository } from "./schedules.repository";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService {
|
||||||
constructor(private readonly schedulesRepository: SchedulesRepository) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Publish a new train schedule. */
|
async createTrip(dto: CreateTripDto) {
|
||||||
create(dto: CreateScheduleDto): Promise<Schedule> {
|
const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt);
|
||||||
return this.schedulesRepository.create({
|
return this.prisma.trip.create({
|
||||||
...dto,
|
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 },
|
||||||
departureTime: new Date(dto.departureTime),
|
include: { service: true, originStation: true, destinationStation: true },
|
||||||
arrivalTime: new Date(dto.arrivalTime),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List every published schedule. */
|
async getTrip(id: string) {
|
||||||
findAll(): Promise<Schedule[]> {
|
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' } } } });
|
||||||
return this.schedulesRepository.findAll({
|
if (!trip) throw new NotFoundException('Trip not found');
|
||||||
order: { departureTime: "ASC" },
|
return trip;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single schedule by ID. */
|
updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); }
|
||||||
async findById(id: string): Promise<Schedule> {
|
|
||||||
const schedule = await this.schedulesRepository.findById(id);
|
createFareRule(dto: CreateFareRuleDto) {
|
||||||
if (!schedule) {
|
return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } });
|
||||||
throw new NotFoundException(`Schedule ${id} not found`);
|
}
|
||||||
}
|
|
||||||
return schedule;
|
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 { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
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')
|
||||||
|
@Controller('seats')
|
||||||
@ApiTags("seats")
|
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
|
||||||
@Controller("seats")
|
|
||||||
export class SeatsController {
|
export class SeatsController {
|
||||||
constructor(private readonly seatsService: SeatsService) {}
|
constructor(private service: SeatsService) {}
|
||||||
|
@Get('seatmap/:tripId') @ApiOperation({ summary: 'Get seat map for a trip' })
|
||||||
@Get("schedule/:scheduleId")
|
getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); }
|
||||||
@ApiOperation({ summary: "List seats for a schedule" })
|
@Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Hold seats for 15 minutes' })
|
||||||
findBySchedule(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
|
||||||
return this.seatsService.findBySchedule(scheduleId);
|
@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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { SeatsController } from './seats.controller';
|
||||||
|
import { SeatsService } from './seats.service';
|
||||||
|
|
||||||
import { Seat } from "./entities/seat.entity";
|
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
|
||||||
import { SeatsController } from "./seats.controller";
|
|
||||||
import { SeatsService } from "./seats.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([Seat])],
|
|
||||||
controllers: [SeatsController],
|
|
||||||
providers: [SeatsService],
|
|
||||||
exports: [SeatsService],
|
|
||||||
})
|
|
||||||
export class SeatsModule {}
|
export class SeatsModule {}
|
||||||
|
|||||||
@@ -1,21 +1,50 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Repository } from "typeorm";
|
import { HoldSeatsDto } from './seats.dto';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Seat } from "./entities/seat.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatsService {
|
export class SeatsService {
|
||||||
constructor(
|
constructor(private prisma: PrismaService) {}
|
||||||
@InjectRepository(Seat)
|
|
||||||
private readonly seatsRepository: Repository<Seat>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** List every seat on a given schedule, ordered by seat number. */
|
async getSeatMap(tripId: string, coachId?: string) {
|
||||||
findBySchedule(scheduleId: string): Promise<Seat[]> {
|
const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } });
|
||||||
return this.seatsRepository.find({
|
return {
|
||||||
where: { scheduleId },
|
coaches: coaches.map((coach) => ({
|
||||||
order: { seatNumber: "ASC" },
|
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 {
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
Controller,
|
import { StationsService } from './stations.service';
|
||||||
Get,
|
import { CreateStationDto } from './stations.dto';
|
||||||
Param,
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
import { CreateStationDto } from "./dto/create-station.dto";
|
@ApiTags('Stations')
|
||||||
import { StationsService } from "./stations.service";
|
@Controller('stations')
|
||||||
|
|
||||||
@ApiTags("stations")
|
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
|
||||||
@Controller("stations")
|
|
||||||
export class StationsController {
|
export class StationsController {
|
||||||
constructor(private readonly stationsService: StationsService) {}
|
constructor(private service: StationsService) {}
|
||||||
|
@Get() @ApiOperation({ summary: 'List all stations' }) findAll() { return this.service.findAll(); }
|
||||||
@Post()
|
@Get(':id') @ApiOperation({ summary: 'Get station by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
@ApiOperation({ summary: "Register a new station" })
|
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create station' })
|
||||||
create(@Body() dto: CreateStationDto) {
|
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { StationsController } from './stations.controller';
|
||||||
|
import { StationsService } from './stations.service';
|
||||||
|
|
||||||
import { Station } from "./entities/station.entity";
|
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
|
||||||
import { StationsController } from "./stations.controller";
|
|
||||||
import { StationsService } from "./stations.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([Station])],
|
|
||||||
controllers: [StationsController],
|
|
||||||
providers: [StationsService],
|
|
||||||
exports: [StationsService],
|
|
||||||
})
|
|
||||||
export class StationsModule {}
|
export class StationsModule {}
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Repository } from "typeorm";
|
import { CreateStationDto } from './stations.dto';
|
||||||
|
|
||||||
import { CreateStationDto } from "./dto/create-station.dto";
|
|
||||||
import { Station } from "./entities/station.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StationsService {
|
export class StationsService {
|
||||||
constructor(
|
constructor(private prisma: PrismaService) {}
|
||||||
@InjectRepository(Station)
|
findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); }
|
||||||
private readonly stationsRepository: Repository<Station>,
|
async findOne(id: string) {
|
||||||
) {}
|
const s = await this.prisma.station.findUnique({ where: { id } });
|
||||||
|
if (!s) throw new NotFoundException('Station not found');
|
||||||
/** Register a new station. */
|
return s;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user