diff --git a/README.md b/README.md index bed3be5..705b34e 100644 --- a/README.md +++ b/README.md @@ -1,60 +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 | + +## Prerequisites + +- Node.js 18+ - npm +- PostgreSQL -### Installation -1. Navigate to the backend directory: - ```bash - cd backend - ``` -2. Install the dependencies: - ```bash - npm install - ``` - -### Environment Files - -The project uses environment-specific files. The npm scripts automatically set `NODE_ENV` so the correct file is loaded (e.g. `.env.dev`). - -- `.env.local` -- `.env.dev` -- `.env.uat` -- `.env.test` - -### Running the Application - -To start the server, use the command corresponding to your target environment: +## Install ```bash -# Local environment -npm run local -npm run start:local # (watch mode) - -# Development environment -npm run dev -npm run start:dev # (watch mode) - -# UAT environment -npm run uat -npm run start:uat # (watch mode) - -# Test environment -npm run test +npm install ``` -### Database Seeding +## Environment Files -To run the master data seed scripts for a specific environment: +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= +DB_PASSWORD= +DB_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 +``` + +## 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:uat 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 diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..c4ac1b5 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,203 @@ +-- ============================================================================= +-- AeroResolve Database Schema +-- PostgreSQL 14+ +-- Generated from TypeORM entities (aeroresolve_backend) +-- ============================================================================= +-- +-- Usage: +-- psql -h -U -d -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"; + +-- ─── Enum Types ───────────────────────────────────────────────────────────── + +DO $$ BEGIN + CREATE TYPE cohort_high_value_passenger_enum AS ENUM ( + 'Any', + 'Yes(VIP/Strategic)', + 'No' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE 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 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 tbl_membership_tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_membership_tiers("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_customer_values ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_customer_values("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_regions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_regions("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_trip_purposes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_trip_purposes("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_cabin_classes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_cabin_classes("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_passenger_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_passenger_types("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_ancillary_purchases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_ancillary_purchases("tenantId"); + +CREATE TABLE IF NOT EXISTS tbl_revenue_segments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES 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 tbl_revenue_segments("tenantId"); + +-- ─── Cohorts ──────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS tbl_cohorts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES tbl_tenants(id) ON DELETE CASCADE, + "scopeType" VARCHAR, + name VARCHAR NOT NULL, + description VARCHAR, + status VARCHAR NOT NULL DEFAULT 'Draft', + "highValuePassenger" cohort_high_value_passenger_enum NOT NULL DEFAULT 'Any', + "flightType" 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 tbl_cohorts("tenantId"); + +-- ─── Cohort Join Tables (Many-to-Many) ────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS tbl_cohort_cabin_classes ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "cabinClassId" UUID NOT NULL REFERENCES tbl_cabin_classes(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "cabinClassId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_passenger_types ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "passengerTypeId" UUID NOT NULL REFERENCES tbl_passenger_types(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "passengerTypeId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_ancillary_purchases ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "ancillaryPurchaseId" UUID NOT NULL REFERENCES tbl_ancillary_purchases(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "ancillaryPurchaseId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_loyalty_tiers ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "membershipTierId" UUID NOT NULL REFERENCES tbl_membership_tiers(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "membershipTierId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_revenue_segments ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "revenueSegmentId" UUID NOT NULL REFERENCES tbl_revenue_segments(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "revenueSegmentId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_regions ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "regionId" UUID NOT NULL REFERENCES tbl_regions(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "regionId") +); + +CREATE TABLE IF NOT EXISTS tbl_cohort_trip_purposes ( + "cohortId" UUID NOT NULL REFERENCES tbl_cohorts(id) ON DELETE CASCADE, + "tripPurposeId" UUID NOT NULL REFERENCES tbl_trip_purposes(id) ON DELETE CASCADE, + PRIMARY KEY ("cohortId", "tripPurposeId") +); diff --git a/scripts/seed-master-data.ts b/scripts/seed-master-data.ts index 690a4b9..aefbc85 100644 --- a/scripts/seed-master-data.ts +++ b/scripts/seed-master-data.ts @@ -1,24 +1,28 @@ 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 standalone seed script...'); + logger.log('Starting seed script (tenants + master data)...'); - // Initialize the NestJS application context (this sets up TypeORM and ConfigModule) const app = await NestFactory.createApplicationContext(AppModule); - - // Get our service + + const tenantService = app.get(TenantService); const masterDataService = app.get(MasterDataService); - // Run the seed logic - await masterDataService.seedData(); + 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...'); - - // Close DB connections + await app.close(); process.exit(0); } diff --git a/src/app.module.ts b/src/app.module.ts index 7ee87a6..7632919 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,11 @@ -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 { 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']; @@ -16,8 +18,13 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', }), DatabaseModule, HealthModule, + TenantModule, MasterDataModule, CohortModule, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(TenantMiddleware).forRoutes('*'); + } +} diff --git a/src/common/entities/tenant-owned.entity.ts b/src/common/entities/tenant-owned.entity.ts new file mode 100644 index 0000000..74bddb8 --- /dev/null +++ b/src/common/entities/tenant-owned.entity.ts @@ -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; +} diff --git a/src/common/tenant/tenant.context.ts b/src/common/tenant/tenant.context.ts new file mode 100644 index 0000000..0d9dec6 --- /dev/null +++ b/src/common/tenant/tenant.context.ts @@ -0,0 +1,27 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export interface TenantContextStore { + tenantId: string; + tenantSlug: string; +} + +export const tenantContext = new AsyncLocalStorage(); + +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( + store: TenantContextStore, + fn: () => T, +): T { + return tenantContext.run(store, fn); +} diff --git a/src/common/tenant/tenant.middleware.ts b/src/common/tenant/tenant.middleware.ts new file mode 100644 index 0000000..514e288 --- /dev/null +++ b/src/common/tenant/tenant.middleware.ts @@ -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') + ); + } +} diff --git a/src/main.ts b/src/main.ts index a29cee7..ebd76ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ async function bootstrap() { app.enableCors({ origin: corsOrigins.length > 1 ? corsOrigins : corsOrigins[0], credentials: true, + allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id'], }); app.useGlobalPipes( diff --git a/src/modules/cohort/cohort.entity.ts b/src/modules/cohort/cohort.entity.ts index c1b3a1d..3ee4288 100644 --- a/src/modules/cohort/cohort.entity.ts +++ b/src/modules/cohort/cohort.entity.ts @@ -1,4 +1,5 @@ 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'; @@ -8,7 +9,7 @@ import { Region } from '../master-data/entities/region.entity'; import { TripPurpose } from '../master-data/entities/trip-purpose.entity'; @Entity('tbl_cohorts') -export class Cohort { +export class Cohort extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/cohort/cohort.service.ts b/src/modules/cohort/cohort.service.ts index 8a4f33f..8ede91a 100644 --- a/src/modules/cohort/cohort.service.ts +++ b/src/modules/cohort/cohort.service.ts @@ -1,6 +1,7 @@ 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'; @@ -55,13 +56,18 @@ export class CohortService { async create(createCohortDto: CreateCohortDto): Promise { const mappedData = this.mapDtoToEntity(createCohortDto); - const cohort = this.cohortRepository.create(mappedData); + 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' }, @@ -78,8 +84,8 @@ export class CohortService { } async findOne(id: string): Promise { - const cohort = await this.cohortRepository.findOne({ - where: { id }, + const cohort = await this.cohortRepository.findOne({ + where: { id, tenantId: getTenantId() }, relations: this.getRelations(), }); if (!cohort) { diff --git a/src/modules/master-data/entities/ancillary-purchase.entity.ts b/src/modules/master-data/entities/ancillary-purchase.entity.ts index 0dfcd4d..e65c105 100644 --- a/src/modules/master-data/entities/ancillary-purchase.entity.ts +++ b/src/modules/master-data/entities/ancillary-purchase.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_ancillary_purchases') -export class AncillaryPurchase { +export class AncillaryPurchase extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/cabin-class.entity.ts b/src/modules/master-data/entities/cabin-class.entity.ts index ca6b7ba..5fc5a98 100644 --- a/src/modules/master-data/entities/cabin-class.entity.ts +++ b/src/modules/master-data/entities/cabin-class.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_cabin_classes') -export class CabinClass { +export class CabinClass extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/customer-value.entity.ts b/src/modules/master-data/entities/customer-value.entity.ts index 2fb6ca5..f4670fc 100644 --- a/src/modules/master-data/entities/customer-value.entity.ts +++ b/src/modules/master-data/entities/customer-value.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_customer_values') -export class CustomerValue { +export class CustomerValue extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/membership-tier.entity.ts b/src/modules/master-data/entities/membership-tier.entity.ts index a718f97..0651854 100644 --- a/src/modules/master-data/entities/membership-tier.entity.ts +++ b/src/modules/master-data/entities/membership-tier.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_membership_tiers') -export class MembershipTier { +export class MembershipTier extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/passenger-type.entity.ts b/src/modules/master-data/entities/passenger-type.entity.ts index c4f71a0..ba4bb96 100644 --- a/src/modules/master-data/entities/passenger-type.entity.ts +++ b/src/modules/master-data/entities/passenger-type.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_passenger_types') -export class PassengerType { +export class PassengerType extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/region.entity.ts b/src/modules/master-data/entities/region.entity.ts index fa35b95..92122f8 100644 --- a/src/modules/master-data/entities/region.entity.ts +++ b/src/modules/master-data/entities/region.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_regions') -export class Region { +export class Region extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/revenue-segment.entity.ts b/src/modules/master-data/entities/revenue-segment.entity.ts index 423f8a8..e815da7 100644 --- a/src/modules/master-data/entities/revenue-segment.entity.ts +++ b/src/modules/master-data/entities/revenue-segment.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_revenue_segments') -export class RevenueSegment { +export class RevenueSegment extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/entities/trip-purpose.entity.ts b/src/modules/master-data/entities/trip-purpose.entity.ts index 21fef37..27d2c6a 100644 --- a/src/modules/master-data/entities/trip-purpose.entity.ts +++ b/src/modules/master-data/entities/trip-purpose.entity.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; @Entity('tbl_trip_purposes') -export class TripPurpose { +export class TripPurpose extends TenantOwnedEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/modules/master-data/master-data.service.ts b/src/modules/master-data/master-data.service.ts index 3de5978..15f3e12 100644 --- a/src/modules/master-data/master-data.service.ts +++ b/src/modules/master-data/master-data.service.ts @@ -1,6 +1,7 @@ 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'; @@ -26,7 +27,10 @@ export class MasterDataService { @InjectRepository(RevenueSegment) private revenueSegmentRepo: Repository, ) {} - // Helper to get the correct repository based on category + private resolveTenantId(tenantId?: string): string { + return tenantId ?? getTenantId(); + } + private getRepositoryByCategory(category: string): Repository { switch (category?.toUpperCase()) { case 'MEMBERSHIP_TIER': return this.membershipTierRepo; @@ -49,13 +53,13 @@ export class MasterDataService { const repo = this.getRepositoryByCategory(category); return repo.find({ - where: { isActive: true }, + where: { isActive: true, tenantId: getTenantId() }, }); } async findOne(category: string, id: string): Promise { const repo = this.getRepositoryByCategory(category); - const item = await repo.findOne({ where: { id } }); + const item = await repo.findOne({ where: { id, tenantId: getTenantId() } }); if (!item) { throw new BadRequestException(`${category} with id ${id} not found`); } @@ -64,7 +68,7 @@ export class MasterDataService { async create(category: string, data: any): Promise { const repo = this.getRepositoryByCategory(category); - const newItem = repo.create(data); + const newItem = repo.create({ ...data, tenantId: getTenantId() }); return repo.save(newItem); } @@ -81,113 +85,105 @@ export class MasterDataService { await repo.remove(item); } - public async seedData() { - this.logger.log('Checking if separate master data tables need seeding...'); + public async seedData(tenantId?: string) { + const resolvedTenantId = this.resolveTenantId(tenantId); + this.logger.log(`Checking if master data needs seeding for tenant ${resolvedTenantId}...`); - // MEMBERSHIP_TIER - if (await this.membershipTierRepo.count() === 0) { + if (await this.membershipTierRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'Platinum', value: 'platinum' }, - { label: 'Gold', value: 'gold' }, - { label: 'Silver', value: 'silver' }, - { label: 'Bronze', value: 'bronze' }, - { label: 'Basic', value: 'basic' }, - { label: 'Non-Member', value: 'non-member' }, + { 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)); } - // CUSTOMER_VALUE - if (await this.customerValueRepo.count() === 0) { + if (await this.customerValueRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'High Value', value: 'high' }, - { label: 'Medium Value', value: 'medium' }, - { label: 'Low Value', value: 'low' }, + { 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)); } - // REGION - if (await this.regionRepo.count() === 0) { + if (await this.regionRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'Global', value: 'global' }, - { label: 'Americas', value: 'americas' }, - { label: 'Europe', value: 'europe' }, - { label: 'Middle East', value: 'middle-east' }, - { label: 'Asia Pacific', value: 'asia-pacific' }, - { label: 'Africa', value: 'africa' }, + { 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)); } - // TRIP_PURPOSE - if (await this.tripPurposeRepo.count() === 0) { + if (await this.tripPurposeRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'Business', value: 'business' }, - { label: 'Leisure', value: 'leisure' }, - { label: 'Corporate', value: 'corporate' }, - { label: 'Government', value: 'government' }, - + { 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)); } - // CABIN_CLASS - if (await this.cabinClassRepo.count() === 0) { + if (await this.cabinClassRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'First Class', value: 'first-class' }, - { label: 'Business Class', value: 'business-class' }, - { label: 'Premium Economy', value: 'premium-economy' }, - { label: 'Economy', value: 'economy' }, + { 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)); } - // PASSENGER_TYPE - if (await this.passengerTypeRepo.count() === 0) { + if (await this.passengerTypeRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'Adult', value: 'adult' }, - { label: 'Child', value: 'child' }, - { label: 'Infant', value: 'infant' }, - { label: 'Senior Citizen', value: 'senior' }, + { 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)); } - // ANCILLARY_PURCHASE - if (await this.ancillaryPurchaseRepo.count() === 0) { + if (await this.ancillaryPurchaseRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'Preferred Seat', value: 'preferred-seat' }, - { label: 'Extra Legroom', value: 'extra-legroom' }, - { label: 'Wi-Fi', value: 'wi-fi' }, - { label: 'Lounge Access', value: 'lounge-access' }, - { label: 'Priority Boarding', value: 'priority-boarding' }, - { label: 'Fast Track', value: 'fast-track' }, - { label: 'Paid Meal', value: 'paid-meal' }, - { label: 'Special Meal', value: 'special-meal' }, - { label: 'Extra Baggage', value: 'extra-baggage' }, - { label: 'Upgrade Purchase', value: 'upgrade-purchase' }, - { label: 'Airport Transfer', value: 'airport-transfer' }, - { label: 'Chauffeur Service', value: 'chauffeur-service' }, - { label: 'Sports Equipment', value: 'sports-equipment' }, - { label: 'Musical Instrument', value: 'musical-instrument' }, - { label: 'In-flight Entertainment', value: 'in-flight-entertainment' }, - { label: 'Power Outlet', value: 'power-outlet' }, - { label: 'Carbon Offset', value: 'carbon-offset' }, + { 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)); } - // REVENUE_SEGMENT - if (await this.revenueSegmentRepo.count() === 0) { + if (await this.revenueSegmentRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { const data = [ - { label: 'High Value', value: 'high' }, - { label: 'Medium Value', value: 'medium' }, - { label: 'Low Value', value: 'low' }, + { 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('All 8 master data tables seeded successfully!'); + this.logger.log(`Master data seeded for tenant ${resolvedTenantId}`); } } diff --git a/src/modules/tenant/dto/create-tenant.dto.ts b/src/modules/tenant/dto/create-tenant.dto.ts new file mode 100644 index 0000000..11aa5c7 --- /dev/null +++ b/src/modules/tenant/dto/create-tenant.dto.ts @@ -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; +} diff --git a/src/modules/tenant/tenant.controller.ts b/src/modules/tenant/tenant.controller.ts new file mode 100644 index 0000000..50db18f --- /dev/null +++ b/src/modules/tenant/tenant.controller.ts @@ -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); + } +} diff --git a/src/modules/tenant/tenant.entity.ts b/src/modules/tenant/tenant.entity.ts new file mode 100644 index 0000000..67ce2ec --- /dev/null +++ b/src/modules/tenant/tenant.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('tbl_tenants') +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; +} diff --git a/src/modules/tenant/tenant.module.ts b/src/modules/tenant/tenant.module.ts new file mode 100644 index 0000000..7f519a7 --- /dev/null +++ b/src/modules/tenant/tenant.module.ts @@ -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 {} diff --git a/src/modules/tenant/tenant.service.ts b/src/modules/tenant/tenant.service.ts new file mode 100644 index 0000000..3f6cec7 --- /dev/null +++ b/src/modules/tenant/tenant.service.ts @@ -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, + ) {} + + async findAll(): Promise { + return this.tenantRepository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findById(id: string): Promise { + 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 { + 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 { + if (UUID_REGEX.test(identifier)) { + return this.findById(identifier); + } + return this.findBySlug(identifier); + } + + async create(createTenantDto: CreateTenantDto): Promise { + const tenant = this.tenantRepository.create({ + ...createTenantDto, + slug: createTenantDto.slug.toLowerCase(), + }); + return this.tenantRepository.save(tenant); + } + + async seedDemoTenants(): Promise { + 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; + } +}