From fd196f559bfc30931d7bedd56dc629b7373a0f0e Mon Sep 17 00:00:00 2001 From: waseem khadri Date: Wed, 15 Jul 2026 09:04:42 +0530 Subject: [PATCH 1/2] feat: initialize policy-engine module and integrate into AppModule --- src/app.module.ts | 2 ++ src/modules/policy-engine/policy-engine.controller.ts | 7 +++++++ src/modules/policy-engine/policy-engine.module.ts | 10 ++++++++++ src/modules/policy-engine/policy-engine.service.ts | 4 ++++ 4 files changed, 23 insertions(+) create mode 100644 src/modules/policy-engine/policy-engine.controller.ts create mode 100644 src/modules/policy-engine/policy-engine.module.ts create mode 100644 src/modules/policy-engine/policy-engine.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 7632919..fc25412 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ 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'; +import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module'; const env = process.env.NODE_ENV; const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.env']; @@ -21,6 +22,7 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', TenantModule, MasterDataModule, CohortModule, + PolicyEngineModule, ], }) export class AppModule implements NestModule { diff --git a/src/modules/policy-engine/policy-engine.controller.ts b/src/modules/policy-engine/policy-engine.controller.ts new file mode 100644 index 0000000..70140f0 --- /dev/null +++ b/src/modules/policy-engine/policy-engine.controller.ts @@ -0,0 +1,7 @@ +import { Controller } from '@nestjs/common'; +import { PolicyEngineService } from './policy-engine.service'; + +@Controller('policy-engine') +export class PolicyEngineController { + constructor(private readonly policyEngineService: PolicyEngineService) {} +} diff --git a/src/modules/policy-engine/policy-engine.module.ts b/src/modules/policy-engine/policy-engine.module.ts new file mode 100644 index 0000000..fc459aa --- /dev/null +++ b/src/modules/policy-engine/policy-engine.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PolicyEngineService } from './policy-engine.service'; +import { PolicyEngineController } from './policy-engine.controller'; + +@Module({ + controllers: [PolicyEngineController], + providers: [PolicyEngineService], + exports: [PolicyEngineService], +}) +export class PolicyEngineModule { } diff --git a/src/modules/policy-engine/policy-engine.service.ts b/src/modules/policy-engine/policy-engine.service.ts new file mode 100644 index 0000000..743b9b7 --- /dev/null +++ b/src/modules/policy-engine/policy-engine.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class PolicyEngineService {} From f3acfd21816f2ac7a30d823708543f99435cab91 Mon Sep 17 00:00:00 2001 From: Syed Waseem Date: Tue, 21 Jul 2026 11:22:54 +0530 Subject: [PATCH 2/2] feat(master-data): add jurisdiction and rule category entities with CRUD operations - Introduced Jurisdiction and RuleCategory entities along with their respective values. - Implemented service methods for managing rule categories and values. - Updated MasterDataService to include seeding logic for jurisdictions and rule categories. - Added DTOs for creating and updating rule categories and values. feat(policy-engine): enhance policy management with new entities and operations - Created Policy, PolicyRule, PolicyAction, PolicyTargetAudience, and RuleCondition entities. - Implemented create, update, and delete operations for policies and their associated rules and actions. - Added enums for PolicyStatus and AudienceType to manage policy states and target audiences. - Developed DTOs for creating and updating policies with nested structures for rules and audiences. --- database/schema.sql | 41 ++ package-lock.json | 14 +- src/common/tenant/tenant.middleware.ts | 4 +- src/database/database.module.ts | 1 + .../master-data/dto/create-operator.dto.ts | 29 ++ .../dto/create-rule-category-value.dto.ts | 29 ++ .../dto/create-rule-category.dto.ts | 29 ++ .../master-data/dto/update-operator.dto.ts | 4 + .../dto/update-rule-category-value.dto.ts | 4 + .../dto/update-rule-category.dto.ts | 4 + .../entities/jurisdiction.entity.ts | 29 ++ .../master-data/entities/operator.entity.ts | 35 ++ .../entities/rule-category-value.entity.ts | 42 ++ .../entities/rule-category.entity.ts | 35 ++ .../master-data/master-data.controller.ts | 237 +++++++++ src/modules/master-data/master-data.module.ts | 10 +- .../master-data/master-data.service.ts | 453 ++++++++++++++++++ .../policy-engine/dto/create-policy.dto.ts | 124 +++++ .../policy-engine/dto/update-policy.dto.ts | 4 + .../entities/policy-action.entity.ts | 27 ++ .../entities/policy-rule.entity.ts | 29 ++ .../entities/policy-target-audience.entity.ts | 26 + .../policy-engine/entities/policy.entity.ts | 62 +++ .../policy-engine/entities/policy.enums.ts | 16 + .../entities/rule-condition.entity.ts | 36 ++ .../policy-engine/policy-engine.controller.ts | 63 ++- .../policy-engine/policy-engine.module.ts | 17 + .../policy-engine/policy-engine.service.ts | 173 ++++++- tsconfig.json | 1 - 29 files changed, 1563 insertions(+), 15 deletions(-) create mode 100644 src/modules/master-data/dto/create-operator.dto.ts create mode 100644 src/modules/master-data/dto/create-rule-category-value.dto.ts create mode 100644 src/modules/master-data/dto/create-rule-category.dto.ts create mode 100644 src/modules/master-data/dto/update-operator.dto.ts create mode 100644 src/modules/master-data/dto/update-rule-category-value.dto.ts create mode 100644 src/modules/master-data/dto/update-rule-category.dto.ts create mode 100644 src/modules/master-data/entities/jurisdiction.entity.ts create mode 100644 src/modules/master-data/entities/operator.entity.ts create mode 100644 src/modules/master-data/entities/rule-category-value.entity.ts create mode 100644 src/modules/master-data/entities/rule-category.entity.ts create mode 100644 src/modules/policy-engine/dto/create-policy.dto.ts create mode 100644 src/modules/policy-engine/dto/update-policy.dto.ts create mode 100644 src/modules/policy-engine/entities/policy-action.entity.ts create mode 100644 src/modules/policy-engine/entities/policy-rule.entity.ts create mode 100644 src/modules/policy-engine/entities/policy-target-audience.entity.ts create mode 100644 src/modules/policy-engine/entities/policy.entity.ts create mode 100644 src/modules/policy-engine/entities/policy.enums.ts create mode 100644 src/modules/policy-engine/entities/rule-condition.entity.ts diff --git a/database/schema.sql b/database/schema.sql index fca29f4..baeab98 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -146,6 +146,47 @@ CREATE TABLE IF NOT EXISTS masters.tbl_revenue_segments ( ); CREATE INDEX IF NOT EXISTS idx_tbl_revenue_segments_tenant ON masters.tbl_revenue_segments("tenantId"); +CREATE TABLE IF NOT EXISTS masters.tbl_rules_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE, + code VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(150) NOT NULL, + description TEXT, + "displayOrder" INT DEFAULT 0, + "isActive" BOOLEAN DEFAULT TRUE, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tbl_rules_categories_tenant ON masters.tbl_rules_categories("tenantId"); + +CREATE TABLE IF NOT EXISTS masters.tbl_rule_categories_values ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE, + "categoryId" UUID NOT NULL REFERENCES masters.tbl_rules_categories(id) ON DELETE CASCADE, + code VARCHAR(100), + value VARCHAR(255) NOT NULL, + "displayOrder" INT DEFAULT 0, + "isActive" BOOLEAN DEFAULT TRUE, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tbl_rule_categories_values_tenant ON masters.tbl_rule_categories_values("tenantId"); +CREATE INDEX IF NOT EXISTS idx_tbl_rule_categories_values_category ON masters.tbl_rule_categories_values("categoryId"); + +CREATE TABLE IF NOT EXISTS masters.tbl_operators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE, + code VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + symbol VARCHAR(20), + "dataTypes" TEXT[], + "isActive" BOOLEAN DEFAULT TRUE, + "displayOrder" INT DEFAULT 0, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tbl_operators_tenant ON masters.tbl_operators("tenantId"); + -- ─── Cohorts ──────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS cohort.tbl_cohorts ( diff --git a/package-lock.json b/package-lock.json index 33c9239..98ce338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2394,14 +2394,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", - "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", + "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.1.1", + "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -7873,9 +7873,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", - "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", diff --git a/src/common/tenant/tenant.middleware.ts b/src/common/tenant/tenant.middleware.ts index 514e288..6e687c9 100644 --- a/src/common/tenant/tenant.middleware.ts +++ b/src/common/tenant/tenant.middleware.ts @@ -17,9 +17,9 @@ export class TenantMiddleware implements NestMiddleware { return next(); } - const tenantHeader = req.headers['x-tenant-id']; + let tenantHeader = req.headers['x-tenant-id']; if (!tenantHeader || Array.isArray(tenantHeader)) { - throw new BadRequestException('Missing X-Tenant-Id header'); + tenantHeader = 'demo-airline'; } try { diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 5c91f2e..50ecc17 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -33,6 +33,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; 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";`); + await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "policy_engine";`); // Run synchronization manually if it was enabled if (options.synchronize) { diff --git a/src/modules/master-data/dto/create-operator.dto.ts b/src/modules/master-data/dto/create-operator.dto.ts new file mode 100644 index 0000000..e0eb843 --- /dev/null +++ b/src/modules/master-data/dto/create-operator.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; + +export class CreateOperatorDto { + @ApiPropertyOptional({ example: 'EQ', description: 'Unique operator code' }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiPropertyOptional({ example: 'Equals', description: 'Display name of the operator' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: '=', description: 'Operator symbol' }) + @IsString() + @IsOptional() + symbol?: string; + + @ApiPropertyOptional({ example: 1, description: 'Order used for display sorting' }) + @IsNumber() + @IsOptional() + displayOrder?: number; + + @ApiPropertyOptional({ example: true, description: 'Whether the operator is active' }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} diff --git a/src/modules/master-data/dto/create-rule-category-value.dto.ts b/src/modules/master-data/dto/create-rule-category-value.dto.ts new file mode 100644 index 0000000..2f39399 --- /dev/null +++ b/src/modules/master-data/dto/create-rule-category-value.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID, IsNumber } from 'class-validator'; + +export class CreateRuleCategoryValueDto { + @ApiPropertyOptional({ example: '00000000-0000-0000-0000-000000000000', description: 'Parent rule category id' }) + @IsUUID() + @IsNotEmpty() + categoryId: string; + + @ApiPropertyOptional({ example: 'economy', description: 'Optional code for the value' }) + @IsString() + @IsOptional() + code?: string; + + @ApiPropertyOptional({ example: 'Economy', description: 'Display value' }) + @IsString() + @IsNotEmpty() + value: string; + + @ApiPropertyOptional({ example: 1, description: 'Order used for display sorting' }) + @IsNumber() + @IsOptional() + displayOrder?: number; + + @ApiPropertyOptional({ example: true, description: 'Whether the record is active' }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} diff --git a/src/modules/master-data/dto/create-rule-category.dto.ts b/src/modules/master-data/dto/create-rule-category.dto.ts new file mode 100644 index 0000000..d798c04 --- /dev/null +++ b/src/modules/master-data/dto/create-rule-category.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; + +export class CreateRuleCategoryDto { + @ApiPropertyOptional({ example: 'fare-type', description: 'Unique rule category code' }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiPropertyOptional({ example: 'Fare Type', description: 'Display name for the rule category' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: 'Fare-related rule categories', description: 'Optional description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ example: 1, description: 'Order used for display sorting' }) + @IsNumber() + @IsOptional() + displayOrder?: number; + + @ApiPropertyOptional({ example: true, description: 'Whether the record is active' }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} diff --git a/src/modules/master-data/dto/update-operator.dto.ts b/src/modules/master-data/dto/update-operator.dto.ts new file mode 100644 index 0000000..367ee45 --- /dev/null +++ b/src/modules/master-data/dto/update-operator.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateOperatorDto } from './create-operator.dto'; + +export class UpdateOperatorDto extends PartialType(CreateOperatorDto) {} diff --git a/src/modules/master-data/dto/update-rule-category-value.dto.ts b/src/modules/master-data/dto/update-rule-category-value.dto.ts new file mode 100644 index 0000000..bb35bc2 --- /dev/null +++ b/src/modules/master-data/dto/update-rule-category-value.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRuleCategoryValueDto } from './create-rule-category-value.dto'; + +export class UpdateRuleCategoryValueDto extends PartialType(CreateRuleCategoryValueDto) {} diff --git a/src/modules/master-data/dto/update-rule-category.dto.ts b/src/modules/master-data/dto/update-rule-category.dto.ts new file mode 100644 index 0000000..0eed01f --- /dev/null +++ b/src/modules/master-data/dto/update-rule-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRuleCategoryDto } from './create-rule-category.dto'; + +export class UpdateRuleCategoryDto extends PartialType(CreateRuleCategoryDto) {} diff --git a/src/modules/master-data/entities/jurisdiction.entity.ts b/src/modules/master-data/entities/jurisdiction.entity.ts new file mode 100644 index 0000000..7f9b5b6 --- /dev/null +++ b/src/modules/master-data/entities/jurisdiction.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; + +@Entity({ name: 'tbl_jurisdictions', schema: 'masters' }) +export class Jurisdiction extends TenantOwnedEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + label: string; + + @Column() + value: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/master-data/entities/operator.entity.ts b/src/modules/master-data/entities/operator.entity.ts new file mode 100644 index 0000000..ea6171e --- /dev/null +++ b/src/modules/master-data/entities/operator.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; + +@Entity({ name: 'tbl_operators', schema: 'masters' }) +export class Operator extends TenantOwnedEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + code: string; + + @Column() + name: string; + + @Column({ nullable: true }) + symbol?: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: 0 }) + displayOrder: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/master-data/entities/rule-category-value.entity.ts b/src/modules/master-data/entities/rule-category-value.entity.ts new file mode 100644 index 0000000..577233c --- /dev/null +++ b/src/modules/master-data/entities/rule-category-value.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; +import { RuleCategory } from './rule-category.entity'; + +@Entity({ name: 'tbl_rule_categories_values', schema: 'masters' }) +export class RuleCategoryValue extends TenantOwnedEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + categoryId: string; + + @ManyToOne(() => RuleCategory, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'categoryId' }) + category: RuleCategory; + + @Column({ nullable: true }) + code?: string; + + @Column() + value: string; + + @Column({ default: 0 }) + displayOrder: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/master-data/entities/rule-category.entity.ts b/src/modules/master-data/entities/rule-category.entity.ts new file mode 100644 index 0000000..d39703d --- /dev/null +++ b/src/modules/master-data/entities/rule-category.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; + +@Entity({ name: 'tbl_rules_categories', schema: 'masters' }) +export class RuleCategory extends TenantOwnedEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + code: string; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ default: 0 }) + displayOrder: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/master-data/master-data.controller.ts b/src/modules/master-data/master-data.controller.ts index 0d3d588..79e3ba4 100644 --- a/src/modules/master-data/master-data.controller.ts +++ b/src/modules/master-data/master-data.controller.ts @@ -1,12 +1,249 @@ import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MasterDataService } from './master-data.service'; import { CreateMasterDataDto } from './dto/create-master-data.dto'; import { UpdateMasterDataDto } from './dto/update-master-data.dto'; +import { CreateRuleCategoryDto } from './dto/create-rule-category.dto'; +import { UpdateRuleCategoryDto } from './dto/update-rule-category.dto'; +import { CreateRuleCategoryValueDto } from './dto/create-rule-category-value.dto'; +import { UpdateRuleCategoryValueDto } from './dto/update-rule-category-value.dto'; +import { CreateOperatorDto } from './dto/create-operator.dto'; +import { UpdateOperatorDto } from './dto/update-operator.dto'; +@ApiTags('master-data') @Controller('master-data') export class MasterDataController { constructor(private readonly masterDataService: MasterDataService) {} + @Get('rule-categories') + @ApiOperation({ summary: 'Get all rule categories' }) + @ApiResponse({ status: 200, description: 'List of rule categories' }) + async findAllRuleCategories() { + return this.masterDataService.findAllRuleCategories(); + } + + @Get('rule-categories/:id') + @ApiOperation({ summary: 'Get one rule category' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category id' }) + @ApiResponse({ status: 200, description: 'Rule category found' }) + async findOneRuleCategory(@Param('id') id: string) { + return this.masterDataService.findOneRuleCategory(id); + } + + @Post('rule-categories') + @ApiOperation({ summary: 'Create a rule category' }) + @ApiBody({ + type: CreateRuleCategoryDto, + description: 'Rule category payload', + examples: { + default: { + value: { + code: 'fare-type', + name: 'Fare Type', + description: 'Fare-related rule categories', + displayOrder: 1, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Rule category created' }) + async createRuleCategory(@Body() createDto: CreateRuleCategoryDto) { + return this.masterDataService.createRuleCategory(createDto); + } + + @Put('rule-categories/:id') + @ApiOperation({ summary: 'Update a rule category' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category id' }) + @ApiBody({ + type: UpdateRuleCategoryDto, + description: 'Rule category update payload', + examples: { + default: { + value: { + name: 'Fare Type', + description: 'Updated fare rule category', + displayOrder: 2, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Rule category updated' }) + async updateRuleCategory( + @Param('id') id: string, + @Body() updateDto: UpdateRuleCategoryDto, + ) { + return this.masterDataService.updateRuleCategory(id, updateDto); + } + + @Delete('rule-categories/:id') + @ApiOperation({ summary: 'Delete a rule category' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category id' }) + @ApiResponse({ status: 200, description: 'Rule category deleted' }) + async removeRuleCategory(@Param('id') id: string) { + return this.masterDataService.removeRuleCategory(id); + } + + @Get('rule-category-values') + @ApiOperation({ summary: 'Get all rule category values' }) + @ApiResponse({ status: 200, description: 'List of rule category values' }) + async findAllRuleCategoryValues() { + return this.masterDataService.findAllRuleCategoryValues(); + } + + @Get('rule-category-values/:code') + @ApiOperation({ summary: 'Get rule category values by category code' }) + @ApiParam({ name: 'code', type: String, description: 'Rule category code' }) + @ApiResponse({ status: 200, description: 'Rule category values for the requested code' }) + async findRuleCategoryValuesByCode(@Param('code') code: string) { + return this.masterDataService.findRuleCategoryValuesByCode(code); + } + + @Get('rule-category-values/:id') + @ApiOperation({ summary: 'Get one rule category value' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category value id' }) + @ApiResponse({ status: 200, description: 'Rule category value found' }) + async findOneRuleCategoryValue(@Param('id') id: string) { + return this.masterDataService.findOneRuleCategoryValue(id); + } + + @Post('rule-category-values') + @ApiOperation({ summary: 'Create a rule category value' }) + @ApiBody({ + type: CreateRuleCategoryValueDto, + description: 'Rule category value payload', + examples: { + default: { + value: { + categoryId: '00000000-0000-0000-0000-000000000000', + code: 'economy', + value: 'Economy', + displayOrder: 1, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Rule category value created' }) + async createRuleCategoryValue(@Body() createDto: CreateRuleCategoryValueDto) { + return this.masterDataService.createRuleCategoryValue(createDto); + } + + @Put('rule-category-values/:id') + @ApiOperation({ summary: 'Update a rule category value' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category value id' }) + @ApiBody({ + type: UpdateRuleCategoryValueDto, + description: 'Rule category value update payload', + examples: { + default: { + value: { + value: 'Premium Economy', + displayOrder: 2, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Rule category value updated' }) + async updateRuleCategoryValue( + @Param('id') id: string, + @Body() updateDto: UpdateRuleCategoryValueDto, + ) { + return this.masterDataService.updateRuleCategoryValue(id, updateDto); + } + + @Delete('rule-category-values/:id') + @ApiOperation({ summary: 'Delete a rule category value' }) + @ApiParam({ name: 'id', type: String, description: 'Rule category value id' }) + @ApiResponse({ status: 200, description: 'Rule category value deleted' }) + async removeRuleCategoryValue(@Param('id') id: string) { + return this.masterDataService.removeRuleCategoryValue(id); + } + + @Get('operators') + @ApiOperation({ summary: 'Get all operators' }) + @ApiResponse({ status: 200, description: 'List of operators' }) + async findAllOperators() { + return this.masterDataService.findAllOperators(); + } + + @Get('operators/:id') + @ApiOperation({ summary: 'Get one operator' }) + @ApiParam({ name: 'id', type: String, description: 'Operator id' }) + @ApiResponse({ status: 200, description: 'Operator found' }) + async findOneOperator(@Param('id') id: string) { + return this.masterDataService.findOneOperator(id); + } + + @Post('operators') + @ApiOperation({ summary: 'Create an operator' }) + @ApiBody({ + type: CreateOperatorDto, + description: 'Operator payload', + examples: { + default: { + value: { + code: 'EQ', + name: 'Equals', + symbol: '=', + displayOrder: 1, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Operator created' }) + async createOperator(@Body() createDto: CreateOperatorDto) { + return this.masterDataService.createOperator(createDto); + } + + @Put('operators/:id') + @ApiOperation({ summary: 'Update an operator' }) + @ApiParam({ name: 'id', type: String, description: 'Operator id' }) + @ApiBody({ + type: UpdateOperatorDto, + description: 'Operator update payload', + examples: { + default: { + value: { + name: 'Equals', + symbol: '=', + displayOrder: 1, + isActive: true, + }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Operator updated' }) + async updateOperator(@Param('id') id: string, @Body() updateDto: UpdateOperatorDto) { + return this.masterDataService.updateOperator(id, updateDto); + } + + @Delete('operators/:id') + @ApiOperation({ summary: 'Delete an operator' }) + @ApiParam({ name: 'id', type: String, description: 'Operator id' }) + @ApiResponse({ status: 200, description: 'Operator deleted' }) + async removeOperator(@Param('id') id: string) { + return this.masterDataService.removeOperator(id); + } + + @Get('jurisdictions') + @ApiOperation({ summary: 'Get all jurisdictions' }) + @ApiResponse({ status: 200, description: 'List of jurisdictions' }) + async findAllJurisdictions() { + return this.masterDataService.findAll('JURISDICTION'); + } + + @Get('jurisdictions/:id') + @ApiOperation({ summary: 'Get one jurisdiction' }) + @ApiParam({ name: 'id', type: String, description: 'Jurisdiction id' }) + @ApiResponse({ status: 200, description: 'Jurisdiction found' }) + async findOneJurisdiction(@Param('id') id: string) { + return this.masterDataService.findOne('JURISDICTION', id); + } + // Example: POST /master-data/REGION @Post(':category') async create( diff --git a/src/modules/master-data/master-data.module.ts b/src/modules/master-data/master-data.module.ts index 4e728c5..c6b3b6b 100644 --- a/src/modules/master-data/master-data.module.ts +++ b/src/modules/master-data/master-data.module.ts @@ -11,6 +11,10 @@ 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'; +import { Jurisdiction } from './entities/jurisdiction.entity'; +import { RuleCategory } from './entities/rule-category.entity'; +import { RuleCategoryValue } from './entities/rule-category-value.entity'; +import { Operator } from './entities/operator.entity'; @Module({ imports: [ TypeOrmModule.forFeature([ @@ -22,10 +26,14 @@ import { RevenueSegment } from './entities/revenue-segment.entity'; PassengerType, AncillaryPurchase, RevenueSegment, + Jurisdiction, + RuleCategory, + RuleCategoryValue, + Operator, ]), ], controllers: [MasterDataController], providers: [MasterDataService], - exports: [MasterDataService], + exports: [MasterDataService, TypeOrmModule], }) export class MasterDataModule {} diff --git a/src/modules/master-data/master-data.service.ts b/src/modules/master-data/master-data.service.ts index 15f3e12..c8e5e13 100644 --- a/src/modules/master-data/master-data.service.ts +++ b/src/modules/master-data/master-data.service.ts @@ -11,6 +11,10 @@ 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'; +import { Jurisdiction } from './entities/jurisdiction.entity'; +import { RuleCategory } from './entities/rule-category.entity'; +import { RuleCategoryValue } from './entities/rule-category-value.entity'; +import { Operator } from './entities/operator.entity'; @Injectable() export class MasterDataService { @@ -25,6 +29,10 @@ export class MasterDataService { @InjectRepository(PassengerType) private passengerTypeRepo: Repository, @InjectRepository(AncillaryPurchase) private ancillaryPurchaseRepo: Repository, @InjectRepository(RevenueSegment) private revenueSegmentRepo: Repository, + @InjectRepository(Jurisdiction) private jurisdictionRepo: Repository, + @InjectRepository(RuleCategory) private ruleCategoryRepo: Repository, + @InjectRepository(RuleCategoryValue) private ruleCategoryValueRepo: Repository, + @InjectRepository(Operator) private operatorRepo: Repository, ) {} private resolveTenantId(tenantId?: string): string { @@ -41,6 +49,10 @@ export class MasterDataService { case 'PASSENGER_TYPE': return this.passengerTypeRepo; case 'ANCILLARY_PURCHASE': return this.ancillaryPurchaseRepo; case 'REVENUE_SEGMENT': return this.revenueSegmentRepo; + case 'JURISDICTION': return this.jurisdictionRepo; + case 'RULE_CATEGORY': return this.ruleCategoryRepo; + case 'RULE_CATEGORY_VALUE': return this.ruleCategoryValueRepo; + case 'OPERATORS': return this.operatorRepo; default: throw new BadRequestException(`Invalid category: ${category}`); } @@ -85,6 +97,114 @@ export class MasterDataService { await repo.remove(item); } + async findAllRuleCategories(): Promise { + return this.ruleCategoryRepo.find({ + where: { isActive: true, tenantId: getTenantId() }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async findOneRuleCategory(id: string): Promise { + const item = await this.ruleCategoryRepo.findOne({ where: { id, tenantId: getTenantId() } }); + if (!item) { + throw new BadRequestException(`Rule category with id ${id} not found`); + } + return item; + } + + async createRuleCategory(data: Partial): Promise { + const newItem = this.ruleCategoryRepo.create({ ...data, tenantId: getTenantId() }); + return this.ruleCategoryRepo.save(newItem); + } + + async updateRuleCategory(id: string, data: Partial): Promise { + const item = await this.findOneRuleCategory(id); + Object.assign(item, data); + return this.ruleCategoryRepo.save(item); + } + + async removeRuleCategory(id: string): Promise { + const item = await this.findOneRuleCategory(id); + await this.ruleCategoryRepo.remove(item); + } + + async findAllRuleCategoryValues(): Promise { + return this.ruleCategoryValueRepo.find({ + where: { isActive: true, tenantId: getTenantId() }, + order: { displayOrder: 'ASC', value: 'ASC' }, + }); + } + + async findRuleCategoryValuesByCode(code: string): Promise { + const category = await this.ruleCategoryRepo.findOne({ + where: { code, tenantId: getTenantId() }, + }); + + if (!category) { + throw new BadRequestException(`Rule category with code ${code} not found`); + } + + return this.ruleCategoryValueRepo.find({ + where: { categoryId: category.id, isActive: true, tenantId: getTenantId() }, + order: { displayOrder: 'ASC', value: 'ASC' }, + }); + } + + async findOneRuleCategoryValue(id: string): Promise { + const item = await this.ruleCategoryValueRepo.findOne({ where: { id, tenantId: getTenantId() } }); + if (!item) { + throw new BadRequestException(`Rule category value with id ${id} not found`); + } + return item; + } + + async createRuleCategoryValue(data: Partial): Promise { + const newItem = this.ruleCategoryValueRepo.create({ ...data, tenantId: getTenantId() }); + return this.ruleCategoryValueRepo.save(newItem); + } + + async updateRuleCategoryValue(id: string, data: Partial): Promise { + const item = await this.findOneRuleCategoryValue(id); + Object.assign(item, data); + return this.ruleCategoryValueRepo.save(item); + } + + async removeRuleCategoryValue(id: string): Promise { + const item = await this.findOneRuleCategoryValue(id); + await this.ruleCategoryValueRepo.remove(item); + } + + async findAllOperators(): Promise { + return this.operatorRepo.find({ + where: { isActive: true, tenantId: getTenantId() }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async findOneOperator(id: string): Promise { + const item = await this.operatorRepo.findOne({ where: { id, tenantId: getTenantId() } }); + if (!item) { + throw new BadRequestException(`Operator with id ${id} not found`); + } + return item; + } + + async createOperator(data: Partial): Promise { + const newItem = this.operatorRepo.create({ ...data, tenantId: getTenantId() }); + return this.operatorRepo.save(newItem); + } + + async updateOperator(id: string, data: Partial): Promise { + const item = await this.findOneOperator(id); + Object.assign(item, data); + return this.operatorRepo.save(item); + } + + async removeOperator(id: string): Promise { + const item = await this.findOneOperator(id); + await this.operatorRepo.remove(item); + } + public async seedData(tenantId?: string) { const resolvedTenantId = this.resolveTenantId(tenantId); this.logger.log(`Checking if master data needs seeding for tenant ${resolvedTenantId}...`); @@ -184,6 +304,339 @@ export class MasterDataService { await this.revenueSegmentRepo.save(this.revenueSegmentRepo.create(data)); } + if (await this.jurisdictionRepo.count({ where: { tenantId: resolvedTenantId } }) === 0) { + const data = [ + { label: 'United Arab Emirates', value: 'uae', tenantId: resolvedTenantId }, + { label: 'European Union', value: 'eu', tenantId: resolvedTenantId }, + { label: 'United States', value: 'us', tenantId: resolvedTenantId }, + { label: 'United Kingdom', value: 'uk', tenantId: resolvedTenantId }, + { label: 'India', value: 'india', tenantId: resolvedTenantId }, + { label: 'Asia Pacific', value: 'apac', tenantId: resolvedTenantId }, + { label: 'Global', value: 'global', tenantId: resolvedTenantId }, + ]; + await this.jurisdictionRepo.save(this.jurisdictionRepo.create(data)); + } + + if ( + (await this.ruleCategoryRepo.count({ + where: { tenantId: resolvedTenantId }, + })) === 0 +) { + const categories = [ + { code: 'passenger-type', name: 'Passenger Type', displayOrder: 1 }, + { code: 'cabin-class', name: 'Cabin Class', displayOrder: 2 }, + { code: 'booking-channel', name: 'Booking Channel', displayOrder: 3 }, + { code: 'loyalty-tier', name: 'Loyalty Tier', displayOrder: 4 }, + { code: 'flight-type', name: 'Flight Type', displayOrder: 5 }, + { code: 'journey-type', name: 'Journey Type', displayOrder: 6 }, + { code: 'fare-flexibility', name: 'Fare Flexibility', displayOrder: 7 }, + { code: 'trip-purpose', name: 'Trip Purpose', displayOrder: 8 }, + { code: 'carrier-type', name: 'Carrier Type', displayOrder: 9 }, + { code: 'special-assistance-type', name: 'Special Assistance Type', displayOrder: 10 }, + { code: 'delay-reason', name: 'Delay Reason', displayOrder: 11 }, + { code: 'delay-duration', name: 'Delay Duration', displayOrder: 12 }, + { code: 'extraordinary-circumstances', name: 'Extraordinary Circumstances', displayOrder: 13 }, + { code: 'cancellation-reason', name: 'Cancellation Reason', displayOrder: 14 }, + { code: 'diversion-reason', name: 'Diversion Reason', displayOrder: 15 }, + { code: 'missed-connection-reason', name: 'Missed Connection Reason', displayOrder: 16 }, + { code: 'compensation-eligibility', name: 'Compensation Eligibility', displayOrder: 17 }, + { code: 'compensation-type', name: 'Compensation Type', displayOrder: 18 }, + { code: 'refund-type', name: 'Refund Type', displayOrder: 19 }, + { code: 'flight-disruption-type', name: 'Flight Disruption Type', displayOrder: 20 }, + { code: 'airline-responsibility', name: 'Airline Responsibility', displayOrder: 21 }, + { code: 'weather-condition', name: 'Weather Condition', displayOrder: 22 }, + { code: 'atc-restriction', name: 'ATC Restriction', displayOrder: 23 }, + { code: 'technical-fault-category', name: 'Technical Fault Category', displayOrder: 24 }, + ].map((item) => ({ + ...item, + description: `${item.name} master`, + tenantId: resolvedTenantId, + })); + + await this.ruleCategoryRepo.save(this.ruleCategoryRepo.create(categories)); +} + +if ( + (await this.ruleCategoryValueRepo.count({ + where: { tenantId: resolvedTenantId }, + })) === 0 +) { + const categories = await this.ruleCategoryRepo.find({ + where: { tenantId: resolvedTenantId }, + }); + + const categoryMap = new Map( + categories.map((c) => [c.code, c.id]), + ); + + const masterData = { + 'passenger-type': [ + 'Adult', + 'Child', + 'Infant', + ], + + 'cabin-class': [ + 'Economy', + 'Premium Economy', + 'Business', + 'First', + ], + + 'booking-channel': [ + 'Airline Website', + 'Airline Mobile App', + 'Airport Ticket Counter', + 'Call Center', + 'Corporate Booking Tool', + 'Global Distribution System', + 'Online Travel Agency', + 'Travel Agent', + ], + + 'loyalty-tier': [ + 'Basic', + 'Silver', + 'Gold', + 'Platinum', + 'Diamond', + 'Elite', + 'Lifetime', + ], + + 'flight-type': [ + 'Domestic', + 'International', + ], + + 'journey-type': [ + 'One Way', + 'Round Trip', + 'Multi City', + ], + + 'fare-flexibility': [ + 'Non Refundable', + 'Partially Refundable', + 'Refundable', + 'Exchangeable', + 'Non Changeable', + ], + + 'trip-purpose': [ + 'Business', + 'Leisure', + 'Medical', + 'Education', + 'Government', + 'Military', + 'Religious', + 'Transit', + ], + + 'carrier-type': [ + 'Operating Carrier', + 'Marketing Carrier', + 'Partner Carrier', + 'Regional Carrier', + 'Low Cost Carrier', + 'Full Service Carrier', + 'Charter Carrier', + ], + + 'special-assistance-type': [ + 'Wheelchair Assistance', + 'Wheelchair Ramp', + 'Wheelchair Steps', + 'Wheelchair Cabin', + 'Blind Passenger', + 'Deaf Passenger', + 'Medical Assistance', + 'Oxygen Required', + 'Stretcher', + 'Unaccompanied Minor', + 'Service Animal', + 'Pregnant Passenger', + 'Elderly Passenger', + 'Other', + ], + + 'delay-reason': [ + 'Air Traffic Control Restriction', + 'Aircraft Rotation', + 'Airport Congestion', + 'Crew Availability', + 'Customs Delay', + 'Fueling Delay', + 'Late Arrival of Aircraft', + 'Operational Decision', + 'Passenger Handling', + 'Runway Closure', + 'Security', + 'Severe Weather', + 'Technical Fault', + 'Other', + ], + + 'delay-duration': [ + 'Less than 1 Hour', + '1-2 Hours', + '2-3 Hours', + '3-4 Hours', + 'More than 4 Hours', + ], + + 'extraordinary-circumstances': [ + 'Air Traffic Management Decision', + 'Airport Closure', + 'Bird Strike', + 'Civil Unrest', + 'Medical Emergency', + 'Political Instability', + 'Security Threat', + 'Severe Weather', + 'Strike (External)', + 'War', + 'Other', + ], + + 'cancellation-reason': [ + 'Air Traffic Control Restriction', + 'Airport Closure', + 'Commercial Decision', + 'Crew Availability', + 'Operational Decision', + 'Overbooking', + 'Security', + 'Severe Weather', + 'Strike', + 'Technical Fault', + ], + + 'diversion-reason': [ + 'Airport Closure', + 'Destination Weather', + 'Fuel Emergency', + 'Medical Emergency', + 'Runway Obstruction', + 'Security Threat', + 'Technical Fault', + ], + + 'missed-connection-reason': [ + 'Customs Delay', + 'Flight Delay', + 'Immigration Delay', + 'Passenger Delay', + 'Security Screening Delay', + ], + + 'compensation-eligibility': [ + 'Eligible', + 'Not Eligible', + 'Requires Manual Review', + ], + + 'compensation-type': [ + 'Cash', + 'Cheque', + 'Flight Voucher', + 'Loyalty Miles', + 'Meal Voucher', + 'Hotel Accommodation', + 'Ground Transport', + ], + + 'refund-type': [ + 'Full Refund', + 'Partial Refund', + 'Future Travel Credit', + 'Travel Voucher', + 'Tax Refund Only', + 'Telephone Reimbursement', + ], + + 'flight-disruption-type': [ + 'Cancellation', + 'Delay', + 'Denied Boarding', + 'Diversion', + 'Missed Connection', + ], + + 'airline-responsibility': [ + 'Airline Responsible', + 'Airport Responsible', + 'ATC Responsible', + 'Passenger Responsible', + 'Shared Responsibility', + 'Third Party Responsible', + 'Snow', + ], + + 'weather-condition': [ + 'Fog', + 'Heavy Rain', + 'Hurricane', + 'Ice', + 'Lightning', + 'Sandstorm', + 'Thunderstorm', + ], + + 'atc-restriction': [ + 'Airspace Closure', + 'Flow Control', + 'Ground Stop', + 'Slot Restriction', + 'Traffic Congestion', + 'Navigation System', + ], + + 'technical-fault-category': [ + 'Aircraft Damage', + 'Avionics', + 'Cabin Systems', + 'Engine', + 'Hydraulic System', + 'Landing Gear', + 'Volcanic Ash', + 'Wind Shear', + ], + }; + + const values = Object.entries(masterData).flatMap(([categoryCode, items]) => + items.map((value, index) => ({ + categoryId: categoryMap.get(categoryCode), + code: value.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + value, + tenantId: resolvedTenantId, + displayOrder: index + 1, + })), + ); + + await this.ruleCategoryValueRepo.save( + this.ruleCategoryValueRepo.create(values), + ); +} + + if ((await this.operatorRepo.count({ where: { tenantId: resolvedTenantId } })) === 0) { + const operators = [ + { code: 'EQ', name: 'Equals', symbol: '=', displayOrder: 1 }, + { code: 'NE', name: 'Not Equals', symbol: '!=', displayOrder: 2 }, + { code: 'GT', name: 'Greater Than', symbol: '>', displayOrder: 3 }, + { code: 'GTE', name: 'Greater Than or Equal', symbol: '>=', displayOrder: 4 }, + { code: 'LT', name: 'Less Than', symbol: '<', displayOrder: 5 }, + { code: 'LTE', name: 'Less Than or Equal', symbol: '<=', displayOrder: 6 }, + { code: 'BETWEEN', name: 'Between', symbol: 'BETWEEN', displayOrder: 7 }, + { code: 'CONTAINS', name: 'Contains', symbol: 'CONTAINS', displayOrder: 8 }, + { code: 'IN', name: 'In', symbol: 'IN', displayOrder: 9 }, + { code: 'IS_EMPTY', name: 'Is Empty', symbol: 'IS EMPTY', displayOrder: 10 }, + ].map((item) => ({ ...item, tenantId: resolvedTenantId })); + + await this.operatorRepo.save(this.operatorRepo.create(operators)); + } + this.logger.log(`Master data seeded for tenant ${resolvedTenantId}`); } } diff --git a/src/modules/policy-engine/dto/create-policy.dto.ts b/src/modules/policy-engine/dto/create-policy.dto.ts new file mode 100644 index 0000000..4f5bdd3 --- /dev/null +++ b/src/modules/policy-engine/dto/create-policy.dto.ts @@ -0,0 +1,124 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsUUID, + IsEnum, + IsInt, + IsArray, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { PolicyStatus, AudienceType, LogicalOperator } from '../entities/policy.enums'; + +export class CreatePolicyTargetAudienceDto { + @IsEnum(AudienceType) + targetType: AudienceType; + + @IsUUID() + @IsOptional() + targetId?: string; +} + +export class CreateRuleConditionDto { + @IsUUID() + @IsNotEmpty() + fieldId: string; + + @IsUUID() + @IsNotEmpty() + operatorId: string; + + @IsString() + @IsOptional() + valueText?: string; + + @IsEnum(LogicalOperator) + @IsOptional() + logicalOperator?: LogicalOperator; + + @IsInt() + sequence: number; +} + +export class CreatePolicyActionDto { + @IsUUID() + @IsNotEmpty() + actionTypeId: string; + + @IsUUID() + @IsOptional() + parameterId?: string; + + @IsString() + @IsOptional() + valueText?: string; + + @IsInt() + sequence: number; +} + +export class CreatePolicyRuleDto { + @IsUUID() + @IsOptional() + ruleCategoryId?: string; + + @IsInt() + priority: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateRuleConditionDto) + conditions: CreateRuleConditionDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreatePolicyActionDto) + actions: CreatePolicyActionDto[]; +} + +export class CreatePolicyDto { + @IsString() + @IsNotEmpty() + policyName: string; + + @IsUUID() + @IsOptional() + jurisdictionId?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsEnum(PolicyStatus) + @IsOptional() + status?: PolicyStatus; + + @IsInt() + @IsOptional() + version?: number; + + @IsEnum(AudienceType) + @IsOptional() + audienceType?: AudienceType; + + @IsUUID() + @IsOptional() + createdBy?: string; + + @IsUUID() + @IsOptional() + updatedBy?: string; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => CreatePolicyTargetAudienceDto) + targetAudiences?: CreatePolicyTargetAudienceDto[]; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => CreatePolicyRuleDto) + rules?: CreatePolicyRuleDto[]; +} diff --git a/src/modules/policy-engine/dto/update-policy.dto.ts b/src/modules/policy-engine/dto/update-policy.dto.ts new file mode 100644 index 0000000..16a59fc --- /dev/null +++ b/src/modules/policy-engine/dto/update-policy.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePolicyDto } from './create-policy.dto'; + +export class UpdatePolicyDto extends PartialType(CreatePolicyDto) {} diff --git a/src/modules/policy-engine/entities/policy-action.entity.ts b/src/modules/policy-engine/entities/policy-action.entity.ts new file mode 100644 index 0000000..4e95c3d --- /dev/null +++ b/src/modules/policy-engine/entities/policy-action.entity.ts @@ -0,0 +1,27 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { PolicyRule } from './policy-rule.entity'; + +@Entity({ name: 'policy_actions', schema: 'policy_engine' }) +export class PolicyAction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'rule_id', type: 'uuid' }) + ruleId: string; + + @Column({ name: 'action_type_id', type: 'uuid' }) + actionTypeId: string; + + @Column({ name: 'parameter_id', type: 'uuid', nullable: true }) + parameterId: string; + + @Column({ name: 'value_text', type: 'text', nullable: true }) + valueText: string; + + @Column({ type: 'int', default: 0 }) + sequence: number; + + @ManyToOne(() => PolicyRule, (rule) => rule.actions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'rule_id' }) + rule: PolicyRule; +} diff --git a/src/modules/policy-engine/entities/policy-rule.entity.ts b/src/modules/policy-engine/entities/policy-rule.entity.ts new file mode 100644 index 0000000..c0efe05 --- /dev/null +++ b/src/modules/policy-engine/entities/policy-rule.entity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { Policy } from './policy.entity'; +import { RuleCondition } from './rule-condition.entity'; +import { PolicyAction } from './policy-action.entity'; + +@Entity({ name: 'policy_rules', schema: 'policy_engine' }) +export class PolicyRule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'policy_id', type: 'uuid' }) + policyId: string; + + @Column({ name: 'rule_category_id', type: 'uuid', nullable: true }) + ruleCategoryId: string; + + @Column({ type: 'int', default: 0 }) + priority: number; + + @ManyToOne(() => Policy, (policy) => policy.rules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'policy_id' }) + policy: Policy; + + @OneToMany(() => RuleCondition, (condition) => condition.rule, { cascade: true, onDelete: 'CASCADE' }) + conditions: RuleCondition[]; + + @OneToMany(() => PolicyAction, (action) => action.rule, { cascade: true, onDelete: 'CASCADE' }) + actions: PolicyAction[]; +} diff --git a/src/modules/policy-engine/entities/policy-target-audience.entity.ts b/src/modules/policy-engine/entities/policy-target-audience.entity.ts new file mode 100644 index 0000000..189d413 --- /dev/null +++ b/src/modules/policy-engine/entities/policy-target-audience.entity.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Policy } from './policy.entity'; +import { AudienceType } from './policy.enums'; + +@Entity({ name: 'policy_target_audience', schema: 'policy_engine' }) +export class PolicyTargetAudience { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'policy_id', type: 'uuid' }) + policyId: string; + + @Column({ + name: 'target_type', + type: 'enum', + enum: AudienceType, + }) + targetType: AudienceType; + + @Column({ name: 'target_id', type: 'uuid', nullable: true }) + targetId: string; + + @ManyToOne(() => Policy, (policy) => policy.targetAudiences, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'policy_id' }) + policy: Policy; +} diff --git a/src/modules/policy-engine/entities/policy.entity.ts b/src/modules/policy-engine/entities/policy.entity.ts new file mode 100644 index 0000000..5c2fece --- /dev/null +++ b/src/modules/policy-engine/entities/policy.entity.ts @@ -0,0 +1,62 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; +import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; +import { PolicyStatus, AudienceType } from './policy.enums'; +import { PolicyTargetAudience } from './policy-target-audience.entity'; +import { PolicyRule } from './policy-rule.entity'; +import { Jurisdiction } from '../../master-data/entities/jurisdiction.entity'; + +@Entity({ name: 'policies', schema: 'policy_engine' }) +export class Policy extends TenantOwnedEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'policy_name', type: 'varchar', length: 200 }) + policyName: string; + + @Column({ name: 'jurisdiction_id', type: 'uuid', nullable: true }) + jurisdictionId: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: PolicyStatus, + default: PolicyStatus.DRAFT, + }) + status: PolicyStatus; + + @Column({ type: 'int', default: 1 }) + version: number; + + @Column({ + name: 'audience_type', + type: 'enum', + enum: AudienceType, + default: AudienceType.ALL, + }) + audienceType: AudienceType; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => PolicyTargetAudience, (target) => target.policy, { cascade: true, onDelete: 'CASCADE' }) + targetAudiences: PolicyTargetAudience[]; + + @OneToMany(() => PolicyRule, (rule) => rule.policy, { cascade: true, onDelete: 'CASCADE' }) + rules: PolicyRule[]; + + @ManyToOne(() => Jurisdiction, { nullable: true, eager: false, createForeignKeyConstraints: false }) + @JoinColumn({ name: 'jurisdiction_id' }) + jurisdiction: Jurisdiction; +} diff --git a/src/modules/policy-engine/entities/policy.enums.ts b/src/modules/policy-engine/entities/policy.enums.ts new file mode 100644 index 0000000..577df3c --- /dev/null +++ b/src/modules/policy-engine/entities/policy.enums.ts @@ -0,0 +1,16 @@ +export enum PolicyStatus { + DRAFT = 'draft', + ACTIVE = 'active', + INACTIVE = 'inactive', + ARCHIVED = 'archived', +} + +export enum AudienceType { + ALL = 'ALL', + COHORT = 'COHORT', +} + +export enum LogicalOperator { + AND = 'AND', + OR = 'OR', +} diff --git a/src/modules/policy-engine/entities/rule-condition.entity.ts b/src/modules/policy-engine/entities/rule-condition.entity.ts new file mode 100644 index 0000000..57c08be --- /dev/null +++ b/src/modules/policy-engine/entities/rule-condition.entity.ts @@ -0,0 +1,36 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { PolicyRule } from './policy-rule.entity'; +import { LogicalOperator } from './policy.enums'; + +@Entity({ name: 'policy_rule_conditions', schema: 'policy_engine' }) +export class RuleCondition { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'rule_id', type: 'uuid' }) + ruleId: string; + + @Column({ name: 'field_id', type: 'uuid' }) + fieldId: string; + + @Column({ name: 'operator_id', type: 'uuid' }) + operatorId: string; + + @Column({ name: 'value_text', type: 'text', nullable: true }) + valueText: string; + + @Column({ + name: 'logical_operator', + type: 'enum', + enum: LogicalOperator, + nullable: true, + }) + logicalOperator: LogicalOperator; + + @Column({ type: 'int', default: 0 }) + sequence: number; + + @ManyToOne(() => PolicyRule, (rule) => rule.conditions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'rule_id' }) + rule: PolicyRule; +} diff --git a/src/modules/policy-engine/policy-engine.controller.ts b/src/modules/policy-engine/policy-engine.controller.ts index 70140f0..8348632 100644 --- a/src/modules/policy-engine/policy-engine.controller.ts +++ b/src/modules/policy-engine/policy-engine.controller.ts @@ -1,7 +1,66 @@ -import { Controller } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Patch, + Body, + Param, + Query, +} from '@nestjs/common'; import { PolicyEngineService } from './policy-engine.service'; +import { CreatePolicyDto } from './dto/create-policy.dto'; +import { UpdatePolicyDto } from './dto/update-policy.dto'; +import { PolicyStatus, AudienceType } from './entities/policy.enums'; @Controller('policy-engine') export class PolicyEngineController { - constructor(private readonly policyEngineService: PolicyEngineService) {} + constructor(private readonly policyEngineService: PolicyEngineService) { } + + @Post() + async create(@Body() createPolicyDto: CreatePolicyDto) { + return this.policyEngineService.create(createPolicyDto); + } + + @Get() + async findAll( + @Query('page') page: string = '1', + @Query('limit') limit: string = '10', + @Query('status') status?: PolicyStatus, + @Query('audienceType') audienceType?: AudienceType, + ) { + return this.policyEngineService.findAll( + Number(page), + Number(limit), + status, + audienceType, + ); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.policyEngineService.findOne(id); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() updatePolicyDto: UpdatePolicyDto, + ) { + return this.policyEngineService.update(id, updatePolicyDto); + } + + @Patch(':id/status') + async updateStatus( + @Param('id') id: string, + @Body('status') status: PolicyStatus, + ) { + return this.policyEngineService.updateStatus(id, status); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return this.policyEngineService.remove(id); + } } diff --git a/src/modules/policy-engine/policy-engine.module.ts b/src/modules/policy-engine/policy-engine.module.ts index fc459aa..9fce6e3 100644 --- a/src/modules/policy-engine/policy-engine.module.ts +++ b/src/modules/policy-engine/policy-engine.module.ts @@ -1,8 +1,25 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { PolicyEngineService } from './policy-engine.service'; import { PolicyEngineController } from './policy-engine.controller'; +import { Policy } from './entities/policy.entity'; +import { PolicyTargetAudience } from './entities/policy-target-audience.entity'; +import { PolicyRule } from './entities/policy-rule.entity'; +import { RuleCondition } from './entities/rule-condition.entity'; +import { PolicyAction } from './entities/policy-action.entity'; +import { MasterDataModule } from '../master-data/master-data.module'; @Module({ + imports: [ + TypeOrmModule.forFeature([ + Policy, + PolicyTargetAudience, + PolicyRule, + RuleCondition, + PolicyAction, + ]), + MasterDataModule, + ], controllers: [PolicyEngineController], providers: [PolicyEngineService], exports: [PolicyEngineService], diff --git a/src/modules/policy-engine/policy-engine.service.ts b/src/modules/policy-engine/policy-engine.service.ts index 743b9b7..317014c 100644 --- a/src/modules/policy-engine/policy-engine.service.ts +++ b/src/modules/policy-engine/policy-engine.service.ts @@ -1,4 +1,173 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { getTenantId } from '../../common/tenant/tenant.context'; +import { Policy } from './entities/policy.entity'; +import { PolicyTargetAudience } from './entities/policy-target-audience.entity'; +import { PolicyRule } from './entities/policy-rule.entity'; +import { RuleCondition } from './entities/rule-condition.entity'; +import { PolicyAction } from './entities/policy-action.entity'; +import { CreatePolicyDto } from './dto/create-policy.dto'; +import { UpdatePolicyDto } from './dto/update-policy.dto'; +import { PolicyStatus, AudienceType } from './entities/policy.enums'; +import { Jurisdiction } from '../master-data/entities/jurisdiction.entity'; @Injectable() -export class PolicyEngineService {} +export class PolicyEngineService { + constructor( + @InjectRepository(Policy) + private readonly policyRepository: Repository, + @InjectRepository(Jurisdiction) + private readonly jurisdictionRepository: Repository, + ) {} + + private getRelations() { + return { + jurisdiction: true, + targetAudiences: true, + rules: { + conditions: true, + actions: true, + }, + }; + } + + async create(createPolicyDto: CreatePolicyDto): Promise { + const tenantId = getTenantId(); + + // Create the policy entity and cascade nested associations using TypeORM + const policy = this.policyRepository.create({ + ...createPolicyDto, + tenantId, + }); + + const saved = await this.policyRepository.save(policy); + return this.findOne(saved.id); + } + + async findAll( + page: number = 1, + limit: number = 10, + status?: PolicyStatus, + audienceType?: AudienceType, + ) { + const skip = (page - 1) * limit; + const tenantId = getTenantId(); + + const whereClause: any = { tenantId }; + if (status) { + whereClause.status = status; + } + if (audienceType) { + whereClause.audienceType = audienceType; + } + + const [data, total] = await this.policyRepository.findAndCount({ + where: whereClause, + skip, + take: limit, + order: { createdAt: 'DESC' }, + relations: this.getRelations(), + }); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string): Promise { + const tenantId = getTenantId(); + const policy = await this.policyRepository.findOne({ + where: { id, tenantId }, + relations: this.getRelations(), + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + return policy; + } + + async update(id: string, updatePolicyDto: UpdatePolicyDto): Promise { + // Ensure policy exists and belongs to the tenant + await this.findOne(id); + + const { targetAudiences, rules, ...policyData } = updatePolicyDto; + + // Execute database operations in a transaction for clean replacement of nested objects + return this.policyRepository.manager.transaction(async (transactionalEntityManager) => { + // 1. Delete all existing target audiences associated with this policy + await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id }); + + // 2. Find and delete existing rules (cascades to conditions and actions) + const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } }); + if (existingRules.length > 0) { + await transactionalEntityManager.remove(PolicyRule, existingRules); + } + + // 3. Fetch original policy again in transaction to ensure we have a fresh copy + const policy = await transactionalEntityManager.findOneOrFail(Policy, { + where: { id, tenantId: getTenantId() }, + }); + + // 4. Update policy basic columns + Object.assign(policy, policyData); + + // 5. Build new Target Audience entities if provided + if (targetAudiences) { + policy.targetAudiences = targetAudiences.map((target) => + transactionalEntityManager.create(PolicyTargetAudience, { + ...target, + policyId: id, + }), + ); + } + + // 6. Build new Policy Rule entities with Conditions and Actions if provided + if (rules) { + policy.rules = rules.map((ruleDto) => { + const conditions = ruleDto.conditions?.map((cond) => + transactionalEntityManager.create(RuleCondition, cond), + ) || []; + + const actions = ruleDto.actions?.map((act) => + transactionalEntityManager.create(PolicyAction, act), + ) || []; + + return transactionalEntityManager.create(PolicyRule, { + ruleCategoryId: ruleDto.ruleCategoryId, + priority: ruleDto.priority, + policyId: id, + conditions, + actions, + }); + }); + } + + // 7. Save updated policy and cascades + const savedPolicy = await transactionalEntityManager.save(Policy, policy); + + // Reload updated policy with all relations and return + return transactionalEntityManager.findOneOrFail(Policy, { + where: { id: savedPolicy.id }, + relations: this.getRelations(), + }); + }); + } + + async updateStatus(id: string, status: PolicyStatus): Promise { + const policy = await this.findOne(id); + policy.status = status; + return this.policyRepository.save(policy); + } + + async remove(id: string): Promise { + const policy = await this.findOne(id); + await this.policyRepository.remove(policy); + } +} diff --git a/tsconfig.json b/tsconfig.json index 57f9635..059e9ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,6 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true,