251128:1700 Backend to T3.1.1

This commit is contained in:
admin
2025-11-28 17:12:05 +07:00
parent b22d00877e
commit f7a43600a3
50 changed files with 4891 additions and 2849 deletions

View File

@@ -1,3 +1,4 @@
// File: src/modules/json-schema/dto/create-json-schema.dto.ts
import {
IsString,
IsNotEmpty,
@@ -5,22 +6,65 @@ import {
IsOptional,
IsBoolean,
IsObject,
IsArray,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class VirtualColumnConfigDto {
@IsString()
@IsNotEmpty()
json_path!: string;
@IsString()
@IsNotEmpty()
column_name!: string;
@IsString()
@IsNotEmpty()
data_type!: 'INT' | 'VARCHAR' | 'BOOLEAN' | 'DATE' | 'DECIMAL' | 'DATETIME';
@IsString()
@IsOptional()
index_type?: 'INDEX' | 'UNIQUE' | 'FULLTEXT';
@IsBoolean()
@IsOptional()
is_required?: boolean;
}
export class CreateJsonSchemaDto {
@IsString()
@IsNotEmpty()
schemaCode!: string; // รหัส Schema (ต้องไม่ซ้ำ เช่น 'RFA_DWG_V1')
schemaCode!: string;
@IsString() // ✅ เพิ่ม Validation
@IsNotEmpty()
tableName!: string;
@IsInt()
@IsOptional()
version?: number; // เวอร์ชัน (Default: 1)
version?: number;
@IsObject()
@IsNotEmpty()
schemaDefinition!: Record<string, any>; // โครงสร้าง JSON Schema (Standard Format)
schemaDefinition!: Record<string, any>;
@IsObject()
@IsOptional()
uiSchema?: Record<string, any>;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => VirtualColumnConfigDto)
virtualColumns?: VirtualColumnConfigDto[];
@IsObject()
@IsOptional()
migrationScript?: Record<string, any>;
@IsBoolean()
@IsOptional()
isActive?: boolean; // สถานะการใช้งาน
isActive?: boolean;
}

View File

@@ -0,0 +1,19 @@
// File: src/modules/json-schema/dto/migrate-data.dto.ts
import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class MigrateDataDto {
@ApiProperty({ description: 'The schema code to migrate to (e.g., RFA_DWG)' })
@IsString()
@IsNotEmpty()
targetSchemaCode!: string;
@ApiProperty({
description: 'Target version. If omitted, migrates to latest.',
required: false,
})
@IsInt()
@IsOptional()
targetVersion?: number;
}

View File

@@ -1,24 +1,47 @@
// File: src/modules/json-schema/entities/json-schema.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export interface VirtualColumnConfig {
json_path: string;
column_name: string;
data_type: 'INT' | 'VARCHAR' | 'BOOLEAN' | 'DATE' | 'DECIMAL' | 'DATETIME';
index_type?: 'INDEX' | 'UNIQUE' | 'FULLTEXT';
is_required: boolean;
}
@Entity('json_schemas')
@Index(['schemaCode', 'version'], { unique: true })
export class JsonSchema {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'schema_code', unique: true, length: 100 })
schemaCode!: string; // เช่น 'RFA_DWG_V1'
@Column({ name: 'schema_code', length: 100 })
schemaCode!: string;
@Column({ default: 1 })
version!: number;
@Column({ name: 'table_name', length: 100, nullable: false }) // ✅ เพิ่ม: ระบุตารางเป้าหมาย
tableName!: string;
@Column({ name: 'schema_definition', type: 'json' })
schemaDefinition!: any; // เก็บ JSON Schema มาตรฐาน (Draft 7/2019-09)
schemaDefinition!: any;
@Column({ name: 'ui_schema', type: 'json', nullable: true })
uiSchema?: any;
@Column({ name: 'virtual_columns', type: 'json', nullable: true })
virtualColumns?: VirtualColumnConfig[];
@Column({ name: 'migration_script', type: 'json', nullable: true })
migrationScript?: any;
@Column({ name: 'is_active', default: true })
isActive!: boolean;

View File

@@ -0,0 +1,84 @@
// File: src/modules/json-schema/interfaces/ui-schema.interface.ts
export type WidgetType =
| 'text'
| 'textarea'
| 'number'
| 'select'
| 'radio'
| 'checkbox'
| 'date'
| 'datetime'
| 'file-upload'
| 'document-ref'; // Custom widget สำหรับอ้างอิงเอกสารอื่น
export type Operator =
| 'equals'
| 'notEquals'
| 'contains'
| 'greaterThan'
| 'lessThan'
| 'in';
export interface FieldCondition {
field: string;
operator: Operator;
value: any;
}
export interface FieldDependency {
condition: FieldCondition;
actions: {
visibility?: boolean; // true = show, false = hide
required?: boolean;
disabled?: boolean;
filterOptions?: Record<string, any>; // เช่น กรอง Dropdown ตามค่าที่เลือก
};
}
export interface UiSchemaField {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
widget?: WidgetType;
title: string;
description?: string;
placeholder?: string;
enum?: any[]; // กรณีเป็น static options
enumNames?: string[]; // label สำหรับ options
dataSource?: string; // กรณีดึง options จาก API (เช่น 'master-data/disciplines')
defaultValue?: any;
readOnly?: boolean;
hidden?: boolean;
// Validation & Rules
required?: boolean;
dependencies?: FieldDependency[];
// For Nested Structures
properties?: { [key: string]: UiSchemaField };
items?: UiSchemaField; // For arrays
// UI Grid Layout (Tailwind classes equivalent)
colSpan?: number; // 1-12
}
export interface LayoutGroup {
id: string;
title: string;
description?: string;
type: 'group' | 'section';
fields: string[]; // Field keys ที่จะอยู่ในกลุ่มนี้
}
export interface LayoutConfig {
type: 'stack' | 'grid' | 'tabs' | 'steps' | 'wizard';
groups: LayoutGroup[];
options?: Record<string, any>; // Config เพิ่มเติมเฉพาะ Layout type
}
export interface UiSchema {
layout: LayoutConfig;
fields: {
[key: string]: UiSchemaField;
};
}

View File

@@ -0,0 +1,34 @@
// File: src/modules/json-schema/interfaces/validation-result.interface.ts
export interface ValidationOptions {
/**
* ลบ field ที่ไม่ได้ระบุใน Schema ออกอัตโนมัติหรือไม่
* Default: true
*/
removeAdditional?: boolean;
/**
* แปลงชนิดข้อมูลอัตโนมัติถ้าเป็นไปได้ (เช่น "123" -> 123)
* Default: true
*/
coerceTypes?: boolean;
/**
* ใช้ค่า Default จาก Schema ถ้าข้อมูลไม่ถูกส่งมา
* Default: true
*/
useDefaults?: boolean;
}
export interface ValidationErrorDetail {
field: string;
message: string;
value?: any;
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationErrorDetail[];
sanitizedData: any;
}

View File

@@ -1,36 +1,172 @@
import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
// File: src/modules/json-schema/json-schema.controller.ts
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { JsonSchemaService } from './json-schema.service';
// ✅ FIX: Import DTO
import { SchemaMigrationService } from './services/schema-migration.service';
import { CreateJsonSchemaDto } from './dto/create-json-schema.dto';
// ✅ FIX: แก้ไข Path ของ Guards
import { MigrateDataDto } from './dto/migrate-data.dto';
import { SearchJsonSchemaDto } from './dto/search-json-schema.dto';
import { UpdateJsonSchemaDto } from './dto/update-json-schema.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { User } from '../user/entities/user.entity';
@ApiTags('JSON Schemas') // ✅ Add Swagger Tag
@ApiTags('JSON Schemas Management')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('json-schemas')
export class JsonSchemaController {
constructor(private readonly schemaService: JsonSchemaService) {}
constructor(
private readonly jsonSchemaService: JsonSchemaService,
private readonly migrationService: SchemaMigrationService,
) {}
// ----------------------------------------------------------------------
// Schema Management (CRUD)
// ----------------------------------------------------------------------
@Post()
@ApiOperation({ summary: 'Create or Update JSON Schema' })
@ApiOperation({
summary: 'Create a new schema or new version of existing schema',
})
@ApiResponse({
status: 201,
description: 'The schema has been successfully created.',
})
@RequirePermission('system.manage_all') // Admin Only
create(@Body() createDto: CreateJsonSchemaDto) {
return this.schemaService.createOrUpdate(
createDto.schemaCode,
createDto.schemaDefinition,
);
return this.jsonSchemaService.create(createDto);
}
@Post(':code/validate')
@ApiOperation({ summary: 'Test validation against a schema' })
@Get()
@ApiOperation({ summary: 'List all schemas with pagination and filtering' })
@RequirePermission('document.view') // Viewer+ can see schemas
findAll(@Query() searchDto: SearchJsonSchemaDto) {
return this.jsonSchemaService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get a specific schema version by ID' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.jsonSchemaService.findOne(id);
}
@Get('latest/:code')
@ApiOperation({
summary: 'Get the latest active version of a schema by code',
})
@ApiParam({ name: 'code', description: 'Schema Code (e.g., RFA_DWG)' })
@RequirePermission('document.view')
findLatest(@Param('code') code: string) {
return this.jsonSchemaService.findLatestByCode(code);
}
@Patch(':id')
@ApiOperation({
summary: 'Update a specific schema (Not recommended for active schemas)',
})
@RequirePermission('system.manage_all')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateJsonSchemaDto,
) {
return this.jsonSchemaService.update(id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a schema version (Hard Delete)' })
@RequirePermission('system.manage_all')
remove(@Param('id', ParseIntPipe) id: number) {
return this.jsonSchemaService.remove(id);
}
// ----------------------------------------------------------------------
// Validation & Security
// ----------------------------------------------------------------------
@Post('validate/:code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate data against the latest schema version' })
@ApiResponse({
status: 200,
description: 'Validation result including errors and sanitized data',
})
@RequirePermission('document.view')
async validate(@Param('code') code: string, @Body() data: any) {
const isValid = await this.schemaService.validate(code, data);
return { valid: isValid, message: 'Validation passed' };
// Note: Validation API นี้ใช้สำหรับ Test หรือ Pre-check เท่านั้น
// การ Save จริงจะเรียกผ่าน Service ภายใน
return this.jsonSchemaService.validateData(code, data);
}
@Post('read/:code')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Process read data (Decrypt & Filter) based on user roles',
})
@RequirePermission('document.view')
async processReadData(
@Param('code') code: string,
@Body() data: any,
@CurrentUser() user: User,
) {
// แปลง User Entity เป็น Security Context
// ใช้ as any เพื่อ bypass type checking ชั่วคราว เนื่องจาก roles มักจะถูก inject เข้ามาใน request.user
// โดย Strategy หรือ Guard แม้จะไม่มีใน Entity หลัก
const userWithRoles = user as any;
const userRoles = userWithRoles.roles
? userWithRoles.roles.map((r: any) => r.roleName || r) // รองรับทั้ง Object Role และ String Role
: [];
return this.jsonSchemaService.processReadData(code, data, { userRoles });
}
// ----------------------------------------------------------------------
// Data Migration
// ----------------------------------------------------------------------
@Post('migrate/:table/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Migrate specific entity data to target schema version',
})
@ApiParam({ name: 'table', description: 'Table Name (e.g. rfa_revisions)' })
@ApiParam({ name: 'id', description: 'Entity ID' })
@RequirePermission('system.manage_all') // Dangerous Op -> Admin Only
async migrateData(
@Param('table') tableName: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: MigrateDataDto,
) {
return this.migrationService.migrateData(
tableName,
id,
dto.targetSchemaCode,
dto.targetVersion,
);
}
}

View File

@@ -1,14 +1,37 @@
// File: src/modules/json-schema/json-schema.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JsonSchemaService } from './json-schema.service';
import { JsonSchemaController } from './json-schema.controller';
import { JsonSchema } from './entities/json-schema.entity';
import { UserModule } from '../user/user.module';
import { JsonSchemaController } from './json-schema.controller';
import { JsonSchemaService } from './json-schema.service';
import { JsonSecurityService } from './services/json-security.service';
import { SchemaMigrationService } from './services/schema-migration.service';
import { UiSchemaService } from './services/ui-schema.service';
import { VirtualColumnService } from './services/virtual-column.service';
// Fix TS2307: Correct path to CryptoService
import { CryptoService } from '../../common/services/crypto.service';
// Import Module อื่นๆ ที่จำเป็นสำหรับ Guard (ถ้า Guards อยู่ใน Common อาจจะไม่ต้อง import ที่นี่โดยตรง)
// import { UserModule } from '../user/user.module';
@Module({
imports: [TypeOrmModule.forFeature([JsonSchema]), UserModule],
imports: [
TypeOrmModule.forFeature([JsonSchema]),
ConfigModule,
// UserModule,
],
controllers: [JsonSchemaController],
providers: [JsonSchemaService],
exports: [JsonSchemaService],
providers: [
JsonSchemaService,
VirtualColumnService,
UiSchemaService,
SchemaMigrationService,
JsonSecurityService,
CryptoService,
],
exports: [JsonSchemaService, SchemaMigrationService, JsonSecurityService],
})
export class JsonSchemaModule {}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JsonSchemaService } from './json-schema.service';
describe('JsonSchemaService', () => {
let service: JsonSchemaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [JsonSchemaService],
}).compile();
service = module.get<JsonSchemaService>(JsonSchemaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,107 +1,172 @@
// File: src/modules/json-schema/json-schema.controller.ts
import {
Injectable,
OnModuleInit,
BadRequestException,
NotFoundException,
Logger,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { JsonSchema } from './entities/json-schema.entity'; // ลบ .js
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
@Injectable()
export class JsonSchemaService implements OnModuleInit {
private ajv: Ajv;
private validators = new Map<string, any>();
private readonly logger = new Logger(JsonSchemaService.name);
import { JsonSchemaService } from './json-schema.service';
import { SchemaMigrationService } from './services/schema-migration.service';
import { CreateJsonSchemaDto } from './dto/create-json-schema.dto';
import { MigrateDataDto } from './dto/migrate-data.dto';
import { SearchJsonSchemaDto } from './dto/search-json-schema.dto';
import { UpdateJsonSchemaDto } from './dto/update-json-schema.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { User } from '../user/entities/user.entity';
@ApiTags('JSON Schemas Management')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('json-schemas')
export class JsonSchemaController {
constructor(
@InjectRepository(JsonSchema)
private schemaRepo: Repository<JsonSchema>,
private readonly jsonSchemaService: JsonSchemaService,
private readonly migrationService: SchemaMigrationService,
) {}
// ----------------------------------------------------------------------
// Schema Management (CRUD)
// ----------------------------------------------------------------------
@Post()
@ApiOperation({
summary: 'Create a new schema or new version of existing schema',
})
@ApiResponse({
status: 201,
description: 'The schema has been successfully created.',
})
@RequirePermission('system.manage_all') // Admin Only
create(@Body() createDto: CreateJsonSchemaDto) {
return this.jsonSchemaService.create(createDto);
}
@Get()
@ApiOperation({ summary: 'List all schemas with pagination and filtering' })
@RequirePermission('document.view') // Viewer+ can see schemas
findAll(@Query() searchDto: SearchJsonSchemaDto) {
return this.jsonSchemaService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get a specific schema version by ID' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.jsonSchemaService.findOne(id);
}
@Get('latest/:code')
@ApiOperation({
summary: 'Get the latest active version of a schema by code',
})
@ApiParam({ name: 'code', description: 'Schema Code (e.g., RFA_DWG)' })
@RequirePermission('document.view')
findLatest(@Param('code') code: string) {
return this.jsonSchemaService.findLatestByCode(code);
}
@Patch(':id')
@ApiOperation({
summary: 'Update a specific schema (Not recommended for active schemas)',
})
@RequirePermission('system.manage_all')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateJsonSchemaDto,
) {
this.ajv = new Ajv({ allErrors: true, strict: false });
addFormats(this.ajv);
return this.jsonSchemaService.update(id, updateDto);
}
async onModuleInit() {
// Pre-load schemas (Optional for performance)
// const schemas = await this.schemaRepo.find({ where: { isActive: true } });
// schemas.forEach(s => this.createValidator(s.schemaCode, s.schemaDefinition));
@Delete(':id')
@ApiOperation({ summary: 'Delete a schema version (Hard Delete)' })
@RequirePermission('system.manage_all')
remove(@Param('id', ParseIntPipe) id: number) {
return this.jsonSchemaService.remove(id);
}
/**
* ตรวจสอบข้อมูล JSON ว่าถูกต้องตาม Schema หรือไม่
*/
async validate(schemaCode: string, data: any): Promise<boolean> {
let validate = this.validators.get(schemaCode);
// ----------------------------------------------------------------------
// Validation & Security
// ----------------------------------------------------------------------
if (!validate) {
const schema = await this.schemaRepo.findOne({
where: { schemaCode, isActive: true },
});
if (!schema) {
throw new NotFoundException(`JSON Schema '${schemaCode}' not found`);
}
try {
validate = this.ajv.compile(schema.schemaDefinition);
this.validators.set(schemaCode, validate);
} catch (error: any) {
throw new BadRequestException(
`Invalid Schema Definition for '${schemaCode}': ${error.message}`,
);
}
}
const valid = validate(data);
if (!valid) {
const errors = validate.errors
?.map((e: any) => `${e.instancePath} ${e.message}`)
.join(', ');
// โยน Error กลับไปเพื่อให้ Controller/Service ปลายทางจัดการ
throw new BadRequestException(`JSON Validation Failed: ${errors}`);
}
return true;
@Post('validate/:code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate data against the latest schema version' })
@ApiResponse({
status: 200,
description: 'Validation result including errors and sanitized data',
})
@RequirePermission('document.view')
async validate(@Param('code') code: string, @Body() data: any) {
// Note: Validation API นี้ใช้สำหรับ Test หรือ Pre-check เท่านั้น
// การ Save จริงจะเรียกผ่าน Service ภายใน
return this.jsonSchemaService.validateData(code, data);
}
/**
* สร้างหรืออัปเดต Schema
*/
async createOrUpdate(schemaCode: string, definition: Record<string, any>) {
// 1. ตรวจสอบว่า Definition เป็น JSON Schema ที่ถูกต้องไหม
try {
this.ajv.compile(definition);
} catch (error: any) {
throw new BadRequestException(
`Invalid JSON Schema format: ${error.message}`,
);
}
@Post('read/:code')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Process read data (Decrypt & Filter) based on user roles',
})
@RequirePermission('document.view')
async processReadData(
@Param('code') code: string,
@Body() data: any,
@CurrentUser() user: User,
) {
// แปลง User Entity เป็น Security Context
// แก้ไข TS2339 & TS7006: Type Casting เพื่อให้เข้าถึง roles ได้โดยไม่ error
// เนื่องจาก User Entity ปกติไม่มี property roles (แต่อาจถูก Inject มาตอน Runtime หรือผ่าน Assignments)
const userWithRoles = user as any;
const userRoles = userWithRoles.roles
? userWithRoles.roles.map((r: any) => r.roleName)
: [];
// 2. บันทึกลง DB
let schema = await this.schemaRepo.findOne({ where: { schemaCode } });
return this.jsonSchemaService.processReadData(code, data, { userRoles });
}
if (schema) {
schema.schemaDefinition = definition;
schema.version += 1;
} else {
schema = this.schemaRepo.create({
schemaCode,
schemaDefinition: definition,
version: 1,
});
}
// ----------------------------------------------------------------------
// Data Migration
// ----------------------------------------------------------------------
const savedSchema = await this.schemaRepo.save(schema);
// 3. Clear Cache เพื่อให้ครั้งหน้าโหลดตัวใหม่
this.validators.delete(schemaCode);
this.logger.log(`Schema '${schemaCode}' updated (v${savedSchema.version})`);
return savedSchema;
@Post('migrate/:table/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Migrate specific entity data to target schema version',
})
@ApiParam({ name: 'table', description: 'Table Name (e.g. rfa_revisions)' })
@ApiParam({ name: 'id', description: 'Entity ID' })
@RequirePermission('system.manage_all') // Dangerous Op -> Admin Only
async migrateData(
@Param('table') tableName: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: MigrateDataDto,
) {
return this.migrationService.migrateData(
tableName,
id,
dto.targetSchemaCode,
dto.targetVersion,
);
}
}

View File

@@ -0,0 +1,113 @@
// File: src/modules/json-schema/services/json-security.service.ts
import { Injectable } from '@nestjs/common';
import { CryptoService } from '../../../common/services/crypto.service';
export interface SecurityContext {
userRoles: string[]; // Role ของ user ปัจจุบัน (เช่น ['EDITOR', 'viewer'])
}
@Injectable()
export class JsonSecurityService {
constructor(private readonly cryptoService: CryptoService) {}
/**
* ขาเข้า (Write): เข้ารหัสข้อมูล Sensitive ก่อนบันทึก
*/
encryptFields(data: any, schemaDefinition: any): any {
if (!data || typeof data !== 'object') return data;
const processed = Array.isArray(data) ? [...data] : { ...data };
// Traverse schema properties
if (schemaDefinition.properties) {
for (const [key, propSchema] of Object.entries<any>(
schemaDefinition.properties,
)) {
if (data[key] !== undefined) {
// 1. Check encryption flag
if (propSchema['x-encrypt'] === true) {
processed[key] = this.cryptoService.encrypt(data[key]);
}
// 2. Recursive for nested objects/arrays
if (propSchema.type === 'object' && propSchema.properties) {
processed[key] = this.encryptFields(data[key], propSchema);
} else if (propSchema.type === 'array' && propSchema.items) {
if (Array.isArray(data[key])) {
processed[key] = data[key].map((item: any) =>
this.encryptFields(item, propSchema.items),
);
}
}
}
}
}
return processed;
}
/**
* ขาออก (Read): ถอดรหัส และ กรองข้อมูลตามสิทธิ์
*/
decryptAndFilterFields(
data: any,
schemaDefinition: any,
context: SecurityContext,
): any {
if (!data || typeof data !== 'object') return data;
// Clone data to avoid mutation
const processed = Array.isArray(data) ? [...data] : { ...data };
if (schemaDefinition.properties) {
for (const [key, propSchema] of Object.entries<any>(
schemaDefinition.properties,
)) {
if (data[key] !== undefined) {
// 1. Decrypt (ถ้ามีค่าและถูกเข้ารหัสไว้)
if (propSchema['x-encrypt'] === true) {
processed[key] = this.cryptoService.decrypt(data[key]);
}
// 2. Security Check (Role-based Access Control)
if (propSchema['x-security']) {
const rule = propSchema['x-security'];
const requiredRoles = rule.roles || [];
const hasPermission = requiredRoles.some(
(role: string) =>
context.userRoles.includes(role) ||
context.userRoles.includes('SUPERADMIN'),
);
if (!hasPermission) {
if (rule.onDeny === 'REMOVE') {
delete processed[key];
continue; // ข้ามไป field ถัดไป
} else {
// Default: MASK
processed[key] = '********';
}
}
}
// 3. Recursive for nested objects/arrays
// (ทำต่อเมื่อ Field ยังไม่ถูกลบ)
if (processed[key] !== undefined) {
if (propSchema.type === 'object' && propSchema.properties) {
processed[key] = this.decryptAndFilterFields(
processed[key],
propSchema,
context,
);
} else if (propSchema.type === 'array' && propSchema.items) {
if (Array.isArray(processed[key])) {
processed[key] = processed[key].map((item: any) =>
this.decryptAndFilterFields(item, propSchema.items, context),
);
}
}
}
}
}
}
return processed;
}
}

View File

@@ -0,0 +1,205 @@
// File: src/modules/json-schema/services/schema-migration.service.ts
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { JsonSchemaService } from '../json-schema.service';
export interface MigrationStep {
type:
| 'FIELD_RENAME'
| 'FIELD_TRANSFORM'
| 'FIELD_ADD'
| 'FIELD_REMOVE'
| 'STRUCTURE_CHANGE';
config: any;
}
export interface MigrationResult {
success: boolean;
fromVersion: number;
toVersion: number;
migratedFields: string[];
error?: string;
}
@Injectable()
export class SchemaMigrationService {
private readonly logger = new Logger(SchemaMigrationService.name);
constructor(
private readonly dataSource: DataSource,
private readonly jsonSchemaService: JsonSchemaService,
) {}
/**
* Migrate data for a specific entity to a target schema version
*/
async migrateData(
entityType: string, // e.g., 'rfa_revisions', 'correspondence_revisions'
entityId: number,
targetSchemaCode: string,
targetVersion?: number,
): Promise<MigrationResult> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Get Target Schema
let targetSchema;
if (targetVersion) {
targetSchema = await this.jsonSchemaService.findOneByCodeAndVersion(
targetSchemaCode,
targetVersion,
);
} else {
targetSchema =
await this.jsonSchemaService.findLatestByCode(targetSchemaCode);
}
// 2. Fetch Entity Data & Current Version
// Note: This assumes the entity table has 'details' (json) and 'schema_version' (int) columns
// If schema_version is not present, we assume version 1
const entity = await queryRunner.manager.query(
`SELECT details, schema_version FROM ${entityType} WHERE id = ?`,
[entityId],
);
if (!entity || entity.length === 0) {
throw new BadRequestException(
`Entity ${entityType} with ID ${entityId} not found.`,
);
}
const currentData = entity[0].details || {};
const currentVersion = entity[0].schema_version || 1;
if (currentVersion >= targetSchema.version) {
return {
success: true,
fromVersion: currentVersion,
toVersion: currentVersion,
migratedFields: [], // No migration needed
};
}
// 3. Find Migration Path (Iterative Upgrade)
let migratedData = JSON.parse(JSON.stringify(currentData));
const migratedFields: string[] = [];
// Loop from current version up to target version
for (let v = currentVersion + 1; v <= targetSchema.version; v++) {
const schemaVer = await this.jsonSchemaService.findOneByCodeAndVersion(
targetSchemaCode,
v,
);
if (schemaVer && schemaVer.migrationScript) {
this.logger.log(
`Applying migration script for ${targetSchemaCode} v${v}...`,
);
const script = schemaVer.migrationScript;
// Apply steps defined in migrationScript
if (Array.isArray(script.steps)) {
for (const step of script.steps) {
migratedData = await this.applyMigrationStep(step, migratedData);
if (step.config.field || step.config.new_field) {
migratedFields.push(step.config.new_field || step.config.field);
}
}
}
}
}
// 4. Validate Migrated Data against Target Schema
const validation = await this.jsonSchemaService.validateData(
targetSchema.schemaCode,
migratedData,
);
if (!validation.isValid) {
throw new BadRequestException(
`Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}`,
);
}
// 5. Save Migrated Data
// Update details AND schema_version
await queryRunner.manager.query(
`UPDATE ${entityType} SET details = ?, schema_version = ? WHERE id = ?`,
[
JSON.stringify(validation.sanitizedData),
targetSchema.version,
entityId,
],
);
await queryRunner.commitTransaction();
return {
success: true,
fromVersion: currentVersion,
toVersion: targetSchema.version,
migratedFields: [...new Set(migratedFields)],
};
} catch (err: any) {
await queryRunner.rollbackTransaction();
this.logger.error(`Migration failed: ${err.message}`, err.stack);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* Apply a single migration step
*/
private async applyMigrationStep(
step: MigrationStep,
data: any,
): Promise<any> {
const newData = { ...data };
switch (step.type) {
case 'FIELD_RENAME':
if (newData[step.config.old_field] !== undefined) {
newData[step.config.new_field] = newData[step.config.old_field];
delete newData[step.config.old_field];
}
break;
case 'FIELD_ADD':
if (newData[step.config.field] === undefined) {
newData[step.config.field] = step.config.default_value;
}
break;
case 'FIELD_REMOVE':
delete newData[step.config.field];
break;
case 'FIELD_TRANSFORM':
if (newData[step.config.field] !== undefined) {
// Simple transform logic (e.g., map values)
if (step.config.transform === 'MAP_VALUES' && step.config.mapping) {
const oldVal = newData[step.config.field];
newData[step.config.field] = step.config.mapping[oldVal] || oldVal;
}
// Type casting
else if (step.config.transform === 'TO_NUMBER') {
newData[step.config.field] = Number(newData[step.config.field]);
} else if (step.config.transform === 'TO_STRING') {
newData[step.config.field] = String(newData[step.config.field]);
}
}
break;
default:
this.logger.warn(`Unknown migration step type: ${step.type}`);
}
return newData;
}
}

View File

@@ -0,0 +1,115 @@
// File: src/modules/json-schema/services/ui-schema.service.ts
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { UiSchema, UiSchemaField } from '../interfaces/ui-schema.interface';
@Injectable()
export class UiSchemaService {
private readonly logger = new Logger(UiSchemaService.name);
/**
* ตรวจสอบความถูกต้องของ UI Schema
*/
validateUiSchema(uiSchema: UiSchema, dataSchema: any): boolean {
if (!uiSchema) return true; // Optional field
// 1. Validate Structure เบื้องต้น
if (!uiSchema.layout || !uiSchema.fields) {
throw new BadRequestException(
'UI Schema must contain "layout" and "fields" properties.',
);
}
// 2. ตรวจสอบว่า Fields ใน Layout มีคำนิยามครบถ้วน
const definedFields = new Set(Object.keys(uiSchema.fields));
const layoutFields = new Set<string>();
uiSchema.layout.groups.forEach((group) => {
group.fields.forEach((fieldKey) => {
layoutFields.add(fieldKey);
if (!definedFields.has(fieldKey)) {
throw new BadRequestException(
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`,
);
}
});
});
// 3. (Optional) ตรวจสอบว่า Fields ใน Data Schema (AJV) มีครบใน UI Schema หรือไม่
// ถ้า Data Schema บอกว่ามี field 'title' แต่ UI Schema ไม่มี -> Frontend อาจจะไม่เรนเดอร์
if (dataSchema && dataSchema.properties) {
const dataKeys = Object.keys(dataSchema.properties);
const missingFields = dataKeys.filter((key) => !definedFields.has(key));
if (missingFields.length > 0) {
this.logger.warn(
`Data schema properties [${missingFields.join(', ')}] are missing from UI Schema.`,
);
// ไม่ Throw Error เพราะบางทีเราอาจตั้งใจซ่อน Field (Hidden field)
}
}
return true;
}
/**
* สร้าง UI Schema พื้นฐานจาก Data Schema (AJV) อัตโนมัติ
* ใช้กรณี user ไม่ได้ส่ง UI Schema มาให้
*/
generateDefaultUiSchema(dataSchema: any): UiSchema {
if (!dataSchema || !dataSchema.properties) {
return {
layout: { type: 'stack', groups: [] },
fields: {},
};
}
const fields: { [key: string]: UiSchemaField } = {};
const groupFields: string[] = [];
for (const [key, value] of Object.entries<any>(dataSchema.properties)) {
groupFields.push(key);
fields[key] = {
type: value.type || 'string',
title: value.title || this.humanize(key),
description: value.description,
required: (dataSchema.required || []).includes(key),
widget: this.guessWidget(value),
colSpan: 12, // Default full width
};
}
return {
layout: {
type: 'stack',
groups: [
{
id: 'default',
title: 'General Information',
type: 'section',
fields: groupFields,
},
],
},
fields,
};
}
// Helpers
private humanize(str: string): string {
return str
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
private guessWidget(schemaProp: any): any {
if (schemaProp.enum) return 'select';
if (schemaProp.type === 'boolean') return 'checkbox';
if (schemaProp.format === 'date') return 'date';
if (schemaProp.format === 'date-time') return 'datetime';
if (schemaProp.format === 'binary') return 'file-upload';
return 'text';
}
}

View File

@@ -0,0 +1,152 @@
// File: src/modules/json-schema/services/virtual-column.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, QueryRunner } from 'typeorm';
import { VirtualColumnConfig } from '../entities/json-schema.entity';
@Injectable()
export class VirtualColumnService {
private readonly logger = new Logger(VirtualColumnService.name);
constructor(private readonly dataSource: DataSource) {}
/**
* สร้าง/อัปเดต Virtual Columns และ Index บน Database จริง
*/
async setupVirtualColumns(tableName: string, configs: VirtualColumnConfig[]) {
if (!configs || configs.length === 0) return;
// ใช้ QueryRunner เพื่อให้จัดการ Transaction หรือ Connection ได้ละเอียด
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
this.logger.log(
`Start setting up virtual columns for table '${tableName}'...`,
);
// 1. ตรวจสอบว่าตารางมีอยู่จริงไหม
const tableExists = await queryRunner.hasTable(tableName);
if (!tableExists) {
this.logger.warn(
`Table '${tableName}' not found. Skipping virtual columns.`,
);
return;
}
for (const config of configs) {
await this.ensureVirtualColumn(queryRunner, tableName, config);
if (config.index_type) {
await this.ensureIndex(queryRunner, tableName, config);
}
}
this.logger.log(
`Finished setting up virtual columns for '${tableName}'.`,
);
} catch (err: any) {
this.logger.error(
`Failed to setup virtual columns: ${err.message}`,
err.stack,
);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* สร้าง Column ถ้ายังไม่มี
*/
private async ensureVirtualColumn(
queryRunner: QueryRunner,
tableName: string,
config: VirtualColumnConfig,
) {
const hasColumn = await queryRunner.hasColumn(
tableName,
config.column_name,
);
if (!hasColumn) {
const sql = this.generateAddColumnSql(tableName, config);
this.logger.log(`Executing: ${sql}`);
await queryRunner.query(sql);
} else {
// TODO: (Advance) ถ้ามี Column แล้ว แต่ Definition เปลี่ยน อาจต้อง ALTER MODIFY
this.logger.debug(
`Column '${config.column_name}' already exists in '${tableName}'.`,
);
}
}
/**
* สร้าง Index ถ้ายังไม่มี
*/
private async ensureIndex(
queryRunner: QueryRunner,
tableName: string,
config: VirtualColumnConfig,
) {
const indexName = `idx_${tableName}_${config.column_name}`;
// ตรวจสอบว่า Index มีอยู่จริงไหม (Query จาก information_schema เพื่อความชัวร์)
const checkIndexSql = `
SELECT COUNT(1) as count
FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = ?
AND index_name = ?
`;
const result = await queryRunner.query(checkIndexSql, [
tableName,
indexName,
]);
if (result[0].count == 0) {
const sql = `CREATE ${config.index_type === 'UNIQUE' ? 'UNIQUE' : ''} INDEX ${indexName} ON ${tableName} (${config.column_name})`;
this.logger.log(`Creating Index: ${sql}`);
await queryRunner.query(sql);
}
}
/**
* Generate SQL สำหรับ MariaDB 10.11 Virtual Column
* Syntax: ADD COLUMN name type GENERATED ALWAYS AS (expr) VIRTUAL
*/
private generateAddColumnSql(
tableName: string,
config: VirtualColumnConfig,
): string {
const dbType = this.mapDataTypeToSql(config.data_type);
// JSON_UNQUOTE(JSON_EXTRACT(details, '$.path'))
// ใช้ 'details' เป็นชื่อ column JSON หลัก (ต้องตรงกับ Database Schema ที่ออกแบบไว้)
const expression = `JSON_UNQUOTE(JSON_EXTRACT(details, '${config.json_path}'))`;
// Handle Type Casting inside expression if needed,
// but usually MariaDB handles string return from JSON_EXTRACT.
// For INT/DATE, virtual column type definition enforces it.
return `ALTER TABLE ${tableName} ADD COLUMN ${config.column_name} ${dbType} GENERATED ALWAYS AS (${expression}) VIRTUAL`;
}
private mapDataTypeToSql(type: string): string {
switch (type) {
case 'INT':
return 'INT';
case 'VARCHAR':
return 'VARCHAR(255)';
case 'BOOLEAN':
return 'TINYINT(1)';
case 'DATE':
return 'DATE';
case 'DATETIME':
return 'DATETIME';
case 'DECIMAL':
return 'DECIMAL(10,2)';
default:
return 'VARCHAR(255)';
}
}
}