251128:1700 Backend to T3.1.1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
115
backend/src/modules/json-schema/services/ui-schema.service.ts
Normal file
115
backend/src/modules/json-schema/services/ui-schema.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user