22 Commits
Author SHA1 Message Date
Syed Waseem khadri Rafai c5b360bf56 Merge pull request 'azeem' (#7) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/7
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-15 07:45:11 +00:00
azeeee05 4ba1316289 feat: initialize database module with multi-schema support and define master, tenant, and cohort entities 2026-07-15 13:06:03 +05:30
mytechpark 13fa6c2d46 v1 code db 2026-07-14 10:43:46 +05:30
Syed Waseem khadri Rafai 7473456317 Merge pull request 'azeem' (#6) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/6
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-10 05:39:02 +00:00
azeeee05 49b5fcb487 feat: implement Cohort entity and DTO with environment variable exclusion in gitignore 2026-07-10 10:54:02 +05:30
azeeee05 3137cb8038 feat: implement cohort module with entity schema and create DTO along with environment file management 2026-07-10 10:53:49 +05:30
azeeee05 6e7ff937dc feat: implement cohort module with entity, DTO, and CRUD service operations 2026-07-10 10:42:43 +05:30
Syed Waseem khadri Rafai e99f292e24 Merge pull request 'refactor: reorganize environment configurations by replacing global .env with environment-specific files and updating npm scripts accordingly' (#5) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/5
2026-07-09 09:29:11 +00:00
azeeee05 18454d5197 refactor: reorganize environment configurations by replacing global .env with environment-specific files and updating npm scripts accordingly 2026-07-09 14:39:05 +05:30
Syed Waseem khadri Rafai 4441fa2cce Merge pull request 'feat: implement MasterDataService for CRUD operations and database seeding, and update test script command' (#4) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/4
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-09 05:45:30 +00:00
azeeee05 c3936ca692 feat: implement MasterDataService for CRUD operations and database seeding, and update test script command 2026-07-09 11:14:12 +05:30
Syed Waseem khadri Rafai 1d0c41bf8c Merge pull request 'azeem' (#3) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/3
2026-07-08 10:54:22 +00:00
azeeee05 28837230fa feat: implement CRUD operations and pagination for cohort module 2026-07-08 12:49:25 +05:30
azeeee05 1e3ec441dc feat: implement master data service with seeding and introduce cohort module for filtering logic 2026-07-08 11:53:05 +05:30
azeeee05 06598ea462 feat: initialize master data entities for reference lookup tables 2026-07-08 11:22:06 +05:30
azeeee05 44afa45f7a feat: implement master data module with CRUD services and entity support for common lookup tables 2026-07-08 11:11:58 +05:30
azeeee05 9e64243759 Merge branch 'development' of https://gitea.maskantech.in/gitea_admin/aeroresolve_backend into azeem 2026-07-07 17:24:07 +05:30
azeeee05 8021937518 feat: implement master data and cohort management modules with CRUD operations and database seeding 2026-07-07 17:23:45 +05:30
Syed Waseem khadri Rafai 25302cc222 Merge pull request 'feat: update environment configuration and add support for multiple environments' (#2) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/2
2026-07-07 11:28:43 +00:00
waseem khadri 46e4a0ed81 feat: update environment configuration and add support for multiple environments 2026-07-07 16:57:36 +05:30
Syed Waseem khadri Rafai 82ea313f34 Merge pull request 'feat: integrate PostgreSQL with TypeORM and update app configuration' (#1) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/1
2026-07-07 10:04:33 +00:00
waseem khadri 025f114828 feat: integrate PostgreSQL with TypeORM and update app configuration
- Added @nestjs/typeorm and pg dependencies for PostgreSQL support.
- Configured TypeORM in app.module.ts to connect to PostgreSQL using environment variables.
- Enhanced main.ts to log database connection details during bootstrap.
- Removed unused disruption module, controller, service, and demo data.
2026-07-07 15:32:03 +05:30
44 changed files with 2175 additions and 322 deletions
+10
View File
@@ -0,0 +1,10 @@
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=ar_dev
DB_USER=ar_user
DB_PASSWORD="V$:Q1Hc6Qh]@#Fr"
DB_SYNCHRONIZE=true
DB_SSL=false
PORT=9501
CORS_ORIGIN=https://ardev.maskantech.in
NODE_ENV=dev
-4
View File
@@ -1,4 +0,0 @@
PORT=3001
DATABASE_URL=postgresql://username:password@localhost:5432/dbname
REDIS_URL=redis://localhost:6379
KAFKA_BROKERS=localhost:9092
+10
View File
@@ -0,0 +1,10 @@
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=ar_test
DB_USER=ar_user
DB_PASSWORD="V$:Q1Hc6Qh]@#Fr"
DB_SYNCHRONIZE=true
DB_SSL=false
PORT=9502
CORS_ORIGIN=https://artest.maskantech.in
NODE_ENV=test
+10
View File
@@ -0,0 +1,10 @@
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=ar_uat
DB_USER=ar_user
DB_PASSWORD="V$:Q1Hc6Qh]@#Fr"
DB_SYNCHRONIZE=true
DB_SSL=false
PORT=9501
CORS_ORIGIN=https://aruat.maskantech.in
NODE_ENV=uat
+5 -3
View File
@@ -34,8 +34,10 @@ lerna-debug.log*
!.vscode/launch.json
!.vscode/extensions.json
# Local agent customization
.agent.md
# Environment variables
.env
.env.test
.env.production
.env.local
.env
+114 -20
View File
@@ -1,30 +1,124 @@
# Aero Resolve - Backend
# AeroResolve Backend
This is the backend REST API powered by NestJS.
NestJS REST API for AeroResolve.
## 🚀 Getting Started
## Current Modules
### Prerequisites
- Node.js (v18+)
| Module | Status | Description |
|--------|--------|-------------|
| `health` | Implemented | Health check |
| `database` | Implemented | PostgreSQL / TypeORM setup |
| `master-data` | Implemented | CRUD and seed data for lookup categories |
| `cohort` | Implemented | Cohort CRUD, pagination, status update |
### Installation
1. Navigate to the backend directory (if you aren't already here):
```bash
cd backend
```
2. Install the dependencies using npm:
```bash
npm install
```
## Prerequisites
- Node.js 18+
- npm
- PostgreSQL
## Install
### Development
Start the local development server in watch mode:
```bash
npm install
```
## Environment Files
The app loads environment files based on `NODE_ENV`:
```text
.env.local
.env.dev
.env.test
.env.uat
```
Required values:
```text
PORT=3001
CORS_ORIGIN=http://localhost:5174
DB_HOST=localhost
DB_PORT=5432
DB_USER=<postgres-user>
DB_PASSWORD=<postgres-password>
DB_NAME=<database-name>
DB_SYNCHRONIZE=true
DB_SSL=false
```
## Run
```bash
# Local
npm run local
npm run start:local
# Dev
npm run dev
npm run start:dev
# UAT
npm run uat
npm run start:uat
```
### Build for Production
To compile the NestJS application:
```bash
npm run build
## API
Base URL:
```text
http://localhost:3001/api
```
Swagger:
```text
http://localhost:3001/docs
```
## Endpoints
| Method | Path |
|--------|------|
| `GET` | `/api/health` |
| `GET` | `/api/master-data/:category` |
| `POST` | `/api/master-data/:category` |
| `GET` | `/api/master-data/:category/:id` |
| `PUT` | `/api/master-data/:category/:id` |
| `DELETE` | `/api/master-data/:category/:id` |
| `GET` | `/api/cohorts?page=1&limit=10` |
| `POST` | `/api/cohorts` |
| `GET` | `/api/cohorts/:id` |
| `PUT` | `/api/cohorts/:id` |
| `PATCH` | `/api/cohorts/:id/status` |
| `DELETE` | `/api/cohorts/:id` |
## Seed Master Data
```bash
npm run seed:local
npm run seed:dev
npm run seed:test
npm run seed:uat
```
Seeded categories:
- `MEMBERSHIP_TIER`
- `CUSTOMER_VALUE`
- `REGION`
- `TRIP_PURPOSE`
- `CABIN_CLASS`
- `PASSENGER_TYPE`
- `ANCILLARY_PURCHASE`
- `REVENUE_SEGMENT`
## Planned Next
- Tenant foundation
- Auth / RBAC
- Audit logging
- Policy / regulation engine backend
- Compensation engine
+209
View File
@@ -0,0 +1,209 @@
-- =============================================================================
-- AeroResolve Database Schema
-- PostgreSQL 14+
-- Generated from TypeORM entities (aeroresolve_backend)
-- =============================================================================
--
-- Usage:
-- psql -h <host> -U <user> -d <database> -f database/schema.sql
--
-- Notes:
-- - Column names use camelCase to match TypeORM entity field names.
-- - In local/dev, TypeORM can also sync via DB_SYNCHRONIZE=true.
-- - Run scripts/seed-master-data.ts after schema creation for demo data.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ─── Schemas ────────────────────────────────────────────────────────────────
CREATE SCHEMA IF NOT EXISTS tenant;
CREATE SCHEMA IF NOT EXISTS masters;
CREATE SCHEMA IF NOT EXISTS cohort;
-- ─── Enum Types ─────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE cohort.cohort_high_value_passenger_enum AS ENUM (
'Any',
'Yes(VIP/Strategic)',
'No'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE cohort.cohort_flight_type_enum AS ENUM (
'Domestic Only',
'International Only',
'Both (All)'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ─── Tenants ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tenant.tbl_tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR NOT NULL,
name VARCHAR NOT NULL,
tier VARCHAR NOT NULL DEFAULT 'standard',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT uq_tbl_tenants_slug UNIQUE (slug)
);
-- ─── Master Data ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS masters.tbl_membership_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_membership_tiers_tenant ON masters.tbl_membership_tiers("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_customer_values (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_customer_values_tenant ON masters.tbl_customer_values("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_regions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_regions_tenant ON masters.tbl_regions("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_trip_purposes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_trip_purposes_tenant ON masters.tbl_trip_purposes("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_cabin_classes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_cabin_classes_tenant ON masters.tbl_cabin_classes("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_passenger_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_passenger_types_tenant ON masters.tbl_passenger_types("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_ancillary_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_ancillary_purchases_tenant ON masters.tbl_ancillary_purchases("tenantId");
CREATE TABLE IF NOT EXISTS masters.tbl_revenue_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
value VARCHAR NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_revenue_segments_tenant ON masters.tbl_revenue_segments("tenantId");
-- ─── Cohorts ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cohort.tbl_cohorts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
"scopeType" VARCHAR,
name VARCHAR NOT NULL,
description VARCHAR,
status VARCHAR NOT NULL DEFAULT 'Draft',
"highValuePassenger" cohort.cohort_high_value_passenger_enum NOT NULL DEFAULT 'Any',
"flightType" cohort.cohort_flight_type_enum,
"originAirport" TEXT,
"destinationAirport" TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tbl_cohorts_tenant ON cohort.tbl_cohorts("tenantId");
-- ─── Cohort Join Tables (Many-to-Many) ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_cabin_classes (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"cabinClassId" UUID NOT NULL REFERENCES masters.tbl_cabin_classes(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "cabinClassId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_passenger_types (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"passengerTypeId" UUID NOT NULL REFERENCES masters.tbl_passenger_types(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "passengerTypeId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_ancillary_purchases (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"ancillaryPurchaseId" UUID NOT NULL REFERENCES masters.tbl_ancillary_purchases(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "ancillaryPurchaseId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_loyalty_tiers (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"membershipTierId" UUID NOT NULL REFERENCES masters.tbl_membership_tiers(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "membershipTierId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_revenue_segments (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"revenueSegmentId" UUID NOT NULL REFERENCES masters.tbl_revenue_segments(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "revenueSegmentId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_regions (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"regionId" UUID NOT NULL REFERENCES masters.tbl_regions(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "regionId")
);
CREATE TABLE IF NOT EXISTS cohort.tbl_cohort_trip_purposes (
"cohortId" UUID NOT NULL REFERENCES cohort.tbl_cohorts(id) ON DELETE CASCADE,
"tripPurposeId" UUID NOT NULL REFERENCES masters.tbl_trip_purposes(id) ON DELETE CASCADE,
PRIMARY KEY ("cohortId", "tripPurposeId")
);
+541 -109
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -6,19 +6,25 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"dev": "cross-env NODE_ENV=dev nest start",
"uat": "cross-env NODE_ENV=uat nest start",
"local": "cross-env NODE_ENV=local nest start",
"seed:dev": "cross-env NODE_ENV=dev ts-node -r tsconfig-paths/register scripts/seed-master-data.ts",
"seed:local": "cross-env NODE_ENV=local ts-node -r tsconfig-paths/register scripts/seed-master-data.ts",
"seed:uat": "cross-env NODE_ENV=uat ts-node -r tsconfig-paths/register scripts/seed-master-data.ts",
"seed:test": "cross-env NODE_ENV=test ts-node -r tsconfig-paths/register scripts/seed-master-data.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:dev": "cross-env NODE_ENV=dev nest start --watch",
"start:uat": "cross-env NODE_ENV=uat nest start --watch",
"start:local": "cross-env NODE_ENV=local nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test": "cross-env NODE_ENV=test nest start",
"test:cov": "cross-env NODE_ENV=test jest --coverage",
"test:debug": "cross-env NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@@ -26,10 +32,13 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"typeorm": "^1.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -41,6 +50,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
+33
View File
@@ -0,0 +1,33 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { MasterDataService } from '../src/modules/master-data/master-data.service';
import { TenantService } from '../src/modules/tenant/tenant.service';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const logger = new Logger('SeedMasterData');
logger.log('Starting seed script (tenants + master data)...');
const app = await NestFactory.createApplicationContext(AppModule);
const tenantService = app.get(TenantService);
const masterDataService = app.get(MasterDataService);
const tenants = await tenantService.seedDemoTenants();
logger.log(`Seeded ${tenants.length} demo tenant(s)`);
for (const tenant of tenants) {
await masterDataService.seedData(tenant.id);
logger.log(`Master data ready for tenant: ${tenant.slug}`);
}
logger.log('Seeding complete. Exiting...');
await app.close();
process.exit(0);
}
bootstrap().catch((err) => {
console.error('Failed to run seed script:', err);
process.exit(1);
});
+22 -5
View File
@@ -1,13 +1,30 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './modules/health/health.module';
import { DisruptionModule } from './modules/disruption/disruption.module';
import { MasterDataModule } from './modules/master-data/master-data.module';
import { CohortModule } from './modules/cohort/cohort.module';
import { TenantModule } from './modules/tenant/tenant.module';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
const env = process.env.NODE_ENV;
const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.env'];
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule.forRoot({
isGlobal: true,
envFilePath,
}),
DatabaseModule,
HealthModule,
DisruptionModule,
TenantModule,
MasterDataModule,
CohortModule,
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
@@ -0,0 +1,12 @@
import { Column, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Tenant } from '../../modules/tenant/tenant.entity';
export abstract class TenantOwnedEntity {
@Column({ type: 'uuid' })
@Index()
tenantId: string;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenantId' })
tenant: Tenant;
}
+27
View File
@@ -0,0 +1,27 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface TenantContextStore {
tenantId: string;
tenantSlug: string;
}
export const tenantContext = new AsyncLocalStorage<TenantContextStore>();
export function getTenantId(): string {
const tenantId = tenantContext.getStore()?.tenantId;
if (!tenantId) {
throw new Error('Tenant context is not set');
}
return tenantId;
}
export function getTenantIdOrNull(): string | null {
return tenantContext.getStore()?.tenantId ?? null;
}
export function runWithTenantContext<T>(
store: TenantContextStore,
fn: () => T,
): T {
return tenantContext.run(store, fn);
}
+50
View File
@@ -0,0 +1,50 @@
import {
BadRequestException,
Injectable,
NestMiddleware,
NotFoundException,
} from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { TenantService } from '../../modules/tenant/tenant.service';
import { tenantContext } from './tenant.context';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly tenantService: TenantService) {}
async use(req: Request, res: Response, next: NextFunction) {
if (this.isPublicRoute(req.path)) {
return next();
}
const tenantHeader = req.headers['x-tenant-id'];
if (!tenantHeader || Array.isArray(tenantHeader)) {
throw new BadRequestException('Missing X-Tenant-Id header');
}
try {
const tenant = await this.tenantService.resolveTenant(String(tenantHeader));
return tenantContext.run(
{ tenantId: tenant.id, tenantSlug: tenant.slug },
() => next(),
);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Invalid X-Tenant-Id header');
}
}
private isPublicRoute(path: string): boolean {
return (
path === '/api/health' ||
path === '/health' ||
path.startsWith('/api/tenants') ||
path.startsWith('/tenants') ||
path.startsWith('/docs') ||
path.startsWith('/api-json')
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get<string>('DB_HOST'),
port: Number(configService.get<string>('DB_PORT') ?? 5432),
username: configService.get<string>('DB_USER'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_NAME'),
synchronize: configService.get<string>('DB_SYNCHRONIZE') === 'true',
autoLoadEntities: true,
uuidExtension: 'pgcrypto',
ssl: configService.get<string>('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false,
logging: false,
}),
dataSourceFactory: async (options) => {
if (!options) {
throw new Error('Invalid options passed');
}
const { DataSource } = await import('typeorm');
// Initialize without syncing first
const dataSource = new DataSource({ ...options, synchronize: false });
await dataSource.initialize();
// Create schemas if they don't exist
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "tenant";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "cohort";`);
// Run synchronization manually if it was enabled
if (options.synchronize) {
await dataSource.synchronize();
}
return dataSource;
},
}),
],
})
export class DatabaseModule {}
+27 -2
View File
@@ -1,14 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger: ['log', 'warn', 'error'],
});
const configService = app.get(ConfigService);
const dbHost = configService.get('DB_HOST');
const dbPort = configService.get('DB_PORT') ?? '5432';
const corsOrigin = configService.get<string>('CORS_ORIGIN') ?? 'http://localhost:5174';
const corsOrigins = corsOrigin.split(',').map(origin => origin.trim()).filter(Boolean);
app.enableCors({
origin: ['http://localhost:5174'],
origin: corsOrigins.length > 1 ? corsOrigins : corsOrigins[0],
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id'],
});
app.useGlobalPipes(
@@ -18,6 +29,10 @@ async function bootstrap() {
}),
);
app.setGlobalPrefix('api');
const logger = new Logger('Bootstrap');
const swaggerConfig = new DocumentBuilder()
.setTitle('AeroResolve API')
.setDescription('Passenger recovery and compensation intelligence platform')
@@ -28,7 +43,17 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document);
const port = process.env.PORT ?? 3001;
const environment = process.env.NODE_ENV ?? 'dev';
logger.log(`🌍 Environment: ${environment}`);
await app.listen(port);
if (process.env.NODE_ENV !== 'production') {
logger.log('🚀 AeroResolve API started successfully');
logger.log(`🌐 Server : http://localhost:${port}`);
logger.log(`📚 Swagger : http://localhost:${port}/docs`);
logger.log(`🗄️ Database : PostgreSQL (${dbHost}:${dbPort})`);
}
}
bootstrap();
+42
View File
@@ -0,0 +1,42 @@
import { Controller, Get, Post, Body, Param, Put, Delete, Patch, Query } from '@nestjs/common';
import { CohortService } from './cohort.service';
import { CreateCohortDto } from './dto/create-cohort.dto';
import { UpdateCohortDto } from './dto/update-cohort.dto';
@Controller('cohorts')
export class CohortController {
constructor(private readonly cohortService: CohortService) {}
@Post()
async create(@Body() createCohortDto: CreateCohortDto) {
return this.cohortService.create(createCohortDto);
}
@Get()
async findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '10',
) {
return this.cohortService.findAll(Number(page), Number(limit));
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.cohortService.findOne(id);
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateCohortDto: UpdateCohortDto) {
return this.cohortService.update(id, updateCohortDto);
}
@Patch(':id/status')
async updateStatus(@Param('id') id: string, @Body('status') status: string) {
return this.cohortService.updateStatus(id, status);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.cohortService.remove(id);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm';
import { TenantOwnedEntity } from '../../common/entities/tenant-owned.entity';
import { CabinClass } from '../master-data/entities/cabin-class.entity';
import { PassengerType } from '../master-data/entities/passenger-type.entity';
import { AncillaryPurchase } from '../master-data/entities/ancillary-purchase.entity';
import { MembershipTier } from '../master-data/entities/membership-tier.entity';
import { RevenueSegment } from '../master-data/entities/revenue-segment.entity';
import { Region } from '../master-data/entities/region.entity';
import { TripPurpose } from '../master-data/entities/trip-purpose.entity';
@Entity({ name: 'tbl_cohorts', schema: 'cohort' })
export class Cohort extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
scopeType: string;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@Column({ default: 'Draft' })
status: string; // Draft, Active, Inactive
// Passenger Attributes
@ManyToMany(() => CabinClass)
@JoinTable({
name: 'tbl_cohort_cabin_classes',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'cabinClassId', referencedColumnName: 'id' }
})
cabinClasses: CabinClass[];
@ManyToMany(() => PassengerType)
@JoinTable({
name: 'tbl_cohort_passenger_types',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'passengerTypeId', referencedColumnName: 'id' }
})
passengerTypes: PassengerType[];
@ManyToMany(() => AncillaryPurchase)
@JoinTable({
name: 'tbl_cohort_ancillary_purchases',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'ancillaryPurchaseId', referencedColumnName: 'id' }
})
ancillaryPurchases: AncillaryPurchase[];
// Customer Value
@ManyToMany(() => MembershipTier)
@JoinTable({
name: 'tbl_cohort_loyalty_tiers',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'membershipTierId', referencedColumnName: 'id' }
})
loyaltyTiers: MembershipTier[];
@ManyToMany(() => RevenueSegment)
@JoinTable({
name: 'tbl_cohort_revenue_segments',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'revenueSegmentId', referencedColumnName: 'id' }
})
revenueSegments: RevenueSegment[];
@Column({
type: 'enum',
enum: ['Any', 'Yes(VIP/Strategic)', 'No'],
default: 'Any'
})
highValuePassenger: string;
@Column({
type: 'enum',
enum: ['Domestic Only', 'International Only', 'Both (All)'],
nullable: true
})
flightType: string;
@ManyToMany(() => Region)
@JoinTable({
name: 'tbl_cohort_regions',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'regionId', referencedColumnName: 'id' }
})
regions: Region[];
@ManyToMany(() => TripPurpose)
@JoinTable({
name: 'tbl_cohort_trip_purposes',
joinColumn: { name: 'cohortId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tripPurposeId', referencedColumnName: 'id' }
})
tripPurposes: TripPurpose[];
@Column('simple-array', { nullable: true })
originAirport: string[];
@Column('simple-array', { nullable: true })
destinationAirport: string[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CohortController } from './cohort.controller';
import { CohortService } from './cohort.service';
import { Cohort } from './cohort.entity';
@Module({
imports: [TypeOrmModule.forFeature([Cohort])],
controllers: [CohortController],
providers: [CohortService],
exports: [CohortService],
})
export class CohortModule {}
+114
View File
@@ -0,0 +1,114 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DeepPartial } from 'typeorm';
import { getTenantId } from '../../common/tenant/tenant.context';
import { Cohort } from './cohort.entity';
import { CreateCohortDto } from './dto/create-cohort.dto';
import { UpdateCohortDto } from './dto/update-cohort.dto';
@Injectable()
export class CohortService {
constructor(
@InjectRepository(Cohort)
private readonly cohortRepository: Repository<Cohort>,
) {}
private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) {
const {
cabinClassIds,
passengerTypeIds,
ancillaryPurchaseIds,
loyaltyTierIds,
revenueSegmentIds,
regionIds,
tripPurposeIds,
originAirports,
destinationAirports,
...rest
} = dto;
const mapping: any = { ...rest };
if (cabinClassIds) mapping.cabinClasses = cabinClassIds.map(id => ({ id }));
if (passengerTypeIds) mapping.passengerTypes = passengerTypeIds.map(id => ({ id }));
if (ancillaryPurchaseIds) mapping.ancillaryPurchases = ancillaryPurchaseIds.map(id => ({ id }));
if (loyaltyTierIds) mapping.loyaltyTiers = loyaltyTierIds.map(id => ({ id }));
if (revenueSegmentIds) mapping.revenueSegments = revenueSegmentIds.map(id => ({ id }));
if (regionIds) mapping.regions = regionIds.map(id => ({ id }));
if (tripPurposeIds) mapping.tripPurposes = tripPurposeIds.map(id => ({ id }));
if (originAirports !== undefined) mapping.originAirport = originAirports;
if (destinationAirports !== undefined) mapping.destinationAirport = destinationAirports;
return mapping as DeepPartial<Cohort>;
}
private getRelations() {
return {
cabinClasses: true,
passengerTypes: true,
ancillaryPurchases: true,
loyaltyTiers: true,
revenueSegments: true,
regions: true,
tripPurposes: true
};
}
async create(createCohortDto: CreateCohortDto): Promise<Cohort> {
const mappedData = this.mapDtoToEntity(createCohortDto);
const cohort = this.cohortRepository.create({
...mappedData,
tenantId: getTenantId(),
});
return this.cohortRepository.save(cohort);
}
async findAll(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit;
const tenantId = getTenantId();
const [data, total] = await this.cohortRepository.findAndCount({
where: { tenantId },
skip,
take: limit,
order: { createdAt: 'DESC' },
relations: this.getRelations(),
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: string): Promise<Cohort> {
const cohort = await this.cohortRepository.findOne({
where: { id, tenantId: getTenantId() },
relations: this.getRelations(),
});
if (!cohort) {
throw new NotFoundException(`Cohort with ID ${id} not found`);
}
return cohort;
}
async update(id: string, updateCohortDto: UpdateCohortDto): Promise<Cohort> {
const cohort = await this.findOne(id);
const mappedData = this.mapDtoToEntity(updateCohortDto);
Object.assign(cohort, mappedData);
return this.cohortRepository.save(cohort);
}
async updateStatus(id: string, status: string): Promise<Cohort> {
const cohort = await this.findOne(id);
cohort.status = status;
return this.cohortRepository.save(cohort);
}
async remove(id: string): Promise<void> {
const cohort = await this.findOne(id);
await this.cohortRepository.remove(cohort);
}
}
@@ -0,0 +1,72 @@
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
export class CreateCohortDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
scopeType?: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
cabinClassIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
passengerTypeIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
ancillaryPurchaseIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
loyaltyTierIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
revenueSegmentIds?: string[];
@IsString()
@IsOptional()
highValuePassenger?: string;
@IsString()
@IsOptional()
flightType?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
regionIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
tripPurposeIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
originAirports?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
destinationAirports?: string[];
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCohortDto } from './create-cohort.dto';
export class UpdateCohortDto extends PartialType(CreateCohortDto) {}
@@ -1,72 +0,0 @@
const DEMO_DISRUPTION_ID = 'disruption-ek542';
const baseDisruption: any = {
id: DEMO_DISRUPTION_ID,
tenantId: 'tenant-demo',
flightNumber: 'EK542',
route: { origin: 'DXB', destination: 'CDG' },
scheduledDeparture: '2026-07-02T08:00:00.000Z',
estimatedDeparture: '2026-07-02T12:00:00.000Z',
delayMinutes: 240,
type: 'DELAY',
status: 'AWAITING_APPROVAL',
reason: 'Technical Issue',
aircraftType: 'A380-800',
airlineResponsible: true,
createdAt: '2026-07-02T06:15:00.000Z',
updatedAt: '2026-07-02T06:20:00.000Z',
};
const impactSummary: any = {
totalPassengers: 150,
compensationEligible: 92,
connectionsAtRisk: 25,
estimatedExposure: 36800,
currency: 'EUR',
jurisdiction: 'EU261',
};
const passengerSegments: any[] = [
{ label: 'Premium / Business', count: 18, percentage: 12 },
{ label: 'Frequent Flyers', count: 35, percentage: 23 },
{ label: 'Families', count: 20, percentage: 13 },
{ label: 'Connecting Pax', count: 25, percentage: 17 },
{ label: 'Economy', count: 52, percentage: 35 },
];
const recoveryPlan: any = {
id: 'recovery-ek542',
disruptionId: DEMO_DISRUPTION_ID,
status: 'PENDING_APPROVAL',
actions: [
{ type: 'HOTEL', description: 'Hotel allocation', quantity: 70, estimatedCost: 14000 },
{ type: 'MEAL_VOUCHER', description: 'Meal vouchers', quantity: 150, estimatedCost: 4500 },
{ type: 'REBOOKING', description: 'Next available rebooking', quantity: 25, estimatedCost: 0 },
{ type: 'LOUNGE', description: 'Lounge access', quantity: 18, estimatedCost: 1800 },
{ type: 'GROUND_TRANSPORT', description: 'Ground transport', quantity: 20, estimatedCost: 2200 },
],
estimatedRecoveryCost: 22500,
estimatedExposure: 36800,
potentialSavings: 14300,
currency: 'EUR',
};
export const demoDisruptionDetail: any = {
disruption: baseDisruption,
impact: impactSummary,
segments: passengerSegments,
recoveryPlan,
};
export function cloneDetail(
overrides: Partial<any> = {},
): any {
return {
disruption: { ...demoDisruptionDetail.disruption, ...overrides.disruption },
impact: { ...demoDisruptionDetail.impact, ...overrides.impact },
segments: overrides.segments ?? demoDisruptionDetail.segments,
recoveryPlan: overrides.recoveryPlan
? { ...demoDisruptionDetail.recoveryPlan!, ...overrides.recoveryPlan }
: demoDisruptionDetail.recoveryPlan,
};
}
@@ -1,33 +0,0 @@
import { Controller, Get, Param, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { DisruptionService } from './disruption.service';
@ApiTags('disruptions')
@Controller('disruptions')
export class DisruptionController {
constructor(private readonly disruptionService: DisruptionService) {}
@Get()
@ApiOperation({ summary: 'List active disruptions' })
findAll(): any[] {
return this.disruptionService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get disruption detail with impact and recovery plan' })
findOne(@Param('id') id: string): any {
return this.disruptionService.findOne(id);
}
@Post(':id/approve')
@ApiOperation({ summary: 'Approve recovery plan' })
approve(@Param('id') id: string): any {
return this.disruptionService.approve(id);
}
@Post(':id/reject')
@ApiOperation({ summary: 'Reject recovery plan' })
reject(@Param('id') id: string): any {
return this.disruptionService.reject(id);
}
}
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { DisruptionController } from './disruption.controller';
import { DisruptionService } from './disruption.service';
@Module({
controllers: [DisruptionController],
providers: [DisruptionService],
exports: [DisruptionService],
})
export class DisruptionModule {}
@@ -1,55 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { cloneDetail, demoDisruptionDetail } from './data/demo-disruption';
@Injectable()
export class DisruptionService {
private readonly store = new Map<string, any>([
[demoDisruptionDetail.disruption.id, cloneDetail()],
]);
findAll(): any[] {
return Array.from(this.store.values()).map((item) => item.disruption);
}
findOne(id: string): any {
const detail = this.store.get(id);
if (!detail) {
throw new NotFoundException(`Disruption ${id} not found`);
}
return cloneDetail(detail);
}
approve(id: string): any {
const current = this.findOne(id);
const updated = cloneDetail({
disruption: {
...current.disruption,
status: 'COMPLETED',
updatedAt: new Date().toISOString(),
},
recoveryPlan: {
...current.recoveryPlan!,
status: 'COMPLETED',
},
});
this.store.set(id, updated);
return cloneDetail(updated);
}
reject(id: string): any {
const current = this.findOne(id);
const updated = cloneDetail({
disruption: {
...current.disruption,
status: 'REJECTED',
updatedAt: new Date().toISOString(),
},
recoveryPlan: {
...current.recoveryPlan!,
status: 'REJECTED',
},
});
this.store.set(id, updated);
return cloneDetail(updated);
}
}
@@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, IsBoolean, IsOptional, IsNumber } from 'class-validator';
export class CreateMasterDataDto {
@IsString()
@IsNotEmpty()
label: string;
@IsString()
@IsNotEmpty()
value: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateMasterDataDto } from './create-master-data.dto';
export class UpdateMasterDataDto extends PartialType(CreateMasterDataDto) {}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_ancillary_purchases', schema: 'masters' })
export class AncillaryPurchase extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_cabin_classes', schema: 'masters' })
export class CabinClass extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_customer_values', schema: 'masters' })
export class CustomerValue extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_membership_tiers', schema: 'masters' })
export class MembershipTier extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_passenger_types', schema: 'masters' })
export class PassengerType extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_regions', schema: 'masters' })
export class Region extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_revenue_segments', schema: 'masters' })
export class RevenueSegment extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
@Entity({ name: 'tbl_trip_purposes', schema: 'masters' })
export class TripPurpose extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
label: string;
@Column()
value: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,52 @@
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { MasterDataService } from './master-data.service';
import { CreateMasterDataDto } from './dto/create-master-data.dto';
import { UpdateMasterDataDto } from './dto/update-master-data.dto';
@Controller('master-data')
export class MasterDataController {
constructor(private readonly masterDataService: MasterDataService) {}
// Example: POST /master-data/REGION
@Post(':category')
async create(
@Param('category') category: string,
@Body() createDto: CreateMasterDataDto,
) {
return this.masterDataService.create(category, createDto);
}
// Example: GET /master-data/REGION
@Get(':category')
async findAll(@Param('category') category: string) {
return this.masterDataService.findAll(category);
}
// Example: GET /master-data/REGION/uuid
@Get(':category/:id')
async findOne(
@Param('category') category: string,
@Param('id') id: string,
) {
return this.masterDataService.findOne(category, id);
}
// Example: PUT /master-data/REGION/uuid
@Put(':category/:id')
async update(
@Param('category') category: string,
@Param('id') id: string,
@Body() updateDto: UpdateMasterDataDto,
) {
return this.masterDataService.update(category, id, updateDto);
}
// Example: DELETE /master-data/REGION/uuid
@Delete(':category/:id')
async remove(
@Param('category') category: string,
@Param('id') id: string,
) {
return this.masterDataService.remove(category, id);
}
}
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MasterDataController } from './master-data.controller';
import { MasterDataService } from './master-data.service';
import { MembershipTier } from './entities/membership-tier.entity';
import { CustomerValue } from './entities/customer-value.entity';
import { Region } from './entities/region.entity';
import { TripPurpose } from './entities/trip-purpose.entity';
import { CabinClass } from './entities/cabin-class.entity';
import { PassengerType } from './entities/passenger-type.entity';
import { AncillaryPurchase } from './entities/ancillary-purchase.entity';
import { RevenueSegment } from './entities/revenue-segment.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
MembershipTier,
CustomerValue,
Region,
TripPurpose,
CabinClass,
PassengerType,
AncillaryPurchase,
RevenueSegment,
]),
],
controllers: [MasterDataController],
providers: [MasterDataService],
exports: [MasterDataService],
})
export class MasterDataModule {}
@@ -0,0 +1,189 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { getTenantId } from '../../common/tenant/tenant.context';
import { MembershipTier } from './entities/membership-tier.entity';
import { CustomerValue } from './entities/customer-value.entity';
import { Region } from './entities/region.entity';
import { TripPurpose } from './entities/trip-purpose.entity';
import { CabinClass } from './entities/cabin-class.entity';
import { PassengerType } from './entities/passenger-type.entity';
import { AncillaryPurchase } from './entities/ancillary-purchase.entity';
import { RevenueSegment } from './entities/revenue-segment.entity';
@Injectable()
export class MasterDataService {
private readonly logger = new Logger(MasterDataService.name);
constructor(
@InjectRepository(MembershipTier) private membershipTierRepo: Repository<MembershipTier>,
@InjectRepository(CustomerValue) private customerValueRepo: Repository<CustomerValue>,
@InjectRepository(Region) private regionRepo: Repository<Region>,
@InjectRepository(TripPurpose) private tripPurposeRepo: Repository<TripPurpose>,
@InjectRepository(CabinClass) private cabinClassRepo: Repository<CabinClass>,
@InjectRepository(PassengerType) private passengerTypeRepo: Repository<PassengerType>,
@InjectRepository(AncillaryPurchase) private ancillaryPurchaseRepo: Repository<AncillaryPurchase>,
@InjectRepository(RevenueSegment) private revenueSegmentRepo: Repository<RevenueSegment>,
) {}
private resolveTenantId(tenantId?: string): string {
return tenantId ?? getTenantId();
}
private getRepositoryByCategory(category: string): Repository<any> {
switch (category?.toUpperCase()) {
case 'MEMBERSHIP_TIER': return this.membershipTierRepo;
case 'CUSTOMER_VALUE': return this.customerValueRepo;
case 'REGION': return this.regionRepo;
case 'TRIP_PURPOSE': return this.tripPurposeRepo;
case 'CABIN_CLASS': return this.cabinClassRepo;
case 'PASSENGER_TYPE': return this.passengerTypeRepo;
case 'ANCILLARY_PURCHASE': return this.ancillaryPurchaseRepo;
case 'REVENUE_SEGMENT': return this.revenueSegmentRepo;
default:
throw new BadRequestException(`Invalid category: ${category}`);
}
}
async findAll(category: string): Promise<any[]> {
if (!category) {
throw new BadRequestException('Category is required');
}
const repo = this.getRepositoryByCategory(category);
return repo.find({
where: { isActive: true, tenantId: getTenantId() },
});
}
async findOne(category: string, id: string): Promise<any> {
const repo = this.getRepositoryByCategory(category);
const item = await repo.findOne({ where: { id, tenantId: getTenantId() } });
if (!item) {
throw new BadRequestException(`${category} with id ${id} not found`);
}
return item;
}
async create(category: string, data: any): Promise<any> {
const repo = this.getRepositoryByCategory(category);
const newItem = repo.create({ ...data, tenantId: getTenantId() });
return repo.save(newItem);
}
async update(category: string, id: string, data: any): Promise<any> {
const repo = this.getRepositoryByCategory(category);
const item = await this.findOne(category, id);
Object.assign(item, data);
return repo.save(item);
}
async remove(category: string, id: string): Promise<void> {
const repo = this.getRepositoryByCategory(category);
const item = await this.findOne(category, id);
await repo.remove(item);
}
public async seedData(tenantId?: string) {
const resolvedTenantId = this.resolveTenantId(tenantId);
this.logger.log(`Checking if master data needs seeding for tenant ${resolvedTenantId}...`);
if (await this.membershipTierRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'Platinum', value: 'platinum', tenantId: resolvedTenantId },
{ label: 'Gold', value: 'gold', tenantId: resolvedTenantId },
{ label: 'Silver', value: 'silver', tenantId: resolvedTenantId },
{ label: 'Bronze', value: 'bronze', tenantId: resolvedTenantId },
{ label: 'Basic', value: 'basic', tenantId: resolvedTenantId },
{ label: 'Non-Member', value: 'non-member', tenantId: resolvedTenantId },
];
await this.membershipTierRepo.save(this.membershipTierRepo.create(data));
}
if (await this.customerValueRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'High Value', value: 'high', tenantId: resolvedTenantId },
{ label: 'Medium Value', value: 'medium', tenantId: resolvedTenantId },
{ label: 'Low Value', value: 'low', tenantId: resolvedTenantId },
];
await this.customerValueRepo.save(this.customerValueRepo.create(data));
}
if (await this.regionRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'Global', value: 'global', tenantId: resolvedTenantId },
{ label: 'Americas', value: 'americas', tenantId: resolvedTenantId },
{ label: 'Europe', value: 'europe', tenantId: resolvedTenantId },
{ label: 'Middle East', value: 'middle-east', tenantId: resolvedTenantId },
{ label: 'Asia Pacific', value: 'asia-pacific', tenantId: resolvedTenantId },
{ label: 'Africa', value: 'africa', tenantId: resolvedTenantId },
];
await this.regionRepo.save(this.regionRepo.create(data));
}
if (await this.tripPurposeRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'Business', value: 'business', tenantId: resolvedTenantId },
{ label: 'Leisure', value: 'leisure', tenantId: resolvedTenantId },
{ label: 'Corporate', value: 'corporate', tenantId: resolvedTenantId },
{ label: 'Government', value: 'government', tenantId: resolvedTenantId },
];
await this.tripPurposeRepo.save(this.tripPurposeRepo.create(data));
}
if (await this.cabinClassRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'First Class', value: 'first-class', tenantId: resolvedTenantId },
{ label: 'Business Class', value: 'business-class', tenantId: resolvedTenantId },
{ label: 'Premium Economy', value: 'premium-economy', tenantId: resolvedTenantId },
{ label: 'Economy', value: 'economy', tenantId: resolvedTenantId },
];
await this.cabinClassRepo.save(this.cabinClassRepo.create(data));
}
if (await this.passengerTypeRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'Adult', value: 'adult', tenantId: resolvedTenantId },
{ label: 'Child', value: 'child', tenantId: resolvedTenantId },
{ label: 'Infant', value: 'infant', tenantId: resolvedTenantId },
{ label: 'Senior Citizen', value: 'senior', tenantId: resolvedTenantId },
];
await this.passengerTypeRepo.save(this.passengerTypeRepo.create(data));
}
if (await this.ancillaryPurchaseRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'Preferred Seat', value: 'preferred-seat', tenantId: resolvedTenantId },
{ label: 'Extra Legroom', value: 'extra-legroom', tenantId: resolvedTenantId },
{ label: 'Wi-Fi', value: 'wi-fi', tenantId: resolvedTenantId },
{ label: 'Lounge Access', value: 'lounge-access', tenantId: resolvedTenantId },
{ label: 'Priority Boarding', value: 'priority-boarding', tenantId: resolvedTenantId },
{ label: 'Fast Track', value: 'fast-track', tenantId: resolvedTenantId },
{ label: 'Paid Meal', value: 'paid-meal', tenantId: resolvedTenantId },
{ label: 'Special Meal', value: 'special-meal', tenantId: resolvedTenantId },
{ label: 'Extra Baggage', value: 'extra-baggage', tenantId: resolvedTenantId },
{ label: 'Upgrade Purchase', value: 'upgrade-purchase', tenantId: resolvedTenantId },
{ label: 'Airport Transfer', value: 'airport-transfer', tenantId: resolvedTenantId },
{ label: 'Chauffeur Service', value: 'chauffeur-service', tenantId: resolvedTenantId },
{ label: 'Sports Equipment', value: 'sports-equipment', tenantId: resolvedTenantId },
{ label: 'Musical Instrument', value: 'musical-instrument', tenantId: resolvedTenantId },
{ label: 'In-flight Entertainment', value: 'in-flight-entertainment', tenantId: resolvedTenantId },
{ label: 'Power Outlet', value: 'power-outlet', tenantId: resolvedTenantId },
{ label: 'Carbon Offset', value: 'carbon-offset', tenantId: resolvedTenantId },
];
await this.ancillaryPurchaseRepo.save(this.ancillaryPurchaseRepo.create(data));
}
if (await this.revenueSegmentRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) {
const data = [
{ label: 'High Value', value: 'high', tenantId: resolvedTenantId },
{ label: 'Medium Value', value: 'medium', tenantId: resolvedTenantId },
{ label: 'Low Value', value: 'low', tenantId: resolvedTenantId },
];
await this.revenueSegmentRepo.save(this.revenueSegmentRepo.create(data));
}
this.logger.log(`Master data seeded for tenant ${resolvedTenantId}`);
}
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateTenantDto {
@IsString()
@IsNotEmpty()
slug: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
tier?: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantService } from './tenant.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
@ApiTags('tenants')
@Controller('tenants')
export class TenantController {
constructor(private readonly tenantService: TenantService) {}
@Get()
findAll() {
return this.tenantService.findAll();
}
@Post()
create(@Body() createTenantDto: CreateTenantDto) {
return this.tenantService.create(createTenantDto);
}
}
+31
View File
@@ -0,0 +1,31 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_tenants', schema: 'tenant' })
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
slug: string;
@Column()
name: string;
@Column({ default: 'standard' })
tier: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from './tenant.entity';
import { TenantService } from './tenant.service';
import { TenantController } from './tenant.controller';
import { TenantMiddleware } from '../../common/tenant/tenant.middleware';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
controllers: [TenantController],
providers: [TenantService, TenantMiddleware],
exports: [TenantService, TenantMiddleware],
})
export class TenantModule {}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from './tenant.entity';
import { CreateTenantDto } from './dto/create-tenant.dto';
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class TenantService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
) {}
async findAll(): Promise<Tenant[]> {
return this.tenantRepository.find({
where: { isActive: true },
order: { name: 'ASC' },
});
}
async findById(id: string): Promise<Tenant> {
const tenant = await this.tenantRepository.findOne({ where: { id, isActive: true } });
if (!tenant) {
throw new NotFoundException(`Tenant ${id} not found`);
}
return tenant;
}
async findBySlug(slug: string): Promise<Tenant> {
const tenant = await this.tenantRepository.findOne({
where: { slug: slug.toLowerCase(), isActive: true },
});
if (!tenant) {
throw new NotFoundException(`Tenant ${slug} not found`);
}
return tenant;
}
async resolveTenant(identifier: string): Promise<Tenant> {
if (UUID_REGEX.test(identifier)) {
return this.findById(identifier);
}
return this.findBySlug(identifier);
}
async create(createTenantDto: CreateTenantDto): Promise<Tenant> {
const tenant = this.tenantRepository.create({
...createTenantDto,
slug: createTenantDto.slug.toLowerCase(),
});
return this.tenantRepository.save(tenant);
}
async seedDemoTenants(): Promise<Tenant[]> {
const demos = [
{ slug: 'demo-airline', name: 'Demo Airline', tier: 'standard' },
{ slug: 'etihad', name: 'Etihad Airways', tier: 'enterprise' },
];
const tenants: Tenant[] = [];
for (const demo of demos) {
const existing = await this.tenantRepository.findOne({
where: { slug: demo.slug },
});
if (existing) {
tenants.push(existing);
continue;
}
tenants.push(await this.create(demo));
}
return tenants;
}
}