UNPKG

@warriorteam/dynamic-table

Version:

NestJS SDK for Dynamic Table System with PostgreSQL + JSONB - Build Airtable/Notion-like applications easily

1 lines 86 kB
{"version":3,"sources":["../src/dynamic-table.module.ts","../src/entities/workspace.entity.ts","../src/entities/table.entity.ts","../src/entities/field.entity.ts","../src/entities/view.entity.ts","../src/entities/record.entity.ts","../src/entities/record-history.entity.ts","../src/entities/index.ts","../src/services/workspace.service.ts","../src/services/table.service.ts","../src/services/field.service.ts","../src/constants/field-types.constant.ts","../src/services/view.service.ts","../src/services/record.service.ts","../src/services/record-query.service.ts","../src/services/formula.service.ts","../src/services/validation.service.ts"],"sourcesContent":["import { Module, DynamicModule, Provider, Global } from '@nestjs/common';\r\nimport { TypeOrmModule } from '@nestjs/typeorm';\r\nimport {\r\n DynamicTableModuleOptions,\r\n DynamicTableModuleAsyncOptions,\r\n DynamicTableOptionsFactory,\r\n} from './interfaces';\r\nimport { DYNAMIC_TABLE_ENTITIES } from './entities';\r\nimport {\r\n WorkspaceService,\r\n TableService,\r\n FieldService,\r\n ViewService,\r\n RecordService,\r\n RecordQueryService,\r\n FormulaService,\r\n ValidationService,\r\n} from './services';\r\n\r\nexport const DYNAMIC_TABLE_OPTIONS = 'DYNAMIC_TABLE_OPTIONS';\r\n\r\nconst SERVICES = [\r\n WorkspaceService,\r\n TableService,\r\n FieldService,\r\n ViewService,\r\n RecordService,\r\n RecordQueryService,\r\n FormulaService,\r\n ValidationService,\r\n];\r\n\r\n@Global()\r\n@Module({})\r\nexport class DynamicTableModule {\r\n /**\r\n * Register module with synchronous options\r\n */\r\n static forRoot(options: DynamicTableModuleOptions = {}): DynamicModule {\r\n const optionsProvider: Provider = {\r\n provide: DYNAMIC_TABLE_OPTIONS,\r\n useValue: options,\r\n };\r\n\r\n return {\r\n module: DynamicTableModule,\r\n imports: [\r\n TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES),\r\n ],\r\n providers: [optionsProvider, ...SERVICES],\r\n exports: [optionsProvider, ...SERVICES],\r\n };\r\n }\r\n\r\n /**\r\n * Register module with asynchronous options\r\n */\r\n static forRootAsync(options: DynamicTableModuleAsyncOptions): DynamicModule {\r\n const asyncProviders = this.createAsyncProviders(options);\r\n\r\n return {\r\n module: DynamicTableModule,\r\n imports: [\r\n ...(options.imports || []),\r\n TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES),\r\n ],\r\n providers: [...asyncProviders, ...SERVICES],\r\n exports: [DYNAMIC_TABLE_OPTIONS, ...SERVICES],\r\n };\r\n }\r\n\r\n /**\r\n * Create async providers\r\n */\r\n private static createAsyncProviders(\r\n options: DynamicTableModuleAsyncOptions,\r\n ): Provider[] {\r\n if (options.useExisting || options.useFactory) {\r\n return [this.createAsyncOptionsProvider(options)];\r\n }\r\n\r\n if (options.useClass) {\r\n return [\r\n this.createAsyncOptionsProvider(options),\r\n {\r\n provide: options.useClass,\r\n useClass: options.useClass,\r\n },\r\n ];\r\n }\r\n\r\n return [this.createAsyncOptionsProvider(options)];\r\n }\r\n\r\n /**\r\n * Create async options provider\r\n */\r\n private static createAsyncOptionsProvider(\r\n options: DynamicTableModuleAsyncOptions,\r\n ): Provider {\r\n if (options.useFactory) {\r\n return {\r\n provide: DYNAMIC_TABLE_OPTIONS,\r\n useFactory: options.useFactory,\r\n inject: options.inject || [],\r\n };\r\n }\r\n\r\n const inject = options.useExisting || options.useClass;\r\n\r\n return {\r\n provide: DYNAMIC_TABLE_OPTIONS,\r\n useFactory: async (optionsFactory: DynamicTableOptionsFactory) =>\r\n optionsFactory.createDynamicTableOptions(),\r\n inject: inject ? [inject] : [],\r\n };\r\n }\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n CreateDateColumn,\r\n OneToMany,\r\n} from 'typeorm';\r\nimport { SysTable } from './table.entity';\r\n\r\n@Entity('sys_workspaces')\r\nexport class SysWorkspace {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ type: 'varchar', length: 255 })\r\n name: string;\r\n\r\n @Column({ type: 'varchar', length: 255, unique: true })\r\n slug: string;\r\n\r\n @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })\r\n createdAt: Date;\r\n\r\n @OneToMany(() => SysTable, (table) => table.workspace)\r\n tables: SysTable[];\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n CreateDateColumn,\r\n ManyToOne,\r\n OneToMany,\r\n JoinColumn,\r\n Unique,\r\n} from 'typeorm';\r\nimport { SysWorkspace } from './workspace.entity';\r\nimport { SysField } from './field.entity';\r\nimport { SysView } from './view.entity';\r\nimport { UsrRecord } from './record.entity';\r\n\r\n@Entity('sys_tables')\r\n@Unique(['workspaceId', 'slug'])\r\nexport class SysTable {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ name: 'workspace_id', type: 'uuid' })\r\n workspaceId: string;\r\n\r\n @Column({ type: 'varchar', length: 255 })\r\n name: string;\r\n\r\n @Column({ type: 'varchar', length: 255 })\r\n slug: string;\r\n\r\n @Column({ type: 'text', nullable: true })\r\n description: string;\r\n\r\n @Column({ type: 'jsonb', default: {} })\r\n settings: Record<string, any>;\r\n\r\n @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })\r\n createdAt: Date;\r\n\r\n @ManyToOne(() => SysWorkspace, (workspace) => workspace.tables, { onDelete: 'CASCADE' })\r\n @JoinColumn({ name: 'workspace_id' })\r\n workspace: SysWorkspace;\r\n\r\n @OneToMany(() => SysField, (field) => field.table)\r\n fields: SysField[];\r\n\r\n @OneToMany(() => SysView, (view) => view.table)\r\n views: SysView[];\r\n\r\n @OneToMany(() => UsrRecord, (record) => record.table)\r\n records: UsrRecord[];\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n CreateDateColumn,\r\n ManyToOne,\r\n JoinColumn,\r\n Unique,\r\n} from 'typeorm';\r\nimport { SysTable } from './table.entity';\r\nimport { FieldType } from '../constants';\r\nimport { FieldConfig } from '../interfaces';\r\n\r\n@Entity('sys_fields')\r\n@Unique(['tableId', 'keyName'])\r\nexport class SysField {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ name: 'table_id', type: 'uuid' })\r\n tableId: string;\r\n\r\n @Column({ type: 'varchar', length: 255 })\r\n name: string;\r\n\r\n @Column({ name: 'key_name', type: 'varchar', length: 255 })\r\n keyName: string;\r\n\r\n @Column({ type: 'varchar', length: 50 })\r\n type: FieldType;\r\n\r\n @Column({ type: 'jsonb', default: {} })\r\n config: FieldConfig;\r\n\r\n @Column({ name: 'is_primary', type: 'boolean', default: false })\r\n isPrimary: boolean;\r\n\r\n @Column({ name: 'is_required', type: 'boolean', default: false })\r\n isRequired: boolean;\r\n\r\n @Column({ name: 'order_index', type: 'integer', default: 0 })\r\n orderIndex: number;\r\n\r\n @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })\r\n createdAt: Date;\r\n\r\n @ManyToOne(() => SysTable, (table) => table.fields, { onDelete: 'CASCADE' })\r\n @JoinColumn({ name: 'table_id' })\r\n table: SysTable;\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n ManyToOne,\r\n JoinColumn,\r\n} from 'typeorm';\r\nimport { SysTable } from './table.entity';\r\nimport { ViewType, ViewConfig } from '../interfaces';\r\n\r\n@Entity('sys_views')\r\nexport class SysView {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ name: 'table_id', type: 'uuid' })\r\n tableId: string;\r\n\r\n @Column({ type: 'varchar', length: 255 })\r\n name: string;\r\n\r\n @Column({ type: 'varchar', length: 50, default: 'grid' })\r\n type: ViewType;\r\n\r\n @Column({ type: 'jsonb', default: {} })\r\n config: ViewConfig;\r\n\r\n @Column({ name: 'is_default', type: 'boolean', default: false })\r\n isDefault: boolean;\r\n\r\n @ManyToOne(() => SysTable, (table) => table.views, { onDelete: 'CASCADE' })\r\n @JoinColumn({ name: 'table_id' })\r\n table: SysTable;\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n CreateDateColumn,\r\n UpdateDateColumn,\r\n ManyToOne,\r\n OneToMany,\r\n JoinColumn,\r\n Index,\r\n} from 'typeorm';\r\nimport { SysTable } from './table.entity';\r\nimport { UsrRecordHistory } from './record-history.entity';\r\n\r\n@Entity('usr_records')\r\nexport class UsrRecord {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ name: 'table_id', type: 'uuid' })\r\n @Index()\r\n tableId: string;\r\n\r\n @Column({ type: 'jsonb', default: {} })\r\n @Index('idx_usr_records_data', { synchronize: false })\r\n data: Record<string, any>;\r\n\r\n @Column({ name: 'created_by', type: 'uuid', nullable: true })\r\n createdBy: string;\r\n\r\n @Column({ name: 'updated_by', type: 'uuid', nullable: true })\r\n updatedBy: string;\r\n\r\n @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })\r\n createdAt: Date;\r\n\r\n @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })\r\n updatedAt: Date;\r\n\r\n @ManyToOne(() => SysTable, (table) => table.records, { onDelete: 'CASCADE' })\r\n @JoinColumn({ name: 'table_id' })\r\n table: SysTable;\r\n\r\n @OneToMany(() => UsrRecordHistory, (history) => history.record)\r\n history: UsrRecordHistory[];\r\n}\r\n","import {\r\n Entity,\r\n PrimaryGeneratedColumn,\r\n Column,\r\n CreateDateColumn,\r\n ManyToOne,\r\n JoinColumn,\r\n} from 'typeorm';\r\nimport { UsrRecord } from './record.entity';\r\n\r\n@Entity('usr_record_history')\r\nexport class UsrRecordHistory {\r\n @PrimaryGeneratedColumn('uuid')\r\n id: string;\r\n\r\n @Column({ name: 'record_id', type: 'uuid' })\r\n recordId: string;\r\n\r\n @Column({ name: 'changed_by', type: 'uuid', nullable: true })\r\n changedBy: string;\r\n\r\n @CreateDateColumn({ name: 'changed_at', type: 'timestamp with time zone' })\r\n changedAt: Date;\r\n\r\n @Column({ type: 'jsonb' })\r\n changes: Record<string, { old: any; new: any }>;\r\n\r\n @ManyToOne(() => UsrRecord, (record) => record.history, { onDelete: 'CASCADE' })\r\n @JoinColumn({ name: 'record_id' })\r\n record: UsrRecord;\r\n}\r\n","export * from './workspace.entity';\r\nexport * from './table.entity';\r\nexport * from './field.entity';\r\nexport * from './view.entity';\r\nexport * from './record.entity';\r\nexport * from './record-history.entity';\r\n\r\nimport { SysWorkspace } from './workspace.entity';\r\nimport { SysTable } from './table.entity';\r\nimport { SysField } from './field.entity';\r\nimport { SysView } from './view.entity';\r\nimport { UsrRecord } from './record.entity';\r\nimport { UsrRecordHistory } from './record-history.entity';\r\n\r\n/**\r\n * All entities for TypeORM registration\r\n */\r\nexport const DYNAMIC_TABLE_ENTITIES = [\r\n SysWorkspace,\r\n SysTable,\r\n SysField,\r\n SysView,\r\n UsrRecord,\r\n UsrRecordHistory,\r\n];\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository } from 'typeorm';\r\nimport { SysWorkspace } from '../entities';\r\nimport { CreateWorkspaceDto, UpdateWorkspaceDto } from '../interfaces';\r\n\r\n@Injectable()\r\nexport class WorkspaceService {\r\n constructor(\r\n @InjectRepository(SysWorkspace)\r\n private readonly workspaceRepo: Repository<SysWorkspace>,\r\n ) {}\r\n\r\n /**\r\n * Create a new workspace\r\n */\r\n async create(dto: CreateWorkspaceDto): Promise<SysWorkspace> {\r\n const workspace = this.workspaceRepo.create(dto);\r\n return this.workspaceRepo.save(workspace);\r\n }\r\n\r\n /**\r\n * Find all workspaces\r\n */\r\n async findAll(): Promise<SysWorkspace[]> {\r\n return this.workspaceRepo.find({\r\n order: { createdAt: 'DESC' },\r\n });\r\n }\r\n\r\n /**\r\n * Find workspace by ID\r\n */\r\n async findById(id: string): Promise<SysWorkspace | null> {\r\n return this.workspaceRepo.findOne({ where: { id } });\r\n }\r\n\r\n /**\r\n * Find workspace by slug\r\n */\r\n async findBySlug(slug: string): Promise<SysWorkspace | null> {\r\n return this.workspaceRepo.findOne({ where: { slug } });\r\n }\r\n\r\n /**\r\n * Update workspace\r\n */\r\n async update(id: string, dto: UpdateWorkspaceDto): Promise<SysWorkspace | null> {\r\n await this.workspaceRepo.update(id, dto);\r\n return this.findById(id);\r\n }\r\n\r\n /**\r\n * Delete workspace\r\n */\r\n async delete(id: string): Promise<boolean> {\r\n const result = await this.workspaceRepo.delete(id);\r\n return (result.affected ?? 0) > 0;\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository } from 'typeorm';\r\nimport { SysTable } from '../entities';\r\nimport { CreateTableDto, UpdateTableDto } from '../interfaces';\r\n\r\n@Injectable()\r\nexport class TableService {\r\n constructor(\r\n @InjectRepository(SysTable)\r\n private readonly tableRepo: Repository<SysTable>,\r\n ) {}\r\n\r\n /**\r\n * Create a new table\r\n */\r\n async create(dto: CreateTableDto): Promise<SysTable> {\r\n const table = this.tableRepo.create(dto);\r\n return this.tableRepo.save(table);\r\n }\r\n\r\n /**\r\n * Find all tables in a workspace\r\n */\r\n async findByWorkspace(workspaceId: string): Promise<SysTable[]> {\r\n return this.tableRepo.find({\r\n where: { workspaceId },\r\n order: { createdAt: 'DESC' },\r\n });\r\n }\r\n\r\n /**\r\n * Find table by ID\r\n */\r\n async findById(id: string): Promise<SysTable | null> {\r\n return this.tableRepo.findOne({\r\n where: { id },\r\n relations: ['fields'],\r\n });\r\n }\r\n\r\n /**\r\n * Find table by slug within a workspace\r\n */\r\n async findBySlug(workspaceId: string, slug: string): Promise<SysTable | null> {\r\n return this.tableRepo.findOne({\r\n where: { workspaceId, slug },\r\n relations: ['fields'],\r\n });\r\n }\r\n\r\n /**\r\n * Find table with all relations\r\n */\r\n async findByIdWithRelations(id: string): Promise<SysTable | null> {\r\n return this.tableRepo.findOne({\r\n where: { id },\r\n relations: ['fields', 'views'],\r\n });\r\n }\r\n\r\n /**\r\n * Update table\r\n */\r\n async update(id: string, dto: UpdateTableDto): Promise<SysTable | null> {\r\n await this.tableRepo.update(id, dto);\r\n return this.findById(id);\r\n }\r\n\r\n /**\r\n * Delete table\r\n */\r\n async delete(id: string): Promise<boolean> {\r\n const result = await this.tableRepo.delete(id);\r\n return (result.affected ?? 0) > 0;\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository } from 'typeorm';\r\nimport { SysField } from '../entities';\r\nimport { CreateFieldDto, UpdateFieldDto, TableField } from '../interfaces';\r\nimport { FIELD_TYPE_METADATA } from '../constants';\r\n\r\n@Injectable()\r\nexport class FieldService {\r\n constructor(\r\n @InjectRepository(SysField)\r\n private readonly fieldRepo: Repository<SysField>,\r\n ) {}\r\n\r\n /**\r\n * Create a new field\r\n */\r\n async create(dto: CreateFieldDto): Promise<SysField> {\r\n // Apply default config based on field type\r\n const defaultConfig = FIELD_TYPE_METADATA[dto.type]?.defaultConfig || {};\r\n const field = this.fieldRepo.create({\r\n ...dto,\r\n config: { ...defaultConfig, ...dto.config },\r\n });\r\n return this.fieldRepo.save(field);\r\n }\r\n\r\n /**\r\n * Find all fields of a table\r\n */\r\n async findByTable(tableId: string): Promise<SysField[]> {\r\n return this.fieldRepo.find({\r\n where: { tableId },\r\n order: { orderIndex: 'ASC' },\r\n });\r\n }\r\n\r\n /**\r\n * Find field by ID\r\n */\r\n async findById(id: string): Promise<SysField | null> {\r\n return this.fieldRepo.findOne({ where: { id } });\r\n }\r\n\r\n /**\r\n * Find field by keyName within a table\r\n */\r\n async findByKeyName(tableId: string, keyName: string): Promise<SysField | null> {\r\n return this.fieldRepo.findOne({ where: { tableId, keyName } });\r\n }\r\n\r\n /**\r\n * Update field\r\n */\r\n async update(id: string, dto: UpdateFieldDto): Promise<SysField | null> {\r\n const existing = await this.findById(id);\r\n if (!existing) return null;\r\n\r\n // Merge config if provided\r\n const updateData: Partial<SysField> = { ...dto };\r\n if (dto.config) {\r\n updateData.config = { ...existing.config, ...dto.config };\r\n }\r\n\r\n await this.fieldRepo.update(id, updateData);\r\n return this.findById(id);\r\n }\r\n\r\n /**\r\n * Delete field\r\n */\r\n async delete(id: string): Promise<boolean> {\r\n const result = await this.fieldRepo.delete(id);\r\n return (result.affected ?? 0) > 0;\r\n }\r\n\r\n /**\r\n * Reorder fields\r\n */\r\n async reorder(tableId: string, fieldIds: string[]): Promise<void> {\r\n const updates = fieldIds.map((id, index) =>\r\n this.fieldRepo.update(id, { orderIndex: index }),\r\n );\r\n await Promise.all(updates);\r\n }\r\n\r\n /**\r\n * Convert entity to interface\r\n */\r\n toInterface(entity: SysField): TableField {\r\n return {\r\n id: entity.id,\r\n tableId: entity.tableId,\r\n name: entity.name,\r\n keyName: entity.keyName,\r\n type: entity.type,\r\n config: entity.config,\r\n isPrimary: entity.isPrimary,\r\n isRequired: entity.isRequired,\r\n orderIndex: entity.orderIndex,\r\n createdAt: entity.createdAt,\r\n };\r\n }\r\n}\r\n","/**\r\n * Enum định nghĩa các loại trường dữ liệu\r\n */\r\nexport enum FieldType {\r\n // === Loại văn bản ===\r\n TEXT = 'text',\r\n LONG_TEXT = 'long_text',\r\n EMAIL = 'email',\r\n PHONE = 'phone',\r\n URL = 'url',\r\n\r\n // === Loại số ===\r\n NUMBER = 'number',\r\n CURRENCY = 'currency',\r\n PERCENT = 'percent',\r\n RATING = 'rating',\r\n AUTONUMBER = 'autonumber',\r\n\r\n // === Loại lựa chọn ===\r\n SELECT = 'select',\r\n MULTI_SELECT = 'multi_select',\r\n BOOLEAN = 'boolean',\r\n\r\n // === Loại ngày giờ ===\r\n DATE = 'date',\r\n DATETIME = 'datetime',\r\n DURATION = 'duration',\r\n\r\n // === Loại liên kết ===\r\n RELATION = 'relation',\r\n LOOKUP = 'lookup',\r\n ROLLUP = 'rollup',\r\n\r\n // === Loại tự động tính toán ===\r\n FORMULA = 'formula',\r\n CREATED_TIME = 'created_time',\r\n MODIFIED_TIME = 'modified_time',\r\n CREATED_BY = 'created_by',\r\n MODIFIED_BY = 'modified_by',\r\n\r\n // === Loại media ===\r\n ATTACHMENT = 'attachment',\r\n\r\n // === Loại người dùng ===\r\n USER = 'user',\r\n}\r\n\r\n/**\r\n * Danh sách các loại trường tự động tính toán (chỉ đọc)\r\n */\r\nexport const COMPUTED_FIELD_TYPES: FieldType[] = [\r\n FieldType.FORMULA,\r\n FieldType.LOOKUP,\r\n FieldType.ROLLUP,\r\n FieldType.CREATED_TIME,\r\n FieldType.MODIFIED_TIME,\r\n FieldType.CREATED_BY,\r\n FieldType.MODIFIED_BY,\r\n FieldType.AUTONUMBER,\r\n];\r\n\r\n/**\r\n * Các toán tử lọc dữ liệu\r\n */\r\nexport type FilterOperator =\r\n // === So sánh ===\r\n | 'eq'\r\n | 'neq'\r\n | 'gt'\r\n | 'gte'\r\n | 'lt'\r\n | 'lte'\r\n // === Văn bản ===\r\n | 'contains'\r\n | 'not_contains'\r\n | 'starts_with'\r\n | 'ends_with'\r\n // === Mảng/Tập hợp ===\r\n | 'in'\r\n | 'not_in'\r\n // === Kiểm tra null ===\r\n | 'is_empty'\r\n | 'is_not_empty'\r\n // === Ngày tháng ===\r\n | 'is_before'\r\n | 'is_after'\r\n | 'is_on_or_before'\r\n | 'is_on_or_after'\r\n | 'is_within';\r\n\r\n/**\r\n * Rollup functions\r\n */\r\nexport type RollupFunction = 'sum' | 'avg' | 'min' | 'max' | 'count' | 'counta' | 'countall';\r\n\r\n/**\r\n * Metadata cho từng loại trường (dùng cho UI)\r\n */\r\nexport interface FieldTypeMetadata {\r\n label: string;\r\n icon: string;\r\n isComputed: boolean;\r\n defaultConfig?: Record<string, any>;\r\n}\r\n\r\nexport const FIELD_TYPE_METADATA: Record<FieldType, FieldTypeMetadata> = {\r\n [FieldType.TEXT]: { label: 'Văn bản', icon: 'type', isComputed: false },\r\n [FieldType.LONG_TEXT]: {\r\n label: 'Văn bản dài',\r\n icon: 'align-left',\r\n isComputed: false,\r\n defaultConfig: { enableRichText: false },\r\n },\r\n [FieldType.EMAIL]: { label: 'Email', icon: 'mail', isComputed: false },\r\n [FieldType.PHONE]: {\r\n label: 'Số điện thoại',\r\n icon: 'phone',\r\n isComputed: false,\r\n defaultConfig: { defaultCountryCode: '+84' },\r\n },\r\n [FieldType.URL]: {\r\n label: 'Đường dẫn',\r\n icon: 'link',\r\n isComputed: false,\r\n defaultConfig: { urlType: 'any' },\r\n },\r\n [FieldType.NUMBER]: {\r\n label: 'Số',\r\n icon: 'hash',\r\n isComputed: false,\r\n defaultConfig: { precision: 0 },\r\n },\r\n [FieldType.CURRENCY]: {\r\n label: 'Tiền tệ',\r\n icon: 'dollar-sign',\r\n isComputed: false,\r\n defaultConfig: { currencyCode: 'VND', currencySymbol: '₫', precision: 0 },\r\n },\r\n [FieldType.PERCENT]: {\r\n label: 'Phần trăm',\r\n icon: 'percent',\r\n isComputed: false,\r\n defaultConfig: { percentFormat: 'whole', precision: 0 },\r\n },\r\n [FieldType.RATING]: {\r\n label: 'Đánh giá',\r\n icon: 'star',\r\n isComputed: false,\r\n defaultConfig: { maxRating: 5, ratingIcon: 'star' },\r\n },\r\n [FieldType.AUTONUMBER]: {\r\n label: 'Số tự động',\r\n icon: 'list-ordered',\r\n isComputed: true,\r\n defaultConfig: { startNumber: 1, digitCount: 5 },\r\n },\r\n [FieldType.SELECT]: {\r\n label: 'Chọn một',\r\n icon: 'chevron-down',\r\n isComputed: false,\r\n defaultConfig: { options: [] },\r\n },\r\n [FieldType.MULTI_SELECT]: {\r\n label: 'Chọn nhiều',\r\n icon: 'check-square',\r\n isComputed: false,\r\n defaultConfig: { options: [] },\r\n },\r\n [FieldType.BOOLEAN]: { label: 'Checkbox', icon: 'check', isComputed: false },\r\n [FieldType.DATE]: {\r\n label: 'Ngày',\r\n icon: 'calendar',\r\n isComputed: false,\r\n defaultConfig: { dateFormat: 'DD/MM/YYYY' },\r\n },\r\n [FieldType.DATETIME]: {\r\n label: 'Ngày & Giờ',\r\n icon: 'clock',\r\n isComputed: false,\r\n defaultConfig: { dateFormat: 'DD/MM/YYYY', timeFormat: '24h' },\r\n },\r\n [FieldType.DURATION]: {\r\n label: 'Khoảng thời gian',\r\n icon: 'timer',\r\n isComputed: false,\r\n defaultConfig: { durationFormat: 'h:mm' },\r\n },\r\n [FieldType.RELATION]: {\r\n label: 'Liên kết bản ghi',\r\n icon: 'link-2',\r\n isComputed: false,\r\n defaultConfig: { allowMultiple: false },\r\n },\r\n [FieldType.LOOKUP]: { label: 'Tra cứu', icon: 'search', isComputed: true },\r\n [FieldType.ROLLUP]: {\r\n label: 'Tổng hợp',\r\n icon: 'sigma',\r\n isComputed: true,\r\n defaultConfig: { rollupFunction: 'count' },\r\n },\r\n [FieldType.FORMULA]: {\r\n label: 'Công thức',\r\n icon: 'function-square',\r\n isComputed: true,\r\n defaultConfig: { outputType: 'number' },\r\n },\r\n [FieldType.CREATED_TIME]: { label: 'Thời gian tạo', icon: 'clock', isComputed: true },\r\n [FieldType.MODIFIED_TIME]: { label: 'Thời gian sửa', icon: 'clock', isComputed: true },\r\n [FieldType.CREATED_BY]: { label: 'Người tạo', icon: 'user', isComputed: true },\r\n [FieldType.MODIFIED_BY]: { label: 'Người sửa', icon: 'user', isComputed: true },\r\n [FieldType.ATTACHMENT]: {\r\n label: 'File đính kèm',\r\n icon: 'paperclip',\r\n isComputed: false,\r\n defaultConfig: { maxFiles: 10 },\r\n },\r\n [FieldType.USER]: {\r\n label: 'Người dùng',\r\n icon: 'user',\r\n isComputed: false,\r\n defaultConfig: { allowMultipleUsers: false },\r\n },\r\n};\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository } from 'typeorm';\r\nimport { SysView } from '../entities';\r\nimport { CreateViewDto, UpdateViewDto } from '../interfaces';\r\n\r\n@Injectable()\r\nexport class ViewService {\r\n constructor(\r\n @InjectRepository(SysView)\r\n private readonly viewRepo: Repository<SysView>,\r\n ) {}\r\n\r\n /**\r\n * Create a new view\r\n */\r\n async create(dto: CreateViewDto): Promise<SysView> {\r\n // If this is the first view or marked as default, ensure only one default\r\n if (dto.isDefault) {\r\n await this.viewRepo.update({ tableId: dto.tableId }, { isDefault: false });\r\n }\r\n\r\n const view = this.viewRepo.create({\r\n ...dto,\r\n type: dto.type || 'grid',\r\n config: dto.config || {},\r\n });\r\n return this.viewRepo.save(view);\r\n }\r\n\r\n /**\r\n * Find all views of a table\r\n */\r\n async findByTable(tableId: string): Promise<SysView[]> {\r\n return this.viewRepo.find({\r\n where: { tableId },\r\n order: { isDefault: 'DESC', name: 'ASC' },\r\n });\r\n }\r\n\r\n /**\r\n * Find view by ID\r\n */\r\n async findById(id: string): Promise<SysView | null> {\r\n return this.viewRepo.findOne({ where: { id } });\r\n }\r\n\r\n /**\r\n * Find default view of a table\r\n */\r\n async findDefault(tableId: string): Promise<SysView | null> {\r\n return this.viewRepo.findOne({\r\n where: { tableId, isDefault: true },\r\n });\r\n }\r\n\r\n /**\r\n * Update view\r\n */\r\n async update(id: string, dto: UpdateViewDto): Promise<SysView | null> {\r\n const existing = await this.findById(id);\r\n if (!existing) return null;\r\n\r\n // If setting as default, unset other defaults\r\n if (dto.isDefault) {\r\n await this.viewRepo.update({ tableId: existing.tableId }, { isDefault: false });\r\n }\r\n\r\n // Merge config if provided\r\n const updateData: Partial<SysView> = { ...dto };\r\n if (dto.config) {\r\n updateData.config = { ...existing.config, ...dto.config };\r\n }\r\n\r\n await this.viewRepo.update(id, updateData);\r\n return this.findById(id);\r\n }\r\n\r\n /**\r\n * Delete view\r\n */\r\n async delete(id: string): Promise<boolean> {\r\n const result = await this.viewRepo.delete(id);\r\n return (result.affected ?? 0) > 0;\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository, DataSource } from 'typeorm';\r\nimport { UsrRecord, UsrRecordHistory, SysField } from '../entities';\r\nimport {\r\n CreateRecordDto,\r\n UpdateRecordDto,\r\n BatchCreateRecordDto,\r\n QueryOptions,\r\n PaginatedResult,\r\n TableField,\r\n} from '../interfaces';\r\nimport { RecordQueryService } from './record-query.service';\r\nimport { FieldService } from './field.service';\r\nimport { COMPUTED_FIELD_TYPES } from '../constants';\r\n\r\n@Injectable()\r\nexport class RecordService {\r\n constructor(\r\n @InjectRepository(UsrRecord)\r\n private readonly recordRepo: Repository<UsrRecord>,\r\n @InjectRepository(UsrRecordHistory)\r\n private readonly historyRepo: Repository<UsrRecordHistory>,\r\n private readonly dataSource: DataSource,\r\n private readonly recordQueryService: RecordQueryService,\r\n private readonly fieldService: FieldService,\r\n ) {}\r\n\r\n /**\r\n * Create a new record\r\n */\r\n async create(dto: CreateRecordDto): Promise<UsrRecord> {\r\n // Get fields to validate and filter computed fields\r\n const fields = await this.fieldService.findByTable(dto.tableId);\r\n const cleanData = this.filterComputedFields(dto.data, fields);\r\n\r\n const record = this.recordRepo.create({\r\n tableId: dto.tableId,\r\n data: cleanData,\r\n createdBy: dto.createdBy,\r\n updatedBy: dto.createdBy,\r\n });\r\n\r\n return this.recordRepo.save(record);\r\n }\r\n\r\n /**\r\n * Batch create records\r\n */\r\n async batchCreate(dto: BatchCreateRecordDto): Promise<UsrRecord[]> {\r\n const fields = await this.fieldService.findByTable(dto.tableId);\r\n\r\n const records = dto.records.map((item) => {\r\n const cleanData = this.filterComputedFields(item.data, fields);\r\n return this.recordRepo.create({\r\n tableId: dto.tableId,\r\n data: cleanData,\r\n createdBy: dto.createdBy,\r\n updatedBy: dto.createdBy,\r\n });\r\n });\r\n\r\n return this.recordRepo.save(records);\r\n }\r\n\r\n /**\r\n * Find all records with query options\r\n */\r\n async findAll(\r\n tableId: string,\r\n options: QueryOptions = {},\r\n ): Promise<PaginatedResult<Record<string, any>>> {\r\n const fields = await this.fieldService.findByTable(tableId);\r\n const tableFields = fields.map((f) => this.fieldService.toInterface(f));\r\n return this.recordQueryService.getRecords(tableId, tableFields, options);\r\n }\r\n\r\n /**\r\n * Find record by ID\r\n */\r\n async findById(id: string): Promise<UsrRecord | null> {\r\n return this.recordRepo.findOne({ where: { id } });\r\n }\r\n\r\n /**\r\n * Find record by ID with computed fields\r\n */\r\n async findByIdWithComputed(\r\n id: string,\r\n tableId: string,\r\n ): Promise<Record<string, any> | null> {\r\n const fields = await this.fieldService.findByTable(tableId);\r\n const tableFields = fields.map((f) => this.fieldService.toInterface(f));\r\n return this.recordQueryService.getRecordById(id, tableFields);\r\n }\r\n\r\n /**\r\n * Update record\r\n */\r\n async update(\r\n id: string,\r\n dto: UpdateRecordDto,\r\n trackHistory = true,\r\n ): Promise<UsrRecord | null> {\r\n const existing = await this.findById(id);\r\n if (!existing) return null;\r\n\r\n // Get fields to filter computed fields\r\n const fields = await this.fieldService.findByTable(existing.tableId);\r\n\r\n // Calculate changes for history\r\n const changes: Record<string, { old: any; new: any }> = {};\r\n if (dto.data && trackHistory) {\r\n const cleanData = this.filterComputedFields(dto.data, fields);\r\n Object.keys(cleanData).forEach((key) => {\r\n if (existing.data[key] !== cleanData[key]) {\r\n changes[key] = {\r\n old: existing.data[key],\r\n new: cleanData[key],\r\n };\r\n }\r\n });\r\n }\r\n\r\n // Merge data\r\n const newData = dto.data\r\n ? { ...existing.data, ...this.filterComputedFields(dto.data, fields) }\r\n : existing.data;\r\n\r\n await this.recordRepo.update(id, {\r\n data: newData,\r\n updatedBy: dto.updatedBy,\r\n });\r\n\r\n // Track history if there are changes\r\n if (trackHistory && Object.keys(changes).length > 0) {\r\n await this.historyRepo.save({\r\n recordId: id,\r\n changedBy: dto.updatedBy,\r\n changes,\r\n });\r\n }\r\n\r\n return this.findById(id);\r\n }\r\n\r\n /**\r\n * Patch record (partial update)\r\n */\r\n async patch(\r\n id: string,\r\n data: Record<string, any>,\r\n updatedBy?: string,\r\n ): Promise<UsrRecord | null> {\r\n return this.update(id, { data, updatedBy });\r\n }\r\n\r\n /**\r\n * Delete record\r\n */\r\n async delete(id: string): Promise<boolean> {\r\n const result = await this.recordRepo.delete(id);\r\n return (result.affected ?? 0) > 0;\r\n }\r\n\r\n /**\r\n * Batch delete records\r\n */\r\n async batchDelete(ids: string[]): Promise<number> {\r\n const result = await this.recordRepo.delete(ids);\r\n return result.affected ?? 0;\r\n }\r\n\r\n /**\r\n * Get record history\r\n */\r\n async getHistory(recordId: string): Promise<UsrRecordHistory[]> {\r\n return this.historyRepo.find({\r\n where: { recordId },\r\n order: { changedAt: 'DESC' },\r\n });\r\n }\r\n\r\n /**\r\n * Filter out computed fields from data\r\n */\r\n private filterComputedFields(\r\n data: Record<string, any>,\r\n fields: SysField[],\r\n ): Record<string, any> {\r\n const computedKeyNames = fields\r\n .filter((f) => COMPUTED_FIELD_TYPES.includes(f.type))\r\n .map((f) => f.keyName);\r\n\r\n const filtered: Record<string, any> = {};\r\n Object.keys(data).forEach((key) => {\r\n if (!computedKeyNames.includes(key)) {\r\n filtered[key] = data[key];\r\n }\r\n });\r\n\r\n return filtered;\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { InjectRepository } from '@nestjs/typeorm';\r\nimport { Repository, SelectQueryBuilder } from 'typeorm';\r\nimport { UsrRecord } from '../entities';\r\nimport { FieldType } from '../constants';\r\nimport {\r\n TableField,\r\n FilterParams,\r\n SortParams,\r\n PaginationParams,\r\n QueryOptions,\r\n PaginatedResult,\r\n} from '../interfaces';\r\nimport { FormulaService } from './formula.service';\r\n\r\n@Injectable()\r\nexport class RecordQueryService {\r\n constructor(\r\n @InjectRepository(UsrRecord)\r\n private readonly recordRepo: Repository<UsrRecord>,\r\n private readonly formulaService: FormulaService,\r\n ) {}\r\n\r\n /**\r\n * Get records with dynamic filters, sorting, and pagination\r\n */\r\n async getRecords(\r\n tableId: string,\r\n fields: TableField[],\r\n options: QueryOptions = {},\r\n ): Promise<PaginatedResult<Record<string, any>>> {\r\n const { filters = [], sort, pagination, search } = options;\r\n\r\n const query = this.recordRepo\r\n .createQueryBuilder('record')\r\n .where('record.table_id = :tableId', { tableId });\r\n\r\n // Add formula computed columns\r\n this.addFormulaSelects(query, fields);\r\n\r\n // Apply filters\r\n this.applyFilters(query, filters, fields);\r\n\r\n // Apply search\r\n if (search?.query) {\r\n this.applySearch(query, search.query, search.fields || [], fields);\r\n }\r\n\r\n // Get total count before pagination\r\n const total = await query.getCount();\r\n\r\n // Apply sorting\r\n this.applySort(query, sort, fields);\r\n\r\n // Apply pagination\r\n const { page, limit, offset } = this.normalizePagination(pagination);\r\n query.skip(offset).take(limit);\r\n\r\n // Execute query\r\n const rawResults = await query.getRawMany();\r\n const data = this.mapRawResults(rawResults);\r\n\r\n return {\r\n data,\r\n meta: {\r\n total,\r\n page,\r\n limit,\r\n totalPages: Math.ceil(total / limit),\r\n hasNextPage: page * limit < total,\r\n hasPrevPage: page > 1,\r\n },\r\n };\r\n }\r\n\r\n /**\r\n * Get single record by ID\r\n */\r\n async getRecordById(\r\n recordId: string,\r\n fields: TableField[],\r\n ): Promise<Record<string, any> | null> {\r\n const query = this.recordRepo\r\n .createQueryBuilder('record')\r\n .where('record.id = :recordId', { recordId });\r\n\r\n this.addFormulaSelects(query, fields);\r\n\r\n const raw = await query.getRawOne();\r\n if (!raw) return null;\r\n\r\n return this.mapSingleRawResult(raw);\r\n }\r\n\r\n /**\r\n * Add formula fields as computed columns\r\n */\r\n private addFormulaSelects(\r\n query: SelectQueryBuilder<UsrRecord>,\r\n fields: TableField[],\r\n ): void {\r\n fields.forEach((field) => {\r\n if (field.type === FieldType.FORMULA && field.config.formulaExpression) {\r\n const sqlExpression = this.formulaService.parseToSQL(\r\n field.config.formulaExpression,\r\n );\r\n query.addSelect(`(${sqlExpression})`, `formula_${field.keyName}`);\r\n }\r\n });\r\n }\r\n\r\n /**\r\n * Apply filters to query\r\n */\r\n private applyFilters(\r\n query: SelectQueryBuilder<UsrRecord>,\r\n filters: FilterParams[],\r\n fields: TableField[],\r\n ): void {\r\n filters.forEach((filter, index) => {\r\n this.applySingleFilter(query, filter, index, fields);\r\n });\r\n }\r\n\r\n /**\r\n * Apply single filter\r\n */\r\n private applySingleFilter(\r\n query: SelectQueryBuilder<UsrRecord>,\r\n filter: FilterParams,\r\n idx: number,\r\n fields: TableField[],\r\n ): void {\r\n const paramName = `val_${idx}`;\r\n const fieldDef = fields.find((f) => f.keyName === filter.column);\r\n let dbFieldSql = '';\r\n\r\n // Determine data source (Formula or Raw Data)\r\n if (fieldDef?.type === FieldType.FORMULA && fieldDef.config.formulaExpression) {\r\n dbFieldSql = `(${this.formulaService.parseToSQL(fieldDef.config.formulaExpression)})`;\r\n } else {\r\n const castType = this.formulaService.getPostgresCastType(\r\n filter.dataType || fieldDef?.type,\r\n );\r\n dbFieldSql = `(record.data->>'${filter.column}')${castType}`;\r\n }\r\n\r\n const paramObj = { [paramName]: filter.value };\r\n\r\n switch (filter.operator) {\r\n case 'eq':\r\n query.andWhere(`${dbFieldSql} = :${paramName}`, paramObj);\r\n break;\r\n case 'neq':\r\n query.andWhere(`${dbFieldSql} != :${paramName}`, paramObj);\r\n break;\r\n case 'gt':\r\n query.andWhere(`${dbFieldSql} > :${paramName}`, paramObj);\r\n break;\r\n case 'gte':\r\n query.andWhere(`${dbFieldSql} >= :${paramName}`, paramObj);\r\n break;\r\n case 'lt':\r\n query.andWhere(`${dbFieldSql} < :${paramName}`, paramObj);\r\n break;\r\n case 'lte':\r\n query.andWhere(`${dbFieldSql} <= :${paramName}`, paramObj);\r\n break;\r\n case 'contains':\r\n query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {\r\n [paramName]: `%${filter.value}%`,\r\n });\r\n break;\r\n case 'not_contains':\r\n query.andWhere(`record.data->>'${filter.column}' NOT ILIKE :${paramName}`, {\r\n [paramName]: `%${filter.value}%`,\r\n });\r\n break;\r\n case 'starts_with':\r\n query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {\r\n [paramName]: `${filter.value}%`,\r\n });\r\n break;\r\n case 'ends_with':\r\n query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {\r\n [paramName]: `%${filter.value}`,\r\n });\r\n break;\r\n case 'in':\r\n query.andWhere(`${dbFieldSql} IN (:...${paramName})`, paramObj);\r\n break;\r\n case 'not_in':\r\n query.andWhere(`${dbFieldSql} NOT IN (:...${paramName})`, paramObj);\r\n break;\r\n case 'is_empty':\r\n query.andWhere(\r\n `(record.data->>'${filter.column}' IS NULL OR record.data->>'${filter.column}' = '')`,\r\n );\r\n break;\r\n case 'is_not_empty':\r\n query.andWhere(\r\n `(record.data->>'${filter.column}' IS NOT NULL AND record.data->>'${filter.column}' != '')`,\r\n );\r\n break;\r\n case 'is_before':\r\n query.andWhere(`(record.data->>'${filter.column}')::timestamp < :${paramName}`, paramObj);\r\n break;\r\n case 'is_after':\r\n query.andWhere(`(record.data->>'${filter.column}')::timestamp > :${paramName}`, paramObj);\r\n break;\r\n case 'is_on_or_before':\r\n query.andWhere(`(record.data->>'${filter.column}')::timestamp <= :${paramName}`, paramObj);\r\n break;\r\n case 'is_on_or_after':\r\n query.andWhere(`(record.data->>'${filter.column}')::timestamp >= :${paramName}`, paramObj);\r\n break;\r\n case 'is_within':\r\n // value should be like { days: 7 } or { weeks: 2 }\r\n if (typeof filter.value === 'object') {\r\n const interval = this.buildIntervalExpression(filter.value);\r\n query.andWhere(\r\n `(record.data->>'${filter.column}')::timestamp >= NOW() - INTERVAL '${interval}'`,\r\n );\r\n }\r\n break;\r\n }\r\n }\r\n\r\n /**\r\n * Apply full-text search\r\n */\r\n private applySearch(\r\n query: SelectQueryBuilder<UsrRecord>,\r\n searchQuery: string,\r\n searchFields: string[],\r\n fields: TableField[],\r\n ): void {\r\n const textFields =\r\n searchFields.length > 0\r\n ? searchFields\r\n : fields\r\n .filter((f) =>\r\n [FieldType.TEXT, FieldType.LONG_TEXT, FieldType.EMAIL].includes(f.type),\r\n )\r\n .map((f) => f.keyName);\r\n\r\n if (textFields.length === 0) return;\r\n\r\n const conditions = textFields\r\n .map((field) => `record.data->>'${field}' ILIKE :searchQuery`)\r\n .join(' OR ');\r\n\r\n query.andWhere(`(${conditions})`, { searchQuery: `%${searchQuery}%` });\r\n }\r\n\r\n /**\r\n * Apply sorting\r\n */\r\n private applySort(\r\n query: SelectQueryBuilder<UsrRecord>,\r\n sort: SortParams | undefined,\r\n fields: TableField[],\r\n ): void {\r\n if (!sort) {\r\n query.orderBy('record.created_at', 'DESC');\r\n return;\r\n }\r\n\r\n const fieldDef = fields.find((f) => f.keyName === sort.fieldKey);\r\n\r\n if (fieldDef?.type === FieldType.FORMULA && fieldDef.config.formulaExpression) {\r\n const sqlExpression = this.formulaService.parseToSQL(fieldDef.config.formulaExpression);\r\n query.orderBy(`(${sqlExpression})`, sort.order);\r\n } else {\r\n const castType = this.formulaService.getPostgresCastType(fieldDef?.type);\r\n query.orderBy(`(record.data->>'${sort.fieldKey}')${castType}`, sort.order);\r\n }\r\n }\r\n\r\n /**\r\n * Normalize pagination params\r\n */\r\n private normalizePagination(pagination?: PaginationParams): {\r\n page: number;\r\n limit: number;\r\n offset: number;\r\n } {\r\n const page = pagination?.page || 1;\r\n const limit = Math.min(pagination?.limit || 50, 100); // Max 100 per page\r\n const offset = pagination?.offset ?? (page - 1) * limit;\r\n\r\n return { page, limit, offset };\r\n }\r\n\r\n /**\r\n * Build interval expression for is_within filter\r\n */\r\n private buildIntervalExpression(value: Record<string, number>): string {\r\n const parts: string[] = [];\r\n if (value.days) parts.push(`${value.days} days`);\r\n if (value.weeks) parts.push(`${value.weeks * 7} days`);\r\n if (value.months) parts.push(`${value.months} months`);\r\n if (value.years) parts.push(`${value.years} years`);\r\n return parts.join(' ') || '0 days';\r\n }\r\n\r\n /**\r\n * Map raw query results to clean response\r\n */\r\n private mapRawResults(raws: any[]): Record<string, any>[] {\r\n return raws.map((row) => this.mapSingleRawResult(row));\r\n }\r\n\r\n /**\r\n * Map single raw result\r\n */\r\n private mapSingleRawResult(row: any): Record<string, any> {\r\n const cleanRecord: Record<string, any> = {\r\n id: row.record_id,\r\n ...row.record_data,\r\n createdAt: row.record_created_at,\r\n updatedAt: row.record_updated_at,\r\n createdBy: row.record_created_by,\r\n updatedBy: row.record_updated_by,\r\n };\r\n\r\n // Map formula fields\r\n Object.keys(row).forEach((key) => {\r\n if (key.startsWith('formula_')) {\r\n const realKey = key.replace('formula_', '');\r\n const value = row[key];\r\n cleanRecord[realKey] = value !== null ? parseFloat(value) : null;\r\n }\r\n });\r\n\r\n return cleanRecord;\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { FieldType } from '../constants';\r\nimport { TableField } from '../interfaces';\r\n\r\n/**\r\n * Service để parse và xử lý công thức (Formula)\r\n */\r\n@Injectable()\r\nexport class FormulaService {\r\n /**\r\n * Parse formula expression thành SQL expression\r\n * Input: \"{price} * {qty}\"\r\n * Output: \"COALESCE((record.data->>'price')::numeric, 0) * COALESCE((record.data->>'qty')::numeric, 0)\"\r\n */\r\n parseToSQL(expression: string, tableAlias = 'record'): string {\r\n return expression.replace(/\\{(\\w+)\\}/g, (_, keyName) => {\r\n return `COALESCE((${tableAlias}.data->>'${keyName}')::numeric, 0)`;\r\n });\r\n }\r\n\r\n /**\r\n * Parse formula với type casting dựa trên field type\r\n */\r\n parseToSQLWithType(\r\n expression: string,\r\n fields: TableField[],\r\n tableAlias = 'record',\r\n ): string {\r\n return expression.replace(/\\{(\\w+)\\}/g, (_, keyName) => {\r\n const field = fields.find((f) => f.keyName === keyName);\r\n const castType = this.getPostgresCastType(field?.type);\r\n const defaultValue = this.getDefaultValue(field?.type);\r\n\r\n return `COALESCE((${tableAlias}.data->>'${keyName}')${castType}, ${defaultValue})`;\r\n });\r\n }\r\n\r\n /**\r\n * Validate formula expression\r\n */\r\n validate(expression: string, availableFields: string[]): { valid: boolean; errors: string[] } {\r\n const errors: string[] = [];\r\n const fieldPattern = /\\{(\\w+)\\}/g;\r\n let match;\r\n\r\n while ((match = fieldPattern.exec(expression)) !== null) {\r\n const fieldName = match[1];\r\n if (!availableFields.includes(fieldName)) {\r\n errors.push(`Field \"${fieldName}\" does not exist`);\r\n }\r\n }\r\n\r\n // Check for basic syntax errors\r\n try {\r\n // Replace field references with 1 and try to evaluate\r\n const testExpr = expression.replace(/\\{(\\w+)\\}/g, '1');\r\n // Basic check - just ensure it doesn't have obvious syntax errors\r\n if (testExpr.includes('{{') || testExpr.includes('}}')) {\r\n errors.push('Invalid bracket syntax');\r\n }\r\n } catch {\r\n errors.push('Invalid formula syntax');\r\n }\r\n\r\n return {\r\n valid: errors.length === 0,\r\n errors,\r\n };\r\n }\r\n\r\n /**\r\n * Extract field references from formula\r\n */\r\n extractFieldReferences(expression: string): string[] {\r\n const fieldPattern = /\\{(\\w+)\\}/g;\r\n const fields: string[] = [];\r\n let match;\r\n\r\n while ((match = fieldPattern.exec(expression)) !== null) {\r\n if (!fields.includes(match[1])) {\r\n fields.push(match[1]);\r\n }\r\n }\r\n\r\n return fields;\r\n }\r\n\r\n /**\r\n * Get PostgreSQL cast type for field type\r\n */\r\n getPostgresCastType(type?: FieldType): string {\r\n switch (type) {\r\n case FieldType.NUMBER:\r\n case FieldType.CURRENCY:\r\n case FieldType.PERCENT:\r\n case FieldType.RATING:\r\n return '::numeric';\r\n case FieldType.BOOLEAN:\r\n return '::boolean';\r\n case FieldType.DATE:\r\n case FieldType.DATETIME:\r\n return '::timestamp';\r\n default:\r\n return '';\r\n }\r\n }\r\n\r\n /**\r\n * Get default value for field type\r\n */\r\n getDefaultValue(type?: FieldType): string {\r\n switch (type) {\r\n case FieldType.NUMBER:\r\n case FieldType.CURRENCY:\r\n case FieldType.PERCENT:\r\n case FieldType.RATING:\r\n return '0';\r\n case FieldType.BOOLEAN:\r\n return 'false';\r\n case FieldType.TEXT:\r\n case FieldType.LONG_TEXT:\r\n case FieldType.EMAIL:\r\n case FieldType.PHONE:\r\n case FieldType.URL:\r\n return \"''\";\r\n default:\r\n return 'NULL';\r\n }\r\n }\r\n\r\n /**\r\n * Build SQL expression for computed field\r\n */\r\n buildComputedFieldSQL(field: TableField, tableAlias = 'record'): string | null {\r\n if (field.type !== FieldType.FORMULA) {\r\n return null;\r\n }\r\n\r\n const expression = field.config.formulaExpression;\r\n if (!expression) {\r\n return null;\r\n }\r\n\r\n return this.parseToSQL(expression, tableAlias);\r\n }\r\n}\r\n","import { Injectable } from '@nestjs/common';\r\nimport { FieldType } from '../constants';\r\nimport { TableField, FieldConfig } from '../interfaces';\r\n\r\nexport interface ValidationError {\r\n field: string;\r\n message: string;\r\n code: string;\r\n}\r\n\r\nexport interface ValidationResult {\r\n valid: boolean;\r\n errors: ValidationError[];\r\n}\r\n\r\n@Injectable()\r\nexport class ValidationService {\r\n /**\r\n * Validate record data against field definitions\r\n */\r\n validate(data: Record<string, any>, fields: TableField[]): ValidationResult {\r\n const errors: ValidationError[] = [];\r\n\r\n fields.forEach((field) => {\r\n const value = data[field.keyName];\r\n const fieldErrors = this.validateField(value, field);\r\n errors.push(...fieldErrors);\r\n });\r\n\r\n return {\r\n valid: errors.length === 0,\r\n errors,\r\n };\r\n }\r\n\r\n /**\r\n * Validate single field value\r\n */\r\n validateField(value: any, field: TableField): ValidationError[] {\r\n const errors: ValidationError[] = [];\r\n\r\n // Required check\r\n if (field.isRequired && this.isEmpty(value)) {\r\n errors.push({\r\n field: field.keyName,\r\n message: `${field.name} is required`,\r\n code: 'REQUIRED',\r\n });\r\n return errors; // Skip other validations if required and empty\r\n }\r\n\r\n // Skip validation if value is empty and not required\r\n if (this.isEmpty(value)) {\r\n return errors;\r\n }\r\n\r\n // Type-specific validation\r\n switch (field.type) {\r\n case FieldType.EMAIL:\r\n if (!this.isValidEmail(value)) {\r\n errors.push({\r\n field: field.keyName,\r\n message: `${field.name} must be a valid email address`,\r\n code: 'INVALID_EMAIL',\r\n });\r\n }\r\n break;\r\n\r\n case FieldType.URL:\r\n if (!this.isValidUrl(value, field.config)) {\r\n errors.push({\r\n field: field.keyName,\r\n message: `${field.name} must be a valid URL`,\r\n