From 39ba561d8f6cf790241aa77aead3f4e064330721 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 13 May 2026 16:58:49 +0300 Subject: [PATCH] Initial commit of edr-passenger-api alpha version --- README.md | 142 +++- apps/edr-passenger-api/.env.example | 30 +- apps/edr-passenger-api/.eslintrc.js | 1 + apps/edr-passenger-api/Dockerfile | 4 +- apps/edr-passenger-api/nest-cli.json | 5 +- apps/edr-passenger-api/package.json | 78 +- .../migration.sql | 690 ++++++++++++++++++ .../prisma/migrations/migration_lock.toml | 3 + .../edr-passenger-api/prisma/reset-admin.d.ts | 2 + .../prisma/reset-admin.d.ts.map | 1 + apps/edr-passenger-api/prisma/reset-admin.js | 49 ++ .../prisma/reset-admin.js.map | 1 + apps/edr-passenger-api/prisma/reset-admin.ts | 16 + apps/edr-passenger-api/prisma/schema.prisma | 573 +++++++++++++++ apps/edr-passenger-api/prisma/seed.d.ts | 2 + apps/edr-passenger-api/prisma/seed.d.ts.map | 1 + apps/edr-passenger-api/prisma/seed.js | 73 ++ apps/edr-passenger-api/prisma/seed.js.map | 1 + apps/edr-passenger-api/prisma/seed.ts | 48 ++ apps/edr-passenger-api/src/app.module.ts | 69 +- .../common/filters/http-exception.filter.ts | 53 +- .../response-transform.interceptor.ts | 13 +- .../edr-passenger-api/src/common/jwt.guard.ts | 5 + .../src/common/jwt.strategy.ts | 17 + .../src/common/pipes/validation.pipe.ts | 1 - .../src/common/prisma.module.ts | 6 + .../src/common/prisma.service.ts | 8 + .../src/config/app.config.ts | 12 +- .../src/config/database.config.ts | 21 +- apps/edr-passenger-api/src/main.ts | 71 +- .../src/modules/auth/auth.controller.ts | 18 + .../src/modules/auth/auth.dto.ts | 14 + .../src/modules/auth/auth.module.ts | 24 + .../src/modules/auth/auth.service.ts | 42 ++ .../modules/bookings/bookings.controller.ts | 16 + .../src/modules/bookings/bookings.dto.ts | 22 + .../src/modules/bookings/bookings.module.ts | 8 + .../src/modules/bookings/bookings.service.ts | 70 ++ .../modules/dashboard/dashboard.controller.ts | 14 + .../src/modules/dashboard/dashboard.module.ts | 6 + .../modules/dashboard/dashboard.service.ts | 45 ++ .../src/modules/fleet/fleet.controller.ts | 18 + .../src/modules/fleet/fleet.dto.ts | 20 + .../src/modules/fleet/fleet.module.ts | 6 + .../src/modules/fleet/fleet.service.ts | 26 + .../src/modules/live/live.controller.ts | 16 + .../src/modules/live/live.dto.ts | 11 + .../src/modules/live/live.module.ts | 6 + .../src/modules/live/live.service.ts | 35 + .../src/modules/loyalty/loyalty.controller.ts | 15 + .../src/modules/loyalty/loyalty.module.ts | 6 + .../src/modules/loyalty/loyalty.service.ts | 43 ++ .../notifications/notifications.controller.ts | 24 + .../notifications/notifications.dto.ts | 19 + .../notifications/notifications.module.ts | 11 +- .../notifications/notifications.service.ts | 51 +- .../passengers/dto/create-passenger.dto.ts | 20 - .../passengers/entities/passenger.entity.ts | 20 - .../passengers/passengers.controller.ts | 50 +- .../src/modules/passengers/passengers.dto.ts | 19 + .../modules/passengers/passengers.module.ts | 17 +- .../passengers/passengers.repository.ts | 21 - .../modules/passengers/passengers.service.ts | 66 +- .../payments/entities/payment.entity.ts | 37 - .../src/modules/payments/payments.adapters.ts | 10 + .../modules/payments/payments.controller.ts | 28 +- .../src/modules/payments/payments.dto.ts | 22 + .../src/modules/payments/payments.module.ts | 18 +- .../src/modules/payments/payments.service.ts | 84 ++- .../src/modules/promos/promos.controller.ts | 14 + .../src/modules/promos/promos.dto.ts | 13 + .../src/modules/promos/promos.module.ts | 6 + .../src/modules/promos/promos.service.ts | 20 + .../schedules/dto/create-schedule.dto.ts | 35 - .../schedules/entities/schedule.entity.ts | 32 - .../modules/schedules/schedules.controller.ts | 52 +- .../src/modules/schedules/schedules.dto.ts | 25 + .../src/modules/schedules/schedules.module.ts | 17 +- .../modules/schedules/schedules.repository.ts | 16 - .../modules/schedules/schedules.service.ts | 52 +- .../src/modules/search/search.controller.ts | 12 + .../src/modules/search/search.dto.ts | 18 + .../src/modules/search/search.module.ts | 6 + .../src/modules/search/search.service.ts | 50 ++ .../src/modules/seats/entities/seat.entity.ts | 26 - .../src/modules/seats/seats.controller.ts | 28 +- .../src/modules/seats/seats.dto.ts | 9 + .../src/modules/seats/seats.module.ts | 16 +- .../src/modules/seats/seats.service.ts | 57 +- .../stations/dto/create-station.dto.ts | 23 - .../stations/entities/station.entity.ts | 35 - .../modules/stations/stations.controller.ts | 46 +- .../src/modules/stations/stations.dto.ts | 11 + .../src/modules/stations/stations.module.ts | 16 +- .../src/modules/stations/stations.service.ts | 39 +- .../src/modules/support/support.controller.ts | 15 + .../src/modules/support/support.module.ts | 6 + .../src/modules/support/support.service.ts | 35 + .../modules/tickets/dto/create-ticket.dto.ts | 35 - .../modules/tickets/dto/filter-ticket.dto.ts | 29 - .../modules/tickets/entities/ticket.entity.ts | 32 - .../src/modules/tickets/tickets.controller.ts | 56 +- .../src/modules/tickets/tickets.module.ts | 17 +- .../src/modules/tickets/tickets.repository.ts | 21 - .../src/modules/tickets/tickets.service.ts | 72 +- .../src/modules/wallet/wallet.controller.ts | 14 + .../src/modules/wallet/wallet.module.ts | 6 + .../src/modules/wallet/wallet.service.ts | 21 + apps/edr-passenger-api/test/app.e2e-spec.ts | 27 +- apps/edr-passenger-api/test/jest-e2e.json | 7 + apps/edr-passenger-api/tsconfig.json | 6 +- pnpm-lock.yaml | 567 +++++++++++--- pnpm-workspace.yaml | 3 +- 113 files changed, 3602 insertions(+), 1035 deletions(-) create mode 100644 apps/edr-passenger-api/.eslintrc.js create mode 100644 apps/edr-passenger-api/prisma/migrations/20260513080558_initial_migration/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/migration_lock.toml create mode 100644 apps/edr-passenger-api/prisma/reset-admin.d.ts create mode 100644 apps/edr-passenger-api/prisma/reset-admin.d.ts.map create mode 100644 apps/edr-passenger-api/prisma/reset-admin.js create mode 100644 apps/edr-passenger-api/prisma/reset-admin.js.map create mode 100644 apps/edr-passenger-api/prisma/reset-admin.ts create mode 100644 apps/edr-passenger-api/prisma/schema.prisma create mode 100644 apps/edr-passenger-api/prisma/seed.d.ts create mode 100644 apps/edr-passenger-api/prisma/seed.d.ts.map create mode 100644 apps/edr-passenger-api/prisma/seed.js create mode 100644 apps/edr-passenger-api/prisma/seed.js.map create mode 100644 apps/edr-passenger-api/prisma/seed.ts create mode 100644 apps/edr-passenger-api/src/common/jwt.guard.ts create mode 100644 apps/edr-passenger-api/src/common/jwt.strategy.ts delete mode 100644 apps/edr-passenger-api/src/common/pipes/validation.pipe.ts create mode 100644 apps/edr-passenger-api/src/common/prisma.module.ts create mode 100644 apps/edr-passenger-api/src/common/prisma.service.ts create mode 100644 apps/edr-passenger-api/src/modules/auth/auth.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/auth/auth.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/auth/auth.module.ts create mode 100644 apps/edr-passenger-api/src/modules/auth/auth.service.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/bookings.module.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/bookings.service.ts create mode 100644 apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts create mode 100644 apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts create mode 100644 apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/fleet/fleet.module.ts create mode 100644 apps/edr-passenger-api/src/modules/fleet/fleet.service.ts create mode 100644 apps/edr-passenger-api/src/modules/live/live.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/live/live.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/live/live.module.ts create mode 100644 apps/edr-passenger-api/src/modules/live/live.service.ts create mode 100644 apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/loyalty/loyalty.module.ts create mode 100644 apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts create mode 100644 apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/passengers/dto/create-passenger.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/passengers/entities/passenger.entity.ts create mode 100644 apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/passengers/passengers.repository.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/entities/payment.entity.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/payments.adapters.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/payments.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/promos/promos.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/promos/promos.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/promos/promos.module.ts create mode 100644 apps/edr-passenger-api/src/modules/promos/promos.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/schedules/dto/create-schedule.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/schedules/entities/schedule.entity.ts create mode 100644 apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/schedules/schedules.repository.ts create mode 100644 apps/edr-passenger-api/src/modules/search/search.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/search/search.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/search/search.module.ts create mode 100644 apps/edr-passenger-api/src/modules/search/search.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/seats/entities/seat.entity.ts create mode 100644 apps/edr-passenger-api/src/modules/seats/seats.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/stations/dto/create-station.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/stations/entities/station.entity.ts create mode 100644 apps/edr-passenger-api/src/modules/stations/stations.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/support/support.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/support/support.module.ts create mode 100644 apps/edr-passenger-api/src/modules/support/support.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/tickets/dto/create-ticket.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/tickets/dto/filter-ticket.dto.ts delete mode 100644 apps/edr-passenger-api/src/modules/tickets/entities/ticket.entity.ts delete mode 100644 apps/edr-passenger-api/src/modules/tickets/tickets.repository.ts create mode 100644 apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/wallet/wallet.module.ts create mode 100644 apps/edr-passenger-api/src/modules/wallet/wallet.service.ts create mode 100644 apps/edr-passenger-api/test/jest-e2e.json diff --git a/README.md b/README.md index 302dab618..33f22dc0a 100644 --- a/README.md +++ b/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/ +``` diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index eba901b1e..bee21ed65 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -1,17 +1,23 @@ # App NODE_ENV=development -PORT=3002 +PORT=4000 -# Database -DB_HOST=localhost -DB_PORT=5434 -DB_NAME=edr_passenger -DB_USER=postgres -DB_PASSWORD= +# Database (Prisma) +DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger -# JWT (provided by external auth package — placeholder only) -JWT_SECRET= +# CORS +FRONTEND_URL=http://localhost:3000 +PORTAL_URL=http://localhost:3001 -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 +# JWT +JWT_SECRET=edr-platform-secret-change-in-production +JWT_EXPIRES_IN=7d + +# SendGrid +SENDGRID_API_KEY= +SENDGRID_FROM_EMAIL=noreply@edr-platform.com + +# Telebirr +TELEBIRR_API_URL=https://api.telebirr.com +TELEBIRR_APP_ID= +TELEBIRR_APP_KEY= diff --git a/apps/edr-passenger-api/.eslintrc.js b/apps/edr-passenger-api/.eslintrc.js new file mode 100644 index 000000000..32e54266b --- /dev/null +++ b/apps/edr-passenger-api/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@edr/eslint-config/nestjs'); diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 5e778138e..0306b0f70 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -10,6 +10,7 @@ RUN pnpm install --frozen-lockfile --filter @edr/passenger-api... FROM deps AS build COPY apps/edr-passenger-api ./apps/edr-passenger-api +RUN pnpm --filter @edr/passenger-api run prisma:generate RUN pnpm --filter @edr/passenger-api build FROM node:20-alpine AS runtime @@ -21,6 +22,7 @@ COPY --from=deps /app/node_modules ./../../node_modules COPY --from=deps /app/apps/edr-passenger-api/node_modules ./node_modules COPY --from=build /app/apps/edr-passenger-api/dist ./dist COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json +COPY --from=build /app/apps/edr-passenger-api/prisma ./prisma -EXPOSE 3002 +EXPOSE 4000 CMD ["node", "dist/main.js"] diff --git a/apps/edr-passenger-api/nest-cli.json b/apps/edr-passenger-api/nest-cli.json index f9aa683b1..6e3db4584 100644 --- a/apps/edr-passenger-api/nest-cli.json +++ b/apps/edr-passenger-api/nest-cli.json @@ -1,8 +1,9 @@ { - "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "plugins": ["@nestjs/swagger"], + "tsConfigPath": "tsconfig.build.json", + "watchAssets": true } } diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index c7f84d634..87d0b4bbf 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -1,69 +1,71 @@ { "name": "@edr/passenger-api", - "version": "0.0.0", + "version": "1.0.0", "private": true, - "description": "EDR Passenger Management API", "scripts": { "dev": "nest start --watch", "build": "nest build", "start": "node dist/main.js", + "start:prod": "node dist/main.js", "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "ts-node prisma/seed.ts" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" }, "dependencies": { - "@edr/api-common": "workspace:*", - "@edr/types": "workspace:*", "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", - "@nestjs/swagger": "^11.4.2", - "@nestjs/typeorm": "^11.0.1", - "@nestjs/config": "^4.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.0", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.19", + "@nestjs/event-emitter": "^2.0.4", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^11.1.19", + "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^7.4.0", + "@prisma/client": "^5.8.0", + "@sendgrid/mail": "^8.1.0", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "pg": "^8.13.0", + "class-validator": "^0.14.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.20" + "swagger-ui-express": "^5.0.0", + "tsconfig-paths": "^4.2.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", - "@nestjs/cli": "^10.4.5", - "@nestjs/schematics": "^10.2.2", - "@nestjs/testing": "^10.4.6", - "@types/express": "^5.0.0", - "@types/jest": "^29.5.13", - "@types/node": "^20.14.0", + "@nestjs/cli": "^11.0.21", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.19", + "@types/bcrypt": "^5.0.2", + "@types/jest": "^29.5.11", + "@types/node": "^20.10.6", + "@types/passport-jwt": "^4.0.1", + "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "jest": "^29.7.0", + "prisma": "^5.8.0", "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.1", + "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.3.3" }, "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], + "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], + "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "collectCoverageFrom": ["**/*.(t|j)s"], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/apps/edr-passenger-api/prisma/migrations/20260513080558_initial_migration/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260513080558_initial_migration/migration.sql new file mode 100644 index 000000000..5ba579dca --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260513080558_initial_migration/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/migration_lock.toml b/apps/edr-passenger-api/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/reset-admin.d.ts b/apps/edr-passenger-api/prisma/reset-admin.d.ts new file mode 100644 index 000000000..a4595fd95 --- /dev/null +++ b/apps/edr-passenger-api/prisma/reset-admin.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=reset-admin.d.ts.map \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/reset-admin.d.ts.map b/apps/edr-passenger-api/prisma/reset-admin.d.ts.map new file mode 100644 index 000000000..d0f62a26c --- /dev/null +++ b/apps/edr-passenger-api/prisma/reset-admin.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"reset-admin.d.ts","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/reset-admin.js b/apps/edr-passenger-api/prisma/reset-admin.js new file mode 100644 index 000000000..c00b804f6 --- /dev/null +++ b/apps/edr-passenger-api/prisma/reset-admin.js @@ -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 \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/reset-admin.js.map b/apps/edr-passenger-api/prisma/reset-admin.js.map new file mode 100644 index 000000000..a47b50176 --- /dev/null +++ b/apps/edr-passenger-api/prisma/reset-admin.js.map @@ -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"} \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/reset-admin.ts b/apps/edr-passenger-api/prisma/reset-admin.ts new file mode 100644 index 000000000..da9e5313f --- /dev/null +++ b/apps/edr-passenger-api/prisma/reset-admin.ts @@ -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()); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma new file mode 100644 index 000000000..f4103c7a6 --- /dev/null +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/edr-passenger-api/prisma/seed.d.ts b/apps/edr-passenger-api/prisma/seed.d.ts new file mode 100644 index 000000000..0986b1c3a --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=seed.d.ts.map \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/seed.d.ts.map b/apps/edr-passenger-api/prisma/seed.d.ts.map new file mode 100644 index 000000000..c1a0e0b3d --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/seed.js b/apps/edr-passenger-api/prisma/seed.js new file mode 100644 index 000000000..924e06d85 --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed.js @@ -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 \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/seed.js.map b/apps/edr-passenger-api/prisma/seed.js.map new file mode 100644 index 000000000..bcc581885 --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed.js.map @@ -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"} \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts new file mode 100644 index 000000000..f2bbb1332 --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -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()); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index d3542240f..5b594f16e 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,36 +1,51 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; - -import appConfig from "./config/app.config"; -import databaseConfig from "./config/database.config"; - -import { TicketsModule } from "./modules/tickets/tickets.module"; -import { SchedulesModule } from "./modules/schedules/schedules.module"; -import { PassengersModule } from "./modules/passengers/passengers.module"; -import { SeatsModule } from "./modules/seats/seats.module"; -import { StationsModule } from "./modules/stations/stations.module"; -import { PaymentsModule } from "./modules/payments/payments.module"; -import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { PrismaModule } from './common/prisma.module'; +import appConfig from './config/app.config'; +import dbConfig from './config/database.config'; +import { AuthModule } from './modules/auth/auth.module'; +import { StationsModule } from './modules/stations/stations.module'; +import { FleetModule } from './modules/fleet/fleet.module'; +import { SchedulesModule } from './modules/schedules/schedules.module'; +import { SearchModule } from './modules/search/search.module'; +import { SeatsModule } from './modules/seats/seats.module'; +import { BookingsModule } from './modules/bookings/bookings.module'; +import { PaymentsModule } from './modules/payments/payments.module'; +import { TicketsModule } from './modules/tickets/tickets.module'; +import { PassengersModule } from './modules/passengers/passengers.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; +import { LoyaltyModule } from './modules/loyalty/loyalty.module'; +import { WalletModule } from './modules/wallet/wallet.module'; +import { PromosModule } from './modules/promos/promos.module'; +import { LiveModule } from './modules/live/live.module'; +import { SupportModule } from './modules/support/support.module'; +import { DashboardModule } from './modules/dashboard/dashboard.module'; @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [appConfig, databaseConfig], - }), - TypeOrmModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): TypeOrmModuleOptions => - config.get("database")!, - }), - TicketsModule, - SchedulesModule, - PassengersModule, - SeatsModule, + ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }), + ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), + PrismaModule, + AuthModule, StationsModule, + FleetModule, + SchedulesModule, + SearchModule, + SeatsModule, + BookingsModule, PaymentsModule, + TicketsModule, + PassengersModule, NotificationsModule, + LoyaltyModule, + WalletModule, + PromosModule, + LiveModule, + SupportModule, + DashboardModule, ], }) export class AppModule {} diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index f31715918..d1f73bf71 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -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, + }); + } +} diff --git a/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts b/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts index 95f5ab53d..d1cd41c7b 100644 --- a/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts +++ b/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts @@ -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 implements NestInterceptor { + intercept(_ctx: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data) => ({ success: true, data, timestamp: new Date().toISOString() })), + ); + } +} diff --git a/apps/edr-passenger-api/src/common/jwt.guard.ts b/apps/edr-passenger-api/src/common/jwt.guard.ts new file mode 100644 index 000000000..f65f8455d --- /dev/null +++ b/apps/edr-passenger-api/src/common/jwt.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtGuard extends AuthGuard('jwt') {} diff --git a/apps/edr-passenger-api/src/common/jwt.strategy.ts b/apps/edr-passenger-api/src/common/jwt.strategy.ts new file mode 100644 index 000000000..7b62bec40 --- /dev/null +++ b/apps/edr-passenger-api/src/common/jwt.strategy.ts @@ -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 }; + } +} diff --git a/apps/edr-passenger-api/src/common/pipes/validation.pipe.ts b/apps/edr-passenger-api/src/common/pipes/validation.pipe.ts deleted file mode 100644 index f1665bedc..000000000 --- a/apps/edr-passenger-api/src/common/pipes/validation.pipe.ts +++ /dev/null @@ -1 +0,0 @@ -export { createValidationPipe } from "@edr/api-common"; diff --git a/apps/edr-passenger-api/src/common/prisma.module.ts b/apps/edr-passenger-api/src/common/prisma.module.ts new file mode 100644 index 000000000..dd4abed26 --- /dev/null +++ b/apps/edr-passenger-api/src/common/prisma.module.ts @@ -0,0 +1,6 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ providers: [PrismaService], exports: [PrismaService] }) +export class PrismaModule {} diff --git a/apps/edr-passenger-api/src/common/prisma.service.ts b/apps/edr-passenger-api/src/common/prisma.service.ts new file mode 100644 index 000000000..75d4eaa24 --- /dev/null +++ b/apps/edr-passenger-api/src/common/prisma.service.ts @@ -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(); } +} diff --git a/apps/edr-passenger-api/src/config/app.config.ts b/apps/edr-passenger-api/src/config/app.config.ts index 56d1d833b..78492fc0c 100644 --- a/apps/edr-passenger-api/src/config/app.config.ts +++ b/apps/edr-passenger-api/src/config/app.config.ts @@ -1,7 +1,9 @@ -import { registerAs } from "@nestjs/config"; +import { registerAs } from '@nestjs/config'; -export default registerAs("app", () => ({ - env: process.env.NODE_ENV ?? "development", - port: parseInt(process.env.PORT ?? "3002", 10), - apiPrefix: "api", +export default registerAs('app', () => ({ + port: parseInt(process.env.PORT ?? '4000', 10), + jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', + frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000', + portalUrl: process.env.PORTAL_URL ?? 'http://localhost:3001', })); diff --git a/apps/edr-passenger-api/src/config/database.config.ts b/apps/edr-passenger-api/src/config/database.config.ts index 319bdf8e7..4bc504729 100644 --- a/apps/edr-passenger-api/src/config/database.config.ts +++ b/apps/edr-passenger-api/src/config/database.config.ts @@ -1,18 +1,5 @@ -import { registerAs } from "@nestjs/config"; -import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { registerAs } from '@nestjs/config'; -export default registerAs( - "database", - (): TypeOrmModuleOptions => ({ - type: "postgres", - host: process.env.DB_HOST ?? "localhost", - port: parseInt(process.env.DB_PORT ?? "5434", 10), - username: process.env.DB_USER ?? "postgres", - password: process.env.DB_PASSWORD ?? "", - database: process.env.DB_NAME ?? "edr_passenger", - entities: [__dirname + "/../**/*.entity.{ts,js}"], - migrations: [__dirname + "/../../migrations/*.{ts,js}"], - synchronize: process.env.NODE_ENV === "development", - logging: process.env.NODE_ENV === "development", - }), -); +export default registerAs('database', () => ({ + url: process.env.DATABASE_URL, +})); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 2271dea3f..71de72738 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,35 +1,62 @@ -import "reflect-metadata"; -import { NestFactory } from "@nestjs/core"; -import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import { - HttpExceptionFilter, - ResponseTransformInterceptor, - createValidationPipe, -} from "@edr/api-common"; - -import { AppModule } from "./app.module"; +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor'; async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule); + + app.enableCors({ + origin: [ + process.env.FRONTEND_URL ?? 'http://localhost:3000', + process.env.PORTAL_URL ?? 'http://localhost:3001', + ], + }); - app.setGlobalPrefix("api"); - app.useGlobalPipes(createValidationPipe()); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); const config = new DocumentBuilder() - .setTitle("EDR Passenger API") - .setDescription("API for the EDR Passenger Management application") - .setVersion("0.1.0") - .addBearerAuth() + .setTitle('EDR Passenger API') + .setDescription( + 'Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.', + ) + .setVersion('1.0.0') + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, 'JWT-auth') + .addTag('Auth', 'Registration and login') + .addTag('Stations', 'Station directory') + .addTag('Fleet', 'Train services and coaches') + .addTag('Schedule', 'Trips and fare rules') + .addTag('Search', 'Trip search and fare quotes') + .addTag('Seats', 'Seat maps and holds') + .addTag('Booking', 'Booking lifecycle') + .addTag('Payment', 'Payment intents and refunds') + .addTag('Tickets', 'QR ticket generation and validation') + .addTag('Passenger', 'Profiles, traveler profiles, saved routes') + .addTag('Notifications', 'Push and email notifications') + .addTag('Loyalty', 'Points, tiers, and rewards') + .addTag('Wallet', 'Wallet balance and ledger') + .addTag('Promotions', 'Promo codes and campaigns') + .addTag('Live Tracking', 'Real-time trip status and crowd signals') + .addTag('Support', 'FAQ and chat support') + .addTag('Dashboard', 'Home dashboard aggregate') + .addServer('http://localhost:4000', 'Development') + .addServer('https://api.edr-platform.com', 'Production') .build(); + const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup("api/docs", app, document); + SwaggerModule.setup('api-docs', app, document, { + customSiteTitle: 'EDR Passenger API', + swaggerOptions: { persistAuthorization: true, docExpansion: 'none', filter: true }, + }); - const port = parseInt(process.env.PORT ?? "3002", 10); + const port = process.env.PORT ?? 4000; await app.listen(port); - // eslint-disable-next-line no-console - console.log(`[passenger-api] listening on http://localhost:${port}`); + console.log(`🚀 EDR Passenger API running on port ${port}`); + console.log(`📚 Swagger: http://localhost:${port}/api-docs`); } - bootstrap(); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts new file mode 100644 index 000000000..ea128b261 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts new file mode 100644 index 000000000..8cfe1ac65 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.module.ts b/apps/edr-passenger-api/src/modules/auth/auth.module.ts new file mode 100644 index 000000000..547937d74 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts new file mode 100644 index 000000000..7ba9937d8 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts @@ -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 } }; + } +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts new file mode 100644 index 000000000..eb7e533b5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts new file mode 100644 index 000000000..a2e208d2c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts new file mode 100644 index 000000000..5632af5d7 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts new file mode 100644 index 000000000..cb6158639 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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' } }); } + } +} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 000000000..781ae261e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 000000000..09b3717b4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 000000000..22b6ddeb1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -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 })), + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts new file mode 100644 index 000000000..1557c2ece --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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(); } +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts new file mode 100644 index 000000000..bef3b5a9a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -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[]; +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts new file mode 100644 index 000000000..e2f4a28c9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts new file mode 100644 index 000000000..6fa189666 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -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 }; + } +} diff --git a/apps/edr-passenger-api/src/modules/live/live.controller.ts b/apps/edr-passenger-api/src/modules/live/live.controller.ts new file mode 100644 index 000000000..a34e92e35 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/live/live.controller.ts @@ -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(); } +} diff --git a/apps/edr-passenger-api/src/modules/live/live.dto.ts b/apps/edr-passenger-api/src/modules/live/live.dto.ts new file mode 100644 index 000000000..9e4de96d5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/live/live.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/live/live.module.ts b/apps/edr-passenger-api/src/modules/live/live.module.ts new file mode 100644 index 000000000..268ba2385 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/live/live.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/live/live.service.ts b/apps/edr-passenger-api/src/modules/live/live.service.ts new file mode 100644 index 000000000..bfc538059 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/live/live.service.ts @@ -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' } }); } +} diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts new file mode 100644 index 000000000..7095110e4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.module.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.module.ts new file mode 100644 index 000000000..e1305d315 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts new file mode 100644 index 000000000..4cc69b214 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts @@ -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 = { 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 }; + } +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 000000000..076c023a1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts new file mode 100644 index 000000000..07bd9dd40 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index 39fe1b5c7..8a1322a50 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,9 +1,6 @@ -import { Module } from "@nestjs/common"; +import { Module } from '@nestjs/common'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; -import { NotificationsService } from "./notifications.service"; - -@Module({ - providers: [NotificationsService], - exports: [NotificationsService], -}) +@Module({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] }) export class NotificationsModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index b57bd14bc..a92c4c71f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,14 +1,45 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { PrismaService } from '../../common/prisma.service'; +import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto'; +import * as sgMail from '@sendgrid/mail'; @Injectable() export class NotificationsService { - private readonly logger = new Logger(NotificationsService.name); - - /** - * Dispatch a notification to a passenger (booking confirmation, schedule change, etc.). - * TODO: wire to email/SMS provider via a mailer service. - */ - async send(recipient: string, subject: string, body: string): Promise { - this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`); + constructor(private prisma: PrismaService) { + if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY); } -} + + private sanitize(value: string): string { + return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c)); + } + + async send(dto: SendNotificationDto) { + const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } }); + const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } }); + if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body)); + return notification; + } + + getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); } + + markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); } + + async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; } + + @OnEvent('booking.created') + async onBookingCreated(payload: any) { + await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } }); + } + + @OnEvent('payment.succeeded') + async onPaymentSucceeded(payload: any) { + await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } }); + } + + private async sendEmail(to: string, subject: string, text: string) { + if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; } + try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); } + catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); } + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/passengers/dto/create-passenger.dto.ts b/apps/edr-passenger-api/src/modules/passengers/dto/create-passenger.dto.ts deleted file mode 100644 index f2528b183..000000000 --- a/apps/edr-passenger-api/src/modules/passengers/dto/create-passenger.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/passengers/entities/passenger.entity.ts b/apps/edr-passenger-api/src/modules/passengers/entities/passenger.entity.ts deleted file mode 100644 index 52f7a6091..000000000 --- a/apps/edr-passenger-api/src/modules/passengers/entities/passenger.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 8a7b87555..5eb701c45 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,37 +1,19 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { PassengersService } from './passengers.service'; +import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto'; +import { JwtGuard } from '../../common/jwt.guard'; -import { CreatePassengerDto } from "./dto/create-passenger.dto"; -import { PassengersService } from "./passengers.service"; - -@ApiTags("passengers") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("passengers") +@ApiTags('Passenger') +@Controller('passengers') +@UseGuards(JwtGuard) +@ApiBearerAuth('JWT-auth') export class PassengersController { - constructor(private readonly passengersService: PassengersService) {} - - @Post() - @ApiOperation({ summary: "Register a new passenger" }) - create(@Body() dto: CreatePassengerDto) { - return this.passengersService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List all passengers" }) - findAll() { - return this.passengersService.findAll(); - } - - @Get(":id") - @ApiOperation({ summary: "Get a passenger by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.passengersService.findById(id); - } + constructor(private service: PassengersService) {} + @Get(':id/profile') @ApiOperation({ summary: 'Get passenger profile' }) getProfile(@Param('id') id: string) { return this.service.getProfile(id); } + @Get(':id/stats') @ApiOperation({ summary: 'Get passenger stats' }) getStats(@Param('id') id: string) { return this.service.getStats(id); } + @Post('traveler-profiles') @ApiOperation({ summary: 'Add traveler profile (family member)' }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); } + @Get(':id/traveler-profiles') @ApiOperation({ summary: 'Get traveler profiles for passenger' }) getTravelerProfiles(@Param('id') id: string) { return this.service.getTravelerProfiles(id); } + @Post('saved-routes') @ApiOperation({ summary: 'Save a route' }) createSavedRoute(@Body() dto: CreateSavedRouteDto) { return this.service.createSavedRoute(dto); } + @Get(':id/saved-routes') @ApiOperation({ summary: 'Get saved routes' }) getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts new file mode 100644 index 000000000..d76fbfc48 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts index 83c061d9c..c5c7c52e3 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts @@ -1,15 +1,6 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { PassengersController } from './passengers.controller'; +import { PassengersService } from './passengers.service'; -import { Passenger } from "./entities/passenger.entity"; -import { PassengersController } from "./passengers.controller"; -import { PassengersRepository } from "./passengers.repository"; -import { PassengersService } from "./passengers.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Passenger])], - controllers: [PassengersController], - providers: [PassengersService, PassengersRepository], - exports: [PassengersService], -}) +@Module({ controllers: [PassengersController], providers: [PassengersService] }) export class PassengersModule {} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.repository.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.repository.ts deleted file mode 100644 index 8fb2366e5..000000000 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.repository.ts +++ /dev/null @@ -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 { - constructor( - @InjectRepository(Passenger) - repository: Repository, - ) { - super(repository); - } - - /** Find a passenger by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); - } -} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 7e2f7f347..54222751e 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,29 +1,57 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreatePassengerDto } from "./dto/create-passenger.dto"; -import { Passenger } from "./entities/passenger.entity"; -import { PassengersRepository } from "./passengers.repository"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto'; @Injectable() export class PassengersService { - constructor(private readonly passengersRepository: PassengersRepository) {} + constructor(private prisma: PrismaService) {} - /** Register a new passenger. */ - create(dto: CreatePassengerDto): Promise { - return this.passengersRepository.create(dto); + async getProfile(passengerId: string) { + const p = await this.prisma.passenger.findUnique({ + where: { id: passengerId }, + include: { + user: { select: { fullName: true, email: true, phone: true } }, + bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } } }, + loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, + }, + }); + if (!p) throw new NotFoundException('Passenger not found'); + return { + id: p.id, + fullName: p.user.fullName, + email: p.user.email, + phone: p.user.phone, + createdAt: p.createdAt, + bookings: p.bookings.map((b) => ({ + id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt, + trip: { + number: b.trip.service.number, + origin: { id: b.trip.originStation.id, name: b.trip.originStation.name, code: b.trip.originStation.code, city: b.trip.originStation.city }, + destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city }, + departureAt: b.trip.departureAt, + }, + passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass } })), + })), + }; } - /** List every passenger (alphabetical). */ - findAll(): Promise { - return this.passengersRepository.findAll({ order: { fullName: "ASC" } }); + async getStats(passengerId: string) { + const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ + this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }), + this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }), + this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }), + ]); + const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100; + return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } - /** Get a single passenger by ID. */ - async findById(id: string): Promise { - const passenger = await this.passengersRepository.findById(id); - if (!passenger) { - throw new NotFoundException(`Passenger ${id} not found`); - } - return passenger; + createTravelerProfile(dto: CreateTravelerProfileDto) { + return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } }); } + + getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); } + + createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); } + + getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } } diff --git a/apps/edr-passenger-api/src/modules/payments/entities/payment.entity.ts b/apps/edr-passenger-api/src/modules/payments/entities/payment.entity.ts deleted file mode 100644 index 5941316e1..000000000 --- a/apps/edr-passenger-api/src/modules/payments/entities/payment.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts new file mode 100644 index 000000000..b686269c5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts @@ -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 { + 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 { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; } +export async function eBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; } +export async function cardAdapter(_a: number, ref: string): Promise { 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 { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 5a32d1664..b153ebf80 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,17 +1,17 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { PaymentsService } from './payments.service'; +import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto'; +import { JwtGuard } from '../../common/jwt.guard'; -import { PaymentsService } from "./payments.service"; - -@ApiTags("payments") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("payments") +@ApiTags('Payment') +@Controller('payments') +@UseGuards(JwtGuard) +@ApiBearerAuth('JWT-auth') export class PaymentsController { - constructor(private readonly paymentsService: PaymentsService) {} - - @Get("ticket/:ticketId") - @ApiOperation({ summary: "List payments for a ticket" }) - findByTicket(@Param("ticketId", ParseUUIDPipe) ticketId: string) { - return this.paymentsService.findByTicket(ticketId); - } + constructor(private service: PaymentsService) {} + @Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); } + @Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } + @Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } + @Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts new file mode 100644 index 000000000..798e4751d --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 37274e64d..9b2a3d738 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,14 +1,8 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { SeatsModule } from '../seats/seats.module'; +import { TicketsModule } from '../tickets/tickets.module'; -import { Payment } from "./entities/payment.entity"; -import { PaymentsController } from "./payments.controller"; -import { PaymentsService } from "./payments.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Payment])], - controllers: [PaymentsController], - providers: [PaymentsService], - exports: [PaymentsService], -}) +@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7817f5764..8f1df1c7b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,21 +1,81 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { Payment } from "./entities/payment.entity"; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto'; +import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters'; @Injectable() export class PaymentsService { constructor( - @InjectRepository(Payment) - private readonly paymentsRepository: Repository, + private prisma: PrismaService, + private seatsService: SeatsService, + private ticketsService: TicketsService, + private eventEmitter: EventEmitter2, ) {} - /** List payments associated with a ticket. */ - findByTicket(ticketId: string): Promise { - return this.paymentsRepository.find({ - where: { ticketId }, - order: { createdAt: "DESC" }, + async initiatePayment(dto: InitiatePaymentDto) { + const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); + if (!booking) throw new NotFoundException('Booking not found'); + if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable'); + + let result; + if (dto.method === 'WALLET') { + result = await this.prisma.$transaction(async (tx) => { + const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } }); + if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' }; + const newBalance = wallet.balanceMinor - booking.totalMinor; + await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } }); + await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } }); + return { success: true, providerRef: `WALLET-${Date.now()}` }; + }); + } else { + const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any; + result = await adapters[dto.method](booking.totalMinor, booking.bookingRef); + } + + const status = result.success ? 'SUCCEEDED' : 'FAILED'; + const intent = await this.prisma.paymentIntent.upsert({ + where: { bookingId: dto.bookingId }, + update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any }, + create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any }, }); + + if (result.success) { + await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); + await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } }); + await this.ticketsService.generate(dto.bookingId); + await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + this.eventEmitter.emit('payment.succeeded', { booking }); + } + + return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success }; + } + + async refund(dto: RefundDto) { + const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); + if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund'); + await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } }); + const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); + if (booking) { + await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } }); + } + return { refunded: true, bookingRef: booking?.bookingRef }; + } + + addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); } + + getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); } + + private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { + const points = Math.floor(amountMinor / 100); + const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); + if (!account) return; + const newBalance = account.pointsBalance + points; + const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE'; + await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); + await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); } } diff --git a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts new file mode 100644 index 000000000..ccb76e051 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts new file mode 100644 index 000000000..421269f5c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/promos/promos.module.ts b/apps/edr-passenger-api/src/modules/promos/promos.module.ts new file mode 100644 index 000000000..302f087d9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/promos/promos.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/promos/promos.service.ts b/apps/edr-passenger-api/src/modules/promos/promos.service.ts new file mode 100644 index 000000000..97a8ed275 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/promos/promos.service.ts @@ -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) } }); + } +} diff --git a/apps/edr-passenger-api/src/modules/schedules/dto/create-schedule.dto.ts b/apps/edr-passenger-api/src/modules/schedules/dto/create-schedule.dto.ts deleted file mode 100644 index 364b88d79..000000000 --- a/apps/edr-passenger-api/src/modules/schedules/dto/create-schedule.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/schedules/entities/schedule.entity.ts b/apps/edr-passenger-api/src/modules/schedules/entities/schedule.entity.ts deleted file mode 100644 index 7943ff3d6..000000000 --- a/apps/edr-passenger-api/src/modules/schedules/entities/schedule.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index e07516e23..9f3972a49 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,37 +1,21 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { SchedulesService } from './schedules.service'; +import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto'; +import { JwtGuard } from '../../common/jwt.guard'; -import { CreateScheduleDto } from "./dto/create-schedule.dto"; -import { SchedulesService } from "./schedules.service"; - -@ApiTags("schedules") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("schedules") +@ApiTags('Schedule') +@Controller('schedule') export class SchedulesController { - constructor(private readonly schedulesService: SchedulesService) {} - - @Post() - @ApiOperation({ summary: "Publish a new train schedule" }) - create(@Body() dto: CreateScheduleDto) { - return this.schedulesService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List all schedules" }) - findAll() { - return this.schedulesService.findAll(); - } - - @Get(":id") - @ApiOperation({ summary: "Get a schedule by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.schedulesService.findById(id); - } + constructor(private service: SchedulesService) {} + @Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' }) + createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); } + @Get('trips/:id') @ApiOperation({ summary: 'Get trip details' }) + getTrip(@Param('id') id: string) { return this.service.getTrip(id); } + @Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' }) + updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); } + @Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' }) + createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } + @Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' }) + getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts new file mode 100644 index 000000000..80638d5ae --- /dev/null +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 431b1a781..41e48bea6 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -1,15 +1,6 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { SchedulesController } from './schedules.controller'; +import { SchedulesService } from './schedules.service'; -import { Schedule } from "./entities/schedule.entity"; -import { SchedulesController } from "./schedules.controller"; -import { SchedulesRepository } from "./schedules.repository"; -import { SchedulesService } from "./schedules.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Schedule])], - controllers: [SchedulesController], - providers: [SchedulesService, SchedulesRepository], - exports: [SchedulesService], -}) +@Module({ controllers: [SchedulesController], providers: [SchedulesService] }) export class SchedulesModule {} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.repository.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.repository.ts deleted file mode 100644 index 075dc3c00..000000000 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.repository.ts +++ /dev/null @@ -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 { - constructor( - @InjectRepository(Schedule) - repository: Repository, - ) { - super(repository); - } -} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7e7684724..a97b54fd6 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,35 +1,39 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreateScheduleDto } from "./dto/create-schedule.dto"; -import { Schedule } from "./entities/schedule.entity"; -import { SchedulesRepository } from "./schedules.repository"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto'; @Injectable() export class SchedulesService { - constructor(private readonly schedulesRepository: SchedulesRepository) {} + constructor(private prisma: PrismaService) {} - /** Publish a new train schedule. */ - create(dto: CreateScheduleDto): Promise { - return this.schedulesRepository.create({ - ...dto, - departureTime: new Date(dto.departureTime), - arrivalTime: new Date(dto.arrivalTime), + async createTrip(dto: CreateTripDto) { + const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt); + return this.prisma.trip.create({ + data: { serviceId: dto.serviceId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60000), stopsCount: dto.stopsCount ?? 0 }, + include: { service: true, originStation: true, destinationStation: true }, }); } - /** List every published schedule. */ - findAll(): Promise { - return this.schedulesRepository.findAll({ - order: { departureTime: "ASC" }, - }); + async getTrip(id: string) { + const trip = await this.prisma.trip.findUnique({ where: { id }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }); + if (!trip) throw new NotFoundException('Trip not found'); + return trip; } - /** Get a single schedule by ID. */ - async findById(id: string): Promise { - const schedule = await this.schedulesRepository.findById(id); - if (!schedule) { - throw new NotFoundException(`Schedule ${id} not found`); - } - return schedule; + updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); } + + createFareRule(dto: CreateFareRuleDto) { + return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } }); + } + + async getFare(tripId: string, serviceClass: string) { + const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } }); + if (!trip) throw new NotFoundException('Trip not found'); + const route = `${trip.originStation.code}-${trip.destinationStation.code}`; + const rule = await this.prisma.fareRule.findFirst({ + where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] }, + orderBy: { validFrom: 'desc' }, + }); + return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass }; } } diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts new file mode 100644 index 000000000..9409873ed --- /dev/null +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts new file mode 100644 index 000000000..e4e3d2cae --- /dev/null +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts new file mode 100644 index 000000000..e156d3cde --- /dev/null +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts new file mode 100644 index 000000000..046bdc70d --- /dev/null +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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; + } +} diff --git a/apps/edr-passenger-api/src/modules/seats/entities/seat.entity.ts b/apps/edr-passenger-api/src/modules/seats/entities/seat.entity.ts deleted file mode 100644 index 353280473..000000000 --- a/apps/edr-passenger-api/src/modules/seats/entities/seat.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a120905f4..a872f337d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -1,17 +1,17 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { SeatsService } from './seats.service'; +import { HoldSeatsDto } from './seats.dto'; +import { JwtGuard } from '../../common/jwt.guard'; -import { SeatsService } from "./seats.service"; - -@ApiTags("seats") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("seats") +@ApiTags('Seats') +@Controller('seats') export class SeatsController { - constructor(private readonly seatsService: SeatsService) {} - - @Get("schedule/:scheduleId") - @ApiOperation({ summary: "List seats for a schedule" }) - findBySchedule(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) { - return this.seatsService.findBySchedule(scheduleId); - } + constructor(private service: SeatsService) {} + @Get('seatmap/:tripId') @ApiOperation({ summary: 'Get seat map for a trip' }) + getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); } + @Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Hold seats for 15 minutes' }) + holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); } + @Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' }) + releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts new file mode 100644 index 000000000..a36d76f18 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 98d2c5dd7..c9394983d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -1,14 +1,6 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { SeatsController } from './seats.controller'; +import { SeatsService } from './seats.service'; -import { Seat } from "./entities/seat.entity"; -import { SeatsController } from "./seats.controller"; -import { SeatsService } from "./seats.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Seat])], - controllers: [SeatsController], - providers: [SeatsService], - exports: [SeatsService], -}) +@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] }) export class SeatsModule {} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 8fd33b103..90234b6f2 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1,21 +1,50 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { Seat } from "./entities/seat.entity"; +import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { HoldSeatsDto } from './seats.dto'; +import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class SeatsService { - constructor( - @InjectRepository(Seat) - private readonly seatsRepository: Repository, - ) {} + constructor(private prisma: PrismaService) {} - /** List every seat on a given schedule, ordered by seat number. */ - findBySchedule(scheduleId: string): Promise { - return this.seatsRepository.find({ - where: { scheduleId }, - order: { seatNumber: "ASC" }, + async getSeatMap(tripId: string, coachId?: string) { + const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } }); + return { + coaches: coaches.map((coach) => ({ + id: coach.id, + name: `Coach ${coach.label}`, + type: coach.serviceClass, + seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })), + })), + }; + } + + async holdSeats(dto: HoldSeatsDto) { + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + const hold = await this.prisma.$transaction(async (tx) => { + const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } }); + const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date())); + if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable'); + await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); + return tx.seatHold.create({ data: { tripId: dto.tripId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } }); }); + return { id: hold.id, tripId: dto.tripId, seatIds: dto.seatIds, expiresAt }; + } + + async releaseHold(holdId: string) { + const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); + if (!hold) throw new NotFoundException('Hold not found'); + await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } }); + await this.prisma.seatHold.delete({ where: { id: holdId } }); + return { released: true }; + } + + async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); } + async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); } + + @Cron(CronExpression.EVERY_MINUTE) + async expireHolds() { + const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); + for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); } } } diff --git a/apps/edr-passenger-api/src/modules/stations/dto/create-station.dto.ts b/apps/edr-passenger-api/src/modules/stations/dto/create-station.dto.ts deleted file mode 100644 index ed98925c6..000000000 --- a/apps/edr-passenger-api/src/modules/stations/dto/create-station.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/stations/entities/station.entity.ts b/apps/edr-passenger-api/src/modules/stations/entities/station.entity.ts deleted file mode 100644 index 431a6a963..000000000 --- a/apps/edr-passenger-api/src/modules/stations/entities/station.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index d2343bbd4..a1be3762d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -1,37 +1,15 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { StationsService } from './stations.service'; +import { CreateStationDto } from './stations.dto'; +import { JwtGuard } from '../../common/jwt.guard'; -import { CreateStationDto } from "./dto/create-station.dto"; -import { StationsService } from "./stations.service"; - -@ApiTags("stations") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("stations") +@ApiTags('Stations') +@Controller('stations') export class StationsController { - constructor(private readonly stationsService: StationsService) {} - - @Post() - @ApiOperation({ summary: "Register a new station" }) - create(@Body() dto: CreateStationDto) { - return this.stationsService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List all stations" }) - findAll() { - return this.stationsService.findAll(); - } - - @Get(":id") - @ApiOperation({ summary: "Get a station by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.stationsService.findById(id); - } + constructor(private service: StationsService) {} + @Get() @ApiOperation({ summary: 'List all stations' }) findAll() { return this.service.findAll(); } + @Get(':id') @ApiOperation({ summary: 'Get station by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); } + @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create station' }) + create(@Body() dto: CreateStationDto) { return this.service.create(dto); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts new file mode 100644 index 000000000..05973bfe4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/stations/stations.module.ts b/apps/edr-passenger-api/src/modules/stations/stations.module.ts index 5a12eb064..28ee6d121 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.module.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.module.ts @@ -1,14 +1,6 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { StationsController } from './stations.controller'; +import { StationsService } from './stations.service'; -import { Station } from "./entities/station.entity"; -import { StationsController } from "./stations.controller"; -import { StationsService } from "./stations.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Station])], - controllers: [StationsController], - providers: [StationsService], - exports: [StationsService], -}) +@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] }) export class StationsModule {} diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index efd360f39..be4de158f 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,34 +1,15 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { CreateStationDto } from "./dto/create-station.dto"; -import { Station } from "./entities/station.entity"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateStationDto } from './stations.dto'; @Injectable() export class StationsService { - constructor( - @InjectRepository(Station) - private readonly stationsRepository: Repository, - ) {} - - /** Register a new station. */ - create(dto: CreateStationDto): Promise { - const entity = this.stationsRepository.create(dto); - return this.stationsRepository.save(entity); - } - - /** List every station (alphabetical). */ - findAll(): Promise { - return this.stationsRepository.find({ order: { name: "ASC" } }); - } - - /** Get a single station by ID. */ - async findById(id: string): Promise { - const station = await this.stationsRepository.findOne({ where: { id } }); - if (!station) { - throw new NotFoundException(`Station ${id} not found`); - } - return station; + constructor(private prisma: PrismaService) {} + findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); } + async findOne(id: string) { + const s = await this.prisma.station.findUnique({ where: { id } }); + if (!s) throw new NotFoundException('Station not found'); + return s; } + create(dto: CreateStationDto) { return this.prisma.station.create({ data: dto }); } } diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts new file mode 100644 index 000000000..c13b3cd33 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -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); } +} diff --git a/apps/edr-passenger-api/src/modules/support/support.module.ts b/apps/edr-passenger-api/src/modules/support/support.module.ts new file mode 100644 index 000000000..17f139a07 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts new file mode 100644 index 000000000..f0d2f9034 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -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.'; + } +} diff --git a/apps/edr-passenger-api/src/modules/tickets/dto/create-ticket.dto.ts b/apps/edr-passenger-api/src/modules/tickets/dto/create-ticket.dto.ts deleted file mode 100644 index 597a49649..000000000 --- a/apps/edr-passenger-api/src/modules/tickets/dto/create-ticket.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/tickets/dto/filter-ticket.dto.ts b/apps/edr-passenger-api/src/modules/tickets/dto/filter-ticket.dto.ts deleted file mode 100644 index b31197cfb..000000000 --- a/apps/edr-passenger-api/src/modules/tickets/dto/filter-ticket.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-passenger-api/src/modules/tickets/entities/ticket.entity.ts b/apps/edr-passenger-api/src/modules/tickets/entities/ticket.entity.ts deleted file mode 100644 index 866fb03fd..000000000 --- a/apps/edr-passenger-api/src/modules/tickets/entities/ticket.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { BaseEntity } from "@edr/api-common"; -import { Passenger } from "@edr/types"; -import { Column, Entity } from "typeorm"; - -@Entity({ name: "tickets" }) -export class Ticket extends BaseEntity { - @Column({ name: "reference", type: "varchar", length: 64, unique: true }) - reference!: string; - - @Column({ name: "passenger_id", type: "uuid" }) - passengerId!: string; - - @Column({ name: "schedule_id", type: "uuid" }) - scheduleId!: string; - - @Column({ name: "seat_id", type: "uuid" }) - seatId!: string; - - @Column({ - name: "status", - type: "enum", - enum: Passenger.TicketStatus, - default: Passenger.TicketStatus.Reserved, - }) - status!: Passenger.TicketStatus; - - @Column({ name: "price_paid", type: "numeric", precision: 10, scale: 2 }) - pricePaid!: number; - - @Column({ name: "issued_at", type: "timestamptz" }) - issuedAt!: Date; -} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index aefd203ee..407af7234 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,48 +1,14 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - Param, - ParseUUIDPipe, - Post, - Query, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { TicketsService } from './tickets.service'; +import { JwtGuard } from '../../common/jwt.guard'; -import { CreateTicketDto } from "./dto/create-ticket.dto"; -import { FilterTicketDto } from "./dto/filter-ticket.dto"; -import { TicketsService } from "./tickets.service"; - -@ApiTags("tickets") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("tickets") +@ApiTags('Tickets') +@Controller('tickets') +@UseGuards(JwtGuard) +@ApiBearerAuth('JWT-auth') export class TicketsController { - constructor(private readonly ticketsService: TicketsService) {} - - @Post() - @ApiOperation({ summary: "Issue a new passenger ticket" }) - create(@Body() dto: CreateTicketDto) { - return this.ticketsService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List passenger tickets (paginated)" }) - findAll(@Query() filter: FilterTicketDto) { - return this.ticketsService.findAll(filter); - } - - @Get(":id") - @ApiOperation({ summary: "Get a ticket by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.ticketsService.findById(id); - } - - @Delete(":id") - @HttpCode(204) - @ApiOperation({ summary: "Cancel a ticket" }) - cancel(@Param("id", ParseUUIDPipe) id: string) { - return this.ticketsService.cancel(id); - } + constructor(private service: TicketsService) {} + @Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } + @Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index ce339d218..f1c01ab9d 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -1,15 +1,6 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { TicketsController } from './tickets.controller'; +import { TicketsService } from './tickets.service'; -import { Ticket } from "./entities/ticket.entity"; -import { TicketsController } from "./tickets.controller"; -import { TicketsRepository } from "./tickets.repository"; -import { TicketsService } from "./tickets.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Ticket])], - controllers: [TicketsController], - providers: [TicketsService, TicketsRepository], - exports: [TicketsService], -}) +@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] }) export class TicketsModule {} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.repository.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.repository.ts deleted file mode 100644 index 36dd7b6cf..000000000 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.repository.ts +++ /dev/null @@ -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 { Ticket } from "./entities/ticket.entity"; - -@Injectable() -export class TicketsRepository extends BaseRepository { - constructor( - @InjectRepository(Ticket) - repository: Repository, - ) { - super(repository); - } - - /** Find a ticket by its passenger-facing reference. */ - findByReference(reference: string): Promise { - return this.repository.findOne({ where: { reference } }); - } -} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 8d313f2fe..1c9e05830 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,53 +1,43 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreateTicketDto } from "./dto/create-ticket.dto"; -import { FilterTicketDto } from "./dto/filter-ticket.dto"; -import { Ticket } from "./entities/ticket.entity"; -import { TicketsRepository } from "./tickets.repository"; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import * as QRCode from 'qrcode'; @Injectable() export class TicketsService { - constructor(private readonly ticketsRepository: TicketsRepository) {} + constructor(private prisma: PrismaService) {} - /** Issue a new passenger ticket. */ - create(dto: CreateTicketDto): Promise { - return this.ticketsRepository.create({ - ...dto, - issuedAt: new Date(dto.issuedAt), + async generate(bookingId: string) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } }, }); + if (!booking) throw new NotFoundException('Booking not found'); + const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); + return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } }); } - /** Paginated list of tickets matching the filter. */ - async findAll( - filter: FilterTicketDto, - ): Promise<{ items: Ticket[]; total: number }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const [items, total] = await this.ticketsRepository.findAndCount({ - where: { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.passengerId ? { passengerId: filter.passengerId } : {}), - ...(filter.scheduleId ? { scheduleId: filter.scheduleId } : {}), - }, - skip: (page - 1) * pageSize, - take: pageSize, - order: { createdAt: "DESC" }, + 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 } } } }, ticket: true }, }); - return { items, total }; + if (!booking?.ticket) throw new NotFoundException('Ticket not found'); + const seat = booking.seats[0]; + return { + id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, + fromStationName: booking.trip.originStation.name, toStationName: booking.trip.destinationStation.name, + departureAt: booking.trip.departureAt, trainName: booking.trip.service.name, + coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName, + priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, + }; } - /** Get a single ticket by ID. */ - async findById(id: string): Promise { - const ticket = await this.ticketsRepository.findById(id); - if (!ticket) { - throw new NotFoundException(`Ticket ${id} not found`); - } - return ticket; - } - - /** Cancel and soft-delete a ticket. */ - async cancel(id: string): Promise { - await this.findById(id); - await this.ticketsRepository.softDelete(id); + async validate(bookingRef: string, validatorId: string) { + const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); + if (!booking) throw new NotFoundException('Booking not found'); + const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + if (ticket.validatedAt) throw new BadRequestException('Ticket already validated'); + return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts new file mode 100644 index 000000000..8d100863c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { WalletService } from './wallet.service'; +import { JwtGuard } from '../../common/jwt.guard'; + +@ApiTags('Wallet') +@Controller('wallet') +@UseGuards(JwtGuard) +@ApiBearerAuth('JWT-auth') +export class WalletController { + constructor(private service: WalletService) {} + @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } + @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } +} diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.module.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.module.ts new file mode 100644 index 000000000..fb05cf67b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common'; +import { WalletController } from './wallet.controller'; +import { WalletService } from './wallet.service'; + +@Module({ controllers: [WalletController], providers: [WalletService] }) +export class WalletModule {} diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts new file mode 100644 index 000000000..a83d97e0b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts @@ -0,0 +1,21 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; + +@Injectable() +export class WalletService { + constructor(private prisma: PrismaService) {} + + async getWallet(passengerId: string) { + const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); + if (!wallet) throw new NotFoundException('Wallet not found'); + return wallet; + } + + async topUp(passengerId: string, amountMinor: number, description = 'Top-up') { + const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId } }); + if (!wallet) throw new NotFoundException('Wallet not found'); + const newBalance = wallet.balanceMinor + amountMinor; + await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } }); + return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } }); + } +} diff --git a/apps/edr-passenger-api/test/app.e2e-spec.ts b/apps/edr-passenger-api/test/app.e2e-spec.ts index 295966095..e5c55ebc2 100644 --- a/apps/edr-passenger-api/test/app.e2e-spec.ts +++ b/apps/edr-passenger-api/test/app.e2e-spec.ts @@ -1,10 +1,9 @@ -import { INestApplication } from "@nestjs/common"; -import { Test, TestingModule } from "@nestjs/testing"; -import request from "supertest"; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; -import { AppModule } from "../src/app.module"; - -describe("Passenger API (e2e)", () => { +describe('Passenger API (e2e)', () => { let app: INestApplication; beforeAll(async () => { @@ -13,6 +12,7 @@ describe("Passenger API (e2e)", () => { }).compile(); app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); await app.init(); }); @@ -20,7 +20,18 @@ describe("Passenger API (e2e)", () => { await app.close(); }); - it("GET /api/tickets returns a 200 with a list", () => { - return request(app.getHttpServer()).get("/api/tickets").expect(200); + it('GET /stations returns 200', () => { + return request(app.getHttpServer()).get('/stations').expect(200); + }); + + it('GET /search returns 200', () => { + return request(app.getHttpServer()).get('/search').expect(404); // POST only + }); + + it('POST /auth/login with bad credentials returns 401', () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'nobody@test.com', password: 'wrong' }) + .expect(401); }); }); diff --git a/apps/edr-passenger-api/test/jest-e2e.json b/apps/edr-passenger-api/test/jest-e2e.json new file mode 100644 index 000000000..0f4a0d400 --- /dev/null +++ b/apps/edr-passenger-api/test/jest-e2e.json @@ -0,0 +1,7 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testRegex": ".e2e-spec.ts$", + "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "testEnvironment": "node" +} diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index e8cec7548..e9fbe1ffe 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -6,7 +6,11 @@ "rootDir": "./src", "noEmit": false, "incremental": true, - "tsBuildInfoFile": "./.tsbuildinfo" + "tsBuildInfoFile": "./.tsbuildinfo", + "paths": { "@/*": ["./src/*"] }, + "strictPropertyInitialization": false, + "noUnusedLocals": false, + "noUnusedParameters": false }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afe2891b7..bf0d892f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,60 +281,69 @@ importers: apps/edr-passenger-api: dependencies: - '@edr/api-common': - specifier: workspace:* - version: link:../../packages/api-common - '@edr/types': - specifier: workspace:* - version: link:../../packages/types - '@nestjs/cli': - specifier: ^11.0.0 - version: 11.0.21(@types/node@20.19.41)(prettier@3.8.3) '@nestjs/common': specifier: ^11.0.0 version: 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': - specifier: ^4.0.0 + specifier: ^4.0.4 version: 4.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': - specifier: ^11.0.0 + specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/microservices': - specifier: ^11.0.0 - version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^2.0.4 + version: 2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': - specifier: ^11.0.0 + specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) - '@nestjs/schematics': - specifier: ^11.0.0 - version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/schedule': + specifier: ^6.1.3 + version: 6.1.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) '@nestjs/swagger': - specifier: ^11.4.2 - version: 11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/testing': - specifier: ^11.0.0 - version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19) - '@nestjs/typeorm': - specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.29(babel-plugin-macros@3.1.0)(pg@8.20.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))) + specifier: ^7.4.0 + version: 7.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^5.8.0 + version: 5.22.0(prisma@5.22.0) + '@sendgrid/mail': + specifier: ^8.1.0 + version: 8.1.6 + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 class-transformer: specifier: ^0.5.1 version: 0.5.1 class-validator: - specifier: ^0.14.1 + specifier: ^0.14.0 version: 0.14.4 - pg: - specifier: ^8.13.0 - version: 8.20.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + qrcode: + specifier: ^1.5.3 + version: 1.5.4 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.2 - typeorm: - specifier: ^0.3.20 - version: 0.3.29(babel-plugin-macros@3.1.0)(pg@8.20.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)) + swagger-ui-express: + specifier: ^5.0.0 + version: 5.0.1(express@5.2.1) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@edr/eslint-config': specifier: workspace:* @@ -342,38 +351,50 @@ importers: '@edr/tsconfig': specifier: workspace:* version: link:../../packages/config/tsconfig - '@types/express': - specifier: ^5.0.0 - version: 5.0.6 + '@nestjs/cli': + specifier: ^11.0.21 + version: 11.0.21(@types/node@20.19.41)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.1.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.1.19 + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 '@types/jest': - specifier: ^29.5.13 + specifier: ^29.5.11 version: 29.5.14 '@types/node': - specifier: ^20.14.0 + specifier: ^20.10.6 version: 20.19.41 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.6 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)) + prisma: + specifier: ^5.8.0 + version: 5.22.0 supertest: specifier: ^7.0.0 version: 7.2.2 ts-jest: - specifier: ^29.2.5 + specifier: ^29.1.1 version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3) - ts-loader: - specifier: ^9.5.1 - version: 9.5.7(typescript@5.9.3)(webpack@5.106.0) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.41)(typescript@5.9.3) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 typescript: - specifier: ^5.5.4 + specifier: ^5.3.3 version: 5.9.3 apps/edr-passenger-web/backoffice: @@ -1501,6 +1522,9 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} @@ -1708,11 +1732,35 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@2.1.1': + resolution: {integrity: sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/jwt@11.0.2': resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} peerDependencies: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/mapped-types@2.0.5': + resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/mapped-types@2.1.1': resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} peerDependencies: @@ -1762,6 +1810,12 @@ packages: nats: optional: true + '@nestjs/passport@10.0.3': + resolution: {integrity: sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/passport@11.0.5': resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} peerDependencies: @@ -1774,6 +1828,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.3': + resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.1.0': resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} peerDependencies: @@ -1800,6 +1860,23 @@ packages: class-validator: optional: true + '@nestjs/swagger@7.4.2': + resolution: {integrity: sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==} + peerDependencies: + '@fastify/static': ^6.0.0 || ^7.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.19': resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==} peerDependencies: @@ -1877,6 +1954,30 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2808,6 +2909,18 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sendgrid/client@8.1.6': + resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==} + engines: {node: '>=12.*'} + + '@sendgrid/helpers@8.0.0': + resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==} + engines: {node: '>= 12.0.0'} + + '@sendgrid/mail@8.1.6': + resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==} + engines: {node: '>=12.*'} + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -3247,6 +3360,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -3339,6 +3455,12 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -3360,9 +3482,21 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -4345,6 +4479,10 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -4943,6 +5081,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} @@ -5633,6 +5775,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -6964,6 +7109,10 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -7038,6 +7187,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -7056,9 +7209,15 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -7423,6 +7582,10 @@ packages: peerDependencies: react: 18.3.1 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -7741,6 +7904,9 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-addon-api@8.7.0: resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} engines: {node: ^18 || ^20 || >= 21} @@ -8103,6 +8269,9 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -8334,6 +8503,11 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -9488,9 +9662,18 @@ packages: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} + swagger-ui-dist@5.17.14: + resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} + swagger-ui-dist@5.32.4: resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -10772,7 +10955,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -10941,7 +11124,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -11268,7 +11451,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -11339,7 +11522,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -11756,7 +11939,8 @@ snapshots: transitivePeerDependencies: - encoding - supports-color - optional: true + + '@microsoft/tsdoc@0.15.1': {} '@microsoft/tsdoc@0.16.0': {} @@ -11972,12 +12156,32 @@ snapshots: '@nestjs/microservices': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@10.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/jsonwebtoken': 9.0.10 jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@2.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11995,6 +12199,11 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 + '@nestjs/passport@10.0.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/passport@11.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -12012,6 +12221,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -12040,6 +12255,21 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@nestjs/swagger@7.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.17.14 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/microservices@11.1.19)(@nestjs/platform-express@11.1.19)': dependencies: '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -12102,6 +12332,31 @@ snapshots: '@popperjs/core@2.11.8': {} + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -13127,6 +13382,24 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@sendgrid/client@8.1.6': + dependencies: + '@sendgrid/helpers': 8.0.0 + axios: 1.16.0 + transitivePeerDependencies: + - debug + + '@sendgrid/helpers@8.0.0': + dependencies: + deepmerge: 4.3.1 + + '@sendgrid/mail@8.1.6': + dependencies: + '@sendgrid/client': 8.1.6 + '@sendgrid/helpers': 8.0.0 + transitivePeerDependencies: + - debug + '@sinclair/typebox@0.27.10': {} '@sinonjs/commons@3.0.1': @@ -13450,7 +13723,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -13719,6 +13992,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.19.41 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -13822,6 +14099,12 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 20.19.41 + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 20.19.41 + + '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} '@types/ms@2.1.0': {} @@ -13840,8 +14123,26 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 20.19.41 + '@types/qs@6.15.1': {} '@types/raf@3.4.3': @@ -13926,7 +14227,7 @@ snapshots: '@typescript-eslint/types': 8.59.3 '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -13936,7 +14237,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) '@typescript-eslint/types': 8.59.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13955,7 +14256,7 @@ snapshots: '@typescript-eslint/types': 8.59.3 '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) '@typescript-eslint/utils': 8.59.3(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -13970,7 +14271,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) '@typescript-eslint/types': 8.59.3 '@typescript-eslint/visitor-keys': 8.59.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.0 tinyglobby: 0.2.16 @@ -14704,8 +15005,7 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abbrev@1.1.1: - optional: true + abbrev@1.1.1: {} abort-controller@3.0.0: dependencies: @@ -14749,10 +15049,9 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true agent-base@7.1.4: {} @@ -14994,8 +15293,7 @@ snapshots: append-field@1.0.0: {} - aproba@2.1.0: - optional: true + aproba@2.1.0: {} archiver-utils@2.1.0: dependencies: @@ -15037,7 +15335,6 @@ snapshots: dependencies: delegates: 1.0.0 readable-stream: 3.6.2 - optional: true arg@4.1.3: {} @@ -15218,7 +15515,7 @@ snapshots: axios@1.16.0: dependencies: - follow-redirects: 1.16.0(debug@3.2.7(supports-color@5.5.0)) + follow-redirects: 1.16.0 form-data: 4.0.5 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -15313,6 +15610,14 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -15374,7 +15679,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -15732,8 +16037,7 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@2.0.0: - optional: true + chownr@2.0.0: {} chrome-trace-event@1.0.4: {} @@ -15862,8 +16166,7 @@ snapshots: dependencies: color-name: 2.1.0 - color-support@1.1.3: - optional: true + color-support@1.1.3: {} colorette@2.0.20: {} @@ -15931,8 +16234,7 @@ snapshots: console-browserify@1.2.0: {} - console-control-strings@1.1.0: - optional: true + console-control-strings@1.1.0: {} constants-browserify@1.0.0: {} @@ -16073,6 +16375,11 @@ snapshots: create-require@1.1.1: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@5.1.0: dependencies: lru-cache: 4.1.5 @@ -16233,12 +16540,20 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@3.2.7(supports-color@5.5.0): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 5.5.0 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -16323,8 +16638,7 @@ snapshots: delayed-stream@1.0.0: {} - delegates@1.0.0: - optional: true + delegates@1.0.0: {} depd@1.1.2: {} @@ -16743,7 +17057,7 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7 is-core-module: 2.16.2 resolve: 2.0.0-next.6 transitivePeerDependencies: @@ -16751,7 +17065,7 @@ snapshots: eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@8.57.1): dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 @@ -16766,7 +17080,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7 doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 @@ -16845,7 +17159,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -16923,6 +17237,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter2@6.4.9: {} + eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} @@ -17077,7 +17393,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -17278,7 +17594,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -17324,6 +17640,8 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@3.2.7(supports-color@5.5.0)): optionalDependencies: debug: 3.2.7(supports-color@5.5.0) @@ -17437,7 +17755,6 @@ snapshots: fs-minipass@2.1.0: dependencies: minipass: 3.3.6 - optional: true fs-monkey@1.1.0: {} @@ -17483,7 +17800,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 wide-align: 1.1.5 - optional: true generator-function@2.0.1: {} @@ -17703,8 +18019,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - has-unicode@2.0.1: - optional: true + has-unicode@2.0.1: {} has-value@0.3.1: dependencies: @@ -17841,7 +18156,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -17874,15 +18189,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18279,7 +18593,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -18649,6 +18963,10 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -18725,6 +19043,19 @@ snapshots: jsonparse@1.3.1: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.3 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.0 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -18770,12 +19101,23 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jws@3.2.3: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -18897,7 +19239,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -19112,6 +19454,8 @@ snapshots: dependencies: react: 18.3.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -19125,7 +19469,6 @@ snapshots: make-dir@3.1.0: dependencies: semver: 6.3.1 - optional: true make-dir@4.0.0: dependencies: @@ -19341,10 +19684,8 @@ snapshots: minipass@3.3.6: dependencies: yallist: 4.0.0 - optional: true - minipass@5.0.0: - optional: true + minipass@5.0.0: {} minipass@7.1.3: {} @@ -19352,7 +19693,6 @@ snapshots: dependencies: minipass: 3.3.6 yallist: 4.0.0 - optional: true mixin-deep@1.3.2: dependencies: @@ -19363,8 +19703,7 @@ snapshots: dependencies: minimist: 1.2.8 - mkdirp@1.0.4: - optional: true + mkdirp@1.0.4: {} moment@2.30.1: {} @@ -19497,6 +19836,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@5.1.0: {} + node-addon-api@8.7.0: {} node-emoji@1.11.0: @@ -19556,7 +19897,6 @@ snapshots: nopt@5.0.0: dependencies: abbrev: 1.1.1 - optional: true normalize-package-data@2.5.0: dependencies: @@ -19593,7 +19933,6 @@ snapshots: console-control-strings: 1.1.0 gauge: 3.0.2 set-blocking: 2.0.0 - optional: true nth-check@2.1.1: dependencies: @@ -19880,6 +20219,8 @@ snapshots: path-to-regexp@0.1.13: {} + path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} path-type@1.1.0: @@ -20073,6 +20414,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -20813,7 +21160,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -20933,7 +21280,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -21481,7 +21828,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -21522,10 +21869,17 @@ snapshots: svg-pathdata@6.0.3: optional: true + swagger-ui-dist@5.17.14: {} + swagger-ui-dist@5.32.4: dependencies: '@scarf/scarf': 1.4.0 + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.4 + symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} @@ -21592,7 +21946,6 @@ snapshots: minizlib: 2.1.2 mkdirp: 1.0.4 yallist: 4.0.0 - optional: true terser-webpack-plugin@5.6.0(webpack@5.106.0): dependencies: @@ -21952,7 +22305,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.20 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -22207,7 +22560,7 @@ snapshots: vite-node@2.1.9(@types/node@24.12.4)(lightningcss@1.32.0)(terser@5.47.1): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.12.4)(lightningcss@1.32.0)(terser@5.47.1) @@ -22243,7 +22596,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -22531,7 +22884,6 @@ snapshots: wide-align@1.1.5: dependencies: string-width: 4.2.3 - optional: true window-size@0.1.0: {} @@ -22622,8 +22974,7 @@ snapshots: yallist@3.1.1: {} - yallist@4.0.0: - optional: true + yallist@4.0.0: {} yaml@1.10.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb25b8f87..c78606724 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ packages: - - "apps/*" - - "apps/edr-freight-web/*" + - "apps/edr-passenger-api" - "apps/edr-passenger-web/*" - "packages/*" - "packages/config/*"