UNPKG

@warriorteam/dynamic-table

Version:

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

1,567 lines (1,545 loc) 45.8 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index); // src/dynamic-table.module.ts import { Module, Global } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; // src/entities/workspace.entity.ts import { Entity as Entity6, PrimaryGeneratedColumn as PrimaryGeneratedColumn6, Column as Column6, CreateDateColumn as CreateDateColumn5, OneToMany as OneToMany3 } from "typeorm"; // src/entities/table.entity.ts import { Entity as Entity5, PrimaryGeneratedColumn as PrimaryGeneratedColumn5, Column as Column5, CreateDateColumn as CreateDateColumn4, ManyToOne as ManyToOne5, OneToMany as OneToMany2, JoinColumn as JoinColumn5, Unique as Unique2 } from "typeorm"; // src/entities/field.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Unique } from "typeorm"; var SysField = class { }; __decorateClass([ PrimaryGeneratedColumn("uuid") ], SysField.prototype, "id", 2); __decorateClass([ Column({ name: "table_id", type: "uuid" }) ], SysField.prototype, "tableId", 2); __decorateClass([ Column({ type: "varchar", length: 255 }) ], SysField.prototype, "name", 2); __decorateClass([ Column({ name: "key_name", type: "varchar", length: 255 }) ], SysField.prototype, "keyName", 2); __decorateClass([ Column({ type: "varchar", length: 50 }) ], SysField.prototype, "type", 2); __decorateClass([ Column({ type: "jsonb", default: {} }) ], SysField.prototype, "config", 2); __decorateClass([ Column({ name: "is_primary", type: "boolean", default: false }) ], SysField.prototype, "isPrimary", 2); __decorateClass([ Column({ name: "is_required", type: "boolean", default: false }) ], SysField.prototype, "isRequired", 2); __decorateClass([ Column({ name: "order_index", type: "integer", default: 0 }) ], SysField.prototype, "orderIndex", 2); __decorateClass([ CreateDateColumn({ name: "created_at", type: "timestamp with time zone" }) ], SysField.prototype, "createdAt", 2); __decorateClass([ ManyToOne(() => SysTable, (table) => table.fields, { onDelete: "CASCADE" }), JoinColumn({ name: "table_id" }) ], SysField.prototype, "table", 2); SysField = __decorateClass([ Entity("sys_fields"), Unique(["tableId", "keyName"]) ], SysField); // src/entities/view.entity.ts import { Entity as Entity2, PrimaryGeneratedColumn as PrimaryGeneratedColumn2, Column as Column2, ManyToOne as ManyToOne2, JoinColumn as JoinColumn2 } from "typeorm"; var SysView = class { }; __decorateClass([ PrimaryGeneratedColumn2("uuid") ], SysView.prototype, "id", 2); __decorateClass([ Column2({ name: "table_id", type: "uuid" }) ], SysView.prototype, "tableId", 2); __decorateClass([ Column2({ type: "varchar", length: 255 }) ], SysView.prototype, "name", 2); __decorateClass([ Column2({ type: "varchar", length: 50, default: "grid" }) ], SysView.prototype, "type", 2); __decorateClass([ Column2({ type: "jsonb", default: {} }) ], SysView.prototype, "config", 2); __decorateClass([ Column2({ name: "is_default", type: "boolean", default: false }) ], SysView.prototype, "isDefault", 2); __decorateClass([ ManyToOne2(() => SysTable, (table) => table.views, { onDelete: "CASCADE" }), JoinColumn2({ name: "table_id" }) ], SysView.prototype, "table", 2); SysView = __decorateClass([ Entity2("sys_views") ], SysView); // src/entities/record.entity.ts import { Entity as Entity4, PrimaryGeneratedColumn as PrimaryGeneratedColumn4, Column as Column4, CreateDateColumn as CreateDateColumn3, UpdateDateColumn, ManyToOne as ManyToOne4, OneToMany, JoinColumn as JoinColumn4, Index } from "typeorm"; // src/entities/record-history.entity.ts import { Entity as Entity3, PrimaryGeneratedColumn as PrimaryGeneratedColumn3, Column as Column3, CreateDateColumn as CreateDateColumn2, ManyToOne as ManyToOne3, JoinColumn as JoinColumn3 } from "typeorm"; var UsrRecordHistory = class { }; __decorateClass([ PrimaryGeneratedColumn3("uuid") ], UsrRecordHistory.prototype, "id", 2); __decorateClass([ Column3({ name: "record_id", type: "uuid" }) ], UsrRecordHistory.prototype, "recordId", 2); __decorateClass([ Column3({ name: "changed_by", type: "uuid", nullable: true }) ], UsrRecordHistory.prototype, "changedBy", 2); __decorateClass([ CreateDateColumn2({ name: "changed_at", type: "timestamp with time zone" }) ], UsrRecordHistory.prototype, "changedAt", 2); __decorateClass([ Column3({ type: "jsonb" }) ], UsrRecordHistory.prototype, "changes", 2); __decorateClass([ ManyToOne3(() => UsrRecord, (record) => record.history, { onDelete: "CASCADE" }), JoinColumn3({ name: "record_id" }) ], UsrRecordHistory.prototype, "record", 2); UsrRecordHistory = __decorateClass([ Entity3("usr_record_history") ], UsrRecordHistory); // src/entities/record.entity.ts var UsrRecord = class { }; __decorateClass([ PrimaryGeneratedColumn4("uuid") ], UsrRecord.prototype, "id", 2); __decorateClass([ Column4({ name: "table_id", type: "uuid" }), Index() ], UsrRecord.prototype, "tableId", 2); __decorateClass([ Column4({ type: "jsonb", default: {} }), Index("idx_usr_records_data", { synchronize: false }) ], UsrRecord.prototype, "data", 2); __decorateClass([ Column4({ name: "created_by", type: "uuid", nullable: true }) ], UsrRecord.prototype, "createdBy", 2); __decorateClass([ Column4({ name: "updated_by", type: "uuid", nullable: true }) ], UsrRecord.prototype, "updatedBy", 2); __decorateClass([ CreateDateColumn3({ name: "created_at", type: "timestamp with time zone" }) ], UsrRecord.prototype, "createdAt", 2); __decorateClass([ UpdateDateColumn({ name: "updated_at", type: "timestamp with time zone" }) ], UsrRecord.prototype, "updatedAt", 2); __decorateClass([ ManyToOne4(() => SysTable, (table) => table.records, { onDelete: "CASCADE" }), JoinColumn4({ name: "table_id" }) ], UsrRecord.prototype, "table", 2); __decorateClass([ OneToMany(() => UsrRecordHistory, (history) => history.record) ], UsrRecord.prototype, "history", 2); UsrRecord = __decorateClass([ Entity4("usr_records") ], UsrRecord); // src/entities/table.entity.ts var SysTable = class { }; __decorateClass([ PrimaryGeneratedColumn5("uuid") ], SysTable.prototype, "id", 2); __decorateClass([ Column5({ name: "workspace_id", type: "uuid" }) ], SysTable.prototype, "workspaceId", 2); __decorateClass([ Column5({ type: "varchar", length: 255 }) ], SysTable.prototype, "name", 2); __decorateClass([ Column5({ type: "varchar", length: 255 }) ], SysTable.prototype, "slug", 2); __decorateClass([ Column5({ type: "text", nullable: true }) ], SysTable.prototype, "description", 2); __decorateClass([ Column5({ type: "jsonb", default: {} }) ], SysTable.prototype, "settings", 2); __decorateClass([ CreateDateColumn4({ name: "created_at", type: "timestamp with time zone" }) ], SysTable.prototype, "createdAt", 2); __decorateClass([ ManyToOne5(() => SysWorkspace, (workspace) => workspace.tables, { onDelete: "CASCADE" }), JoinColumn5({ name: "workspace_id" }) ], SysTable.prototype, "workspace", 2); __decorateClass([ OneToMany2(() => SysField, (field) => field.table) ], SysTable.prototype, "fields", 2); __decorateClass([ OneToMany2(() => SysView, (view) => view.table) ], SysTable.prototype, "views", 2); __decorateClass([ OneToMany2(() => UsrRecord, (record) => record.table) ], SysTable.prototype, "records", 2); SysTable = __decorateClass([ Entity5("sys_tables"), Unique2(["workspaceId", "slug"]) ], SysTable); // src/entities/workspace.entity.ts var SysWorkspace = class { }; __decorateClass([ PrimaryGeneratedColumn6("uuid") ], SysWorkspace.prototype, "id", 2); __decorateClass([ Column6({ type: "varchar", length: 255 }) ], SysWorkspace.prototype, "name", 2); __decorateClass([ Column6({ type: "varchar", length: 255, unique: true }) ], SysWorkspace.prototype, "slug", 2); __decorateClass([ CreateDateColumn5({ name: "created_at", type: "timestamp with time zone" }) ], SysWorkspace.prototype, "createdAt", 2); __decorateClass([ OneToMany3(() => SysTable, (table) => table.workspace) ], SysWorkspace.prototype, "tables", 2); SysWorkspace = __decorateClass([ Entity6("sys_workspaces") ], SysWorkspace); // src/entities/index.ts var DYNAMIC_TABLE_ENTITIES = [ SysWorkspace, SysTable, SysField, SysView, UsrRecord, UsrRecordHistory ]; // src/services/workspace.service.ts import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; var WorkspaceService = class { constructor(workspaceRepo) { this.workspaceRepo = workspaceRepo; } /** * Create a new workspace */ async create(dto) { const workspace = this.workspaceRepo.create(dto); return this.workspaceRepo.save(workspace); } /** * Find all workspaces */ async findAll() { return this.workspaceRepo.find({ order: { createdAt: "DESC" } }); } /** * Find workspace by ID */ async findById(id) { return this.workspaceRepo.findOne({ where: { id } }); } /** * Find workspace by slug */ async findBySlug(slug) { return this.workspaceRepo.findOne({ where: { slug } }); } /** * Update workspace */ async update(id, dto) { await this.workspaceRepo.update(id, dto); return this.findById(id); } /** * Delete workspace */ async delete(id) { const result = await this.workspaceRepo.delete(id); return (result.affected ?? 0) > 0; } }; WorkspaceService = __decorateClass([ Injectable(), __decorateParam(0, InjectRepository(SysWorkspace)) ], WorkspaceService); // src/services/table.service.ts import { Injectable as Injectable2 } from "@nestjs/common"; import { InjectRepository as InjectRepository2 } from "@nestjs/typeorm"; var TableService = class { constructor(tableRepo) { this.tableRepo = tableRepo; } /** * Create a new table */ async create(dto) { const table = this.tableRepo.create(dto); return this.tableRepo.save(table); } /** * Find all tables in a workspace */ async findByWorkspace(workspaceId) { return this.tableRepo.find({ where: { workspaceId }, order: { createdAt: "DESC" } }); } /** * Find table by ID */ async findById(id) { return this.tableRepo.findOne({ where: { id }, relations: ["fields"] }); } /** * Find table by slug within a workspace */ async findBySlug(workspaceId, slug) { return this.tableRepo.findOne({ where: { workspaceId, slug }, relations: ["fields"] }); } /** * Find table with all relations */ async findByIdWithRelations(id) { return this.tableRepo.findOne({ where: { id }, relations: ["fields", "views"] }); } /** * Update table */ async update(id, dto) { await this.tableRepo.update(id, dto); return this.findById(id); } /** * Delete table */ async delete(id) { const result = await this.tableRepo.delete(id); return (result.affected ?? 0) > 0; } }; TableService = __decorateClass([ Injectable2(), __decorateParam(0, InjectRepository2(SysTable)) ], TableService); // src/services/field.service.ts import { Injectable as Injectable3 } from "@nestjs/common"; import { InjectRepository as InjectRepository3 } from "@nestjs/typeorm"; // src/constants/field-types.constant.ts var FieldType = /* @__PURE__ */ ((FieldType2) => { FieldType2["TEXT"] = "text"; FieldType2["LONG_TEXT"] = "long_text"; FieldType2["EMAIL"] = "email"; FieldType2["PHONE"] = "phone"; FieldType2["URL"] = "url"; FieldType2["NUMBER"] = "number"; FieldType2["CURRENCY"] = "currency"; FieldType2["PERCENT"] = "percent"; FieldType2["RATING"] = "rating"; FieldType2["AUTONUMBER"] = "autonumber"; FieldType2["SELECT"] = "select"; FieldType2["MULTI_SELECT"] = "multi_select"; FieldType2["BOOLEAN"] = "boolean"; FieldType2["DATE"] = "date"; FieldType2["DATETIME"] = "datetime"; FieldType2["DURATION"] = "duration"; FieldType2["RELATION"] = "relation"; FieldType2["LOOKUP"] = "lookup"; FieldType2["ROLLUP"] = "rollup"; FieldType2["FORMULA"] = "formula"; FieldType2["CREATED_TIME"] = "created_time"; FieldType2["MODIFIED_TIME"] = "modified_time"; FieldType2["CREATED_BY"] = "created_by"; FieldType2["MODIFIED_BY"] = "modified_by"; FieldType2["ATTACHMENT"] = "attachment"; FieldType2["USER"] = "user"; return FieldType2; })(FieldType || {}); var COMPUTED_FIELD_TYPES = [ "formula" /* FORMULA */, "lookup" /* LOOKUP */, "rollup" /* ROLLUP */, "created_time" /* CREATED_TIME */, "modified_time" /* MODIFIED_TIME */, "created_by" /* CREATED_BY */, "modified_by" /* MODIFIED_BY */, "autonumber" /* AUTONUMBER */ ]; var FIELD_TYPE_METADATA = { ["text" /* TEXT */]: { label: "V\u0103n b\u1EA3n", icon: "type", isComputed: false }, ["long_text" /* LONG_TEXT */]: { label: "V\u0103n b\u1EA3n d\xE0i", icon: "align-left", isComputed: false, defaultConfig: { enableRichText: false } }, ["email" /* EMAIL */]: { label: "Email", icon: "mail", isComputed: false }, ["phone" /* PHONE */]: { label: "S\u1ED1 \u0111i\u1EC7n tho\u1EA1i", icon: "phone", isComputed: false, defaultConfig: { defaultCountryCode: "+84" } }, ["url" /* URL */]: { label: "\u0110\u01B0\u1EDDng d\u1EABn", icon: "link", isComputed: false, defaultConfig: { urlType: "any" } }, ["number" /* NUMBER */]: { label: "S\u1ED1", icon: "hash", isComputed: false, defaultConfig: { precision: 0 } }, ["currency" /* CURRENCY */]: { label: "Ti\u1EC1n t\u1EC7", icon: "dollar-sign", isComputed: false, defaultConfig: { currencyCode: "VND", currencySymbol: "\u20AB", precision: 0 } }, ["percent" /* PERCENT */]: { label: "Ph\u1EA7n tr\u0103m", icon: "percent", isComputed: false, defaultConfig: { percentFormat: "whole", precision: 0 } }, ["rating" /* RATING */]: { label: "\u0110\xE1nh gi\xE1", icon: "star", isComputed: false, defaultConfig: { maxRating: 5, ratingIcon: "star" } }, ["autonumber" /* AUTONUMBER */]: { label: "S\u1ED1 t\u1EF1 \u0111\u1ED9ng", icon: "list-ordered", isComputed: true, defaultConfig: { startNumber: 1, digitCount: 5 } }, ["select" /* SELECT */]: { label: "Ch\u1ECDn m\u1ED9t", icon: "chevron-down", isComputed: false, defaultConfig: { options: [] } }, ["multi_select" /* MULTI_SELECT */]: { label: "Ch\u1ECDn nhi\u1EC1u", icon: "check-square", isComputed: false, defaultConfig: { options: [] } }, ["boolean" /* BOOLEAN */]: { label: "Checkbox", icon: "check", isComputed: false }, ["date" /* DATE */]: { label: "Ng\xE0y", icon: "calendar", isComputed: false, defaultConfig: { dateFormat: "DD/MM/YYYY" } }, ["datetime" /* DATETIME */]: { label: "Ng\xE0y & Gi\u1EDD", icon: "clock", isComputed: false, defaultConfig: { dateFormat: "DD/MM/YYYY", timeFormat: "24h" } }, ["duration" /* DURATION */]: { label: "Kho\u1EA3ng th\u1EDDi gian", icon: "timer", isComputed: false, defaultConfig: { durationFormat: "h:mm" } }, ["relation" /* RELATION */]: { label: "Li\xEAn k\u1EBFt b\u1EA3n ghi", icon: "link-2", isComputed: false, defaultConfig: { allowMultiple: false } }, ["lookup" /* LOOKUP */]: { label: "Tra c\u1EE9u", icon: "search", isComputed: true }, ["rollup" /* ROLLUP */]: { label: "T\u1ED5ng h\u1EE3p", icon: "sigma", isComputed: true, defaultConfig: { rollupFunction: "count" } }, ["formula" /* FORMULA */]: { label: "C\xF4ng th\u1EE9c", icon: "function-square", isComputed: true, defaultConfig: { outputType: "number" } }, ["created_time" /* CREATED_TIME */]: { label: "Th\u1EDDi gian t\u1EA1o", icon: "clock", isComputed: true }, ["modified_time" /* MODIFIED_TIME */]: { label: "Th\u1EDDi gian s\u1EEDa", icon: "clock", isComputed: true }, ["created_by" /* CREATED_BY */]: { label: "Ng\u01B0\u1EDDi t\u1EA1o", icon: "user", isComputed: true }, ["modified_by" /* MODIFIED_BY */]: { label: "Ng\u01B0\u1EDDi s\u1EEDa", icon: "user", isComputed: true }, ["attachment" /* ATTACHMENT */]: { label: "File \u0111\xEDnh k\xE8m", icon: "paperclip", isComputed: false, defaultConfig: { maxFiles: 10 } }, ["user" /* USER */]: { label: "Ng\u01B0\u1EDDi d\xF9ng", icon: "user", isComputed: false, defaultConfig: { allowMultipleUsers: false } } }; // src/services/field.service.ts var FieldService = class { constructor(fieldRepo) { this.fieldRepo = fieldRepo; } /** * Create a new field */ async create(dto) { const defaultConfig = FIELD_TYPE_METADATA[dto.type]?.defaultConfig || {}; const field = this.fieldRepo.create({ ...dto, config: { ...defaultConfig, ...dto.config } }); return this.fieldRepo.save(field); } /** * Find all fields of a table */ async findByTable(tableId) { return this.fieldRepo.find({ where: { tableId }, order: { orderIndex: "ASC" } }); } /** * Find field by ID */ async findById(id) { return this.fieldRepo.findOne({ where: { id } }); } /** * Find field by keyName within a table */ async findByKeyName(tableId, keyName) { return this.fieldRepo.findOne({ where: { tableId, keyName } }); } /** * Update field */ async update(id, dto) { const existing = await this.findById(id); if (!existing) return null; const updateData = { ...dto }; if (dto.config) { updateData.config = { ...existing.config, ...dto.config }; } await this.fieldRepo.update(id, updateData); return this.findById(id); } /** * Delete field */ async delete(id) { const result = await this.fieldRepo.delete(id); return (result.affected ?? 0) > 0; } /** * Reorder fields */ async reorder(tableId, fieldIds) { const updates = fieldIds.map( (id, index) => this.fieldRepo.update(id, { orderIndex: index }) ); await Promise.all(updates); } /** * Convert entity to interface */ toInterface(entity) { return { id: entity.id, tableId: entity.tableId, name: entity.name, keyName: entity.keyName, type: entity.type, config: entity.config, isPrimary: entity.isPrimary, isRequired: entity.isRequired, orderIndex: entity.orderIndex, createdAt: entity.createdAt }; } }; FieldService = __decorateClass([ Injectable3(), __decorateParam(0, InjectRepository3(SysField)) ], FieldService); // src/services/view.service.ts import { Injectable as Injectable4 } from "@nestjs/common"; import { InjectRepository as InjectRepository4 } from "@nestjs/typeorm"; var ViewService = class { constructor(viewRepo) { this.viewRepo = viewRepo; } /** * Create a new view */ async create(dto) { if (dto.isDefault) { await this.viewRepo.update({ tableId: dto.tableId }, { isDefault: false }); } const view = this.viewRepo.create({ ...dto, type: dto.type || "grid", config: dto.config || {} }); return this.viewRepo.save(view); } /** * Find all views of a table */ async findByTable(tableId) { return this.viewRepo.find({ where: { tableId }, order: { isDefault: "DESC", name: "ASC" } }); } /** * Find view by ID */ async findById(id) { return this.viewRepo.findOne({ where: { id } }); } /** * Find default view of a table */ async findDefault(tableId) { return this.viewRepo.findOne({ where: { tableId, isDefault: true } }); } /** * Update view */ async update(id, dto) { const existing = await this.findById(id); if (!existing) return null; if (dto.isDefault) { await this.viewRepo.update({ tableId: existing.tableId }, { isDefault: false }); } const updateData = { ...dto }; if (dto.config) { updateData.config = { ...existing.config, ...dto.config }; } await this.viewRepo.update(id, updateData); return this.findById(id); } /** * Delete view */ async delete(id) { const result = await this.viewRepo.delete(id); return (result.affected ?? 0) > 0; } }; ViewService = __decorateClass([ Injectable4(), __decorateParam(0, InjectRepository4(SysView)) ], ViewService); // src/services/record.service.ts import { Injectable as Injectable5 } from "@nestjs/common"; import { InjectRepository as InjectRepository5 } from "@nestjs/typeorm"; var RecordService = class { constructor(recordRepo, historyRepo, dataSource, recordQueryService, fieldService) { this.recordRepo = recordRepo; this.historyRepo = historyRepo; this.dataSource = dataSource; this.recordQueryService = recordQueryService; this.fieldService = fieldService; } /** * Create a new record */ async create(dto) { const fields = await this.fieldService.findByTable(dto.tableId); const cleanData = this.filterComputedFields(dto.data, fields); const record = this.recordRepo.create({ tableId: dto.tableId, data: cleanData, createdBy: dto.createdBy, updatedBy: dto.createdBy }); return this.recordRepo.save(record); } /** * Batch create records */ async batchCreate(dto) { const fields = await this.fieldService.findByTable(dto.tableId); const records = dto.records.map((item) => { const cleanData = this.filterComputedFields(item.data, fields); return this.recordRepo.create({ tableId: dto.tableId, data: cleanData, createdBy: dto.createdBy, updatedBy: dto.createdBy }); }); return this.recordRepo.save(records); } /** * Find all records with query options */ async findAll(tableId, options = {}) { const fields = await this.fieldService.findByTable(tableId); const tableFields = fields.map((f) => this.fieldService.toInterface(f)); return this.recordQueryService.getRecords(tableId, tableFields, options); } /** * Find record by ID */ async findById(id) { return this.recordRepo.findOne({ where: { id } }); } /** * Find record by ID with computed fields */ async findByIdWithComputed(id, tableId) { const fields = await this.fieldService.findByTable(tableId); const tableFields = fields.map((f) => this.fieldService.toInterface(f)); return this.recordQueryService.getRecordById(id, tableFields); } /** * Update record */ async update(id, dto, trackHistory = true) { const existing = await this.findById(id); if (!existing) return null; const fields = await this.fieldService.findByTable(existing.tableId); const changes = {}; if (dto.data && trackHistory) { const cleanData = this.filterComputedFields(dto.data, fields); Object.keys(cleanData).forEach((key) => { if (existing.data[key] !== cleanData[key]) { changes[key] = { old: existing.data[key], new: cleanData[key] }; } }); } const newData = dto.data ? { ...existing.data, ...this.filterComputedFields(dto.data, fields) } : existing.data; await this.recordRepo.update(id, { data: newData, updatedBy: dto.updatedBy }); if (trackHistory && Object.keys(changes).length > 0) { await this.historyRepo.save({ recordId: id, changedBy: dto.updatedBy, changes }); } return this.findById(id); } /** * Patch record (partial update) */ async patch(id, data, updatedBy) { return this.update(id, { data, updatedBy }); } /** * Delete record */ async delete(id) { const result = await this.recordRepo.delete(id); return (result.affected ?? 0) > 0; } /** * Batch delete records */ async batchDelete(ids) { const result = await this.recordRepo.delete(ids); return result.affected ?? 0; } /** * Get record history */ async getHistory(recordId) { return this.historyRepo.find({ where: { recordId }, order: { changedAt: "DESC" } }); } /** * Filter out computed fields from data */ filterComputedFields(data, fields) { const computedKeyNames = fields.filter((f) => COMPUTED_FIELD_TYPES.includes(f.type)).map((f) => f.keyName); const filtered = {}; Object.keys(data).forEach((key) => { if (!computedKeyNames.includes(key)) { filtered[key] = data[key]; } }); return filtered; } }; RecordService = __decorateClass([ Injectable5(), __decorateParam(0, InjectRepository5(UsrRecord)), __decorateParam(1, InjectRepository5(UsrRecordHistory)) ], RecordService); // src/services/record-query.service.ts import { Injectable as Injectable6 } from "@nestjs/common"; import { InjectRepository as InjectRepository6 } from "@nestjs/typeorm"; var RecordQueryService = class { constructor(recordRepo, formulaService) { this.recordRepo = recordRepo; this.formulaService = formulaService; } /** * Get records with dynamic filters, sorting, and pagination */ async getRecords(tableId, fields, options = {}) { const { filters = [], sort, pagination, search } = options; const query = this.recordRepo.createQueryBuilder("record").where("record.table_id = :tableId", { tableId }); this.addFormulaSelects(query, fields); this.applyFilters(query, filters, fields); if (search?.query) { this.applySearch(query, search.query, search.fields || [], fields); } const total = await query.getCount(); this.applySort(query, sort, fields); const { page, limit, offset } = this.normalizePagination(pagination); query.skip(offset).take(limit); const rawResults = await query.getRawMany(); const data = this.mapRawResults(rawResults); return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit), hasNextPage: page * limit < total, hasPrevPage: page > 1 } }; } /** * Get single record by ID */ async getRecordById(recordId, fields) { const query = this.recordRepo.createQueryBuilder("record").where("record.id = :recordId", { recordId }); this.addFormulaSelects(query, fields); const raw = await query.getRawOne(); if (!raw) return null; return this.mapSingleRawResult(raw); } /** * Add formula fields as computed columns */ addFormulaSelects(query, fields) { fields.forEach((field) => { if (field.type === "formula" /* FORMULA */ && field.config.formulaExpression) { const sqlExpression = this.formulaService.parseToSQL( field.config.formulaExpression ); query.addSelect(`(${sqlExpression})`, `formula_${field.keyName}`); } }); } /** * Apply filters to query */ applyFilters(query, filters, fields) { filters.forEach((filter, index) => { this.applySingleFilter(query, filter, index, fields); }); } /** * Apply single filter */ applySingleFilter(query, filter, idx, fields) { const paramName = `val_${idx}`; const fieldDef = fields.find((f) => f.keyName === filter.column); let dbFieldSql = ""; if (fieldDef?.type === "formula" /* FORMULA */ && fieldDef.config.formulaExpression) { dbFieldSql = `(${this.formulaService.parseToSQL(fieldDef.config.formulaExpression)})`; } else { const castType = this.formulaService.getPostgresCastType( filter.dataType || fieldDef?.type ); dbFieldSql = `(record.data->>'${filter.column}')${castType}`; } const paramObj = { [paramName]: filter.value }; switch (filter.operator) { case "eq": query.andWhere(`${dbFieldSql} = :${paramName}`, paramObj); break; case "neq": query.andWhere(`${dbFieldSql} != :${paramName}`, paramObj); break; case "gt": query.andWhere(`${dbFieldSql} > :${paramName}`, paramObj); break; case "gte": query.andWhere(`${dbFieldSql} >= :${paramName}`, paramObj); break; case "lt": query.andWhere(`${dbFieldSql} < :${paramName}`, paramObj); break; case "lte": query.andWhere(`${dbFieldSql} <= :${paramName}`, paramObj); break; case "contains": query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, { [paramName]: `%${filter.value}%` }); break; case "not_contains": query.andWhere(`record.data->>'${filter.column}' NOT ILIKE :${paramName}`, { [paramName]: `%${filter.value}%` }); break; case "starts_with": query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, { [paramName]: `${filter.value}%` }); break; case "ends_with": query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, { [paramName]: `%${filter.value}` }); break; case "in": query.andWhere(`${dbFieldSql} IN (:...${paramName})`, paramObj); break; case "not_in": query.andWhere(`${dbFieldSql} NOT IN (:...${paramName})`, paramObj); break; case "is_empty": query.andWhere( `(record.data->>'${filter.column}' IS NULL OR record.data->>'${filter.column}' = '')` ); break; case "is_not_empty": query.andWhere( `(record.data->>'${filter.column}' IS NOT NULL AND record.data->>'${filter.column}' != '')` ); break; case "is_before": query.andWhere(`(record.data->>'${filter.column}')::timestamp < :${paramName}`, paramObj); break; case "is_after": query.andWhere(`(record.data->>'${filter.column}')::timestamp > :${paramName}`, paramObj); break; case "is_on_or_before": query.andWhere(`(record.data->>'${filter.column}')::timestamp <= :${paramName}`, paramObj); break; case "is_on_or_after": query.andWhere(`(record.data->>'${filter.column}')::timestamp >= :${paramName}`, paramObj); break; case "is_within": if (typeof filter.value === "object") { const interval = this.buildIntervalExpression(filter.value); query.andWhere( `(record.data->>'${filter.column}')::timestamp >= NOW() - INTERVAL '${interval}'` ); } break; } } /** * Apply full-text search */ applySearch(query, searchQuery, searchFields, fields) { const textFields = searchFields.length > 0 ? searchFields : fields.filter( (f) => ["text" /* TEXT */, "long_text" /* LONG_TEXT */, "email" /* EMAIL */].includes(f.type) ).map((f) => f.keyName); if (textFields.length === 0) return; const conditions = textFields.map((field) => `record.data->>'${field}' ILIKE :searchQuery`).join(" OR "); query.andWhere(`(${conditions})`, { searchQuery: `%${searchQuery}%` }); } /** * Apply sorting */ applySort(query, sort, fields) { if (!sort) { query.orderBy("record.created_at", "DESC"); return; } const fieldDef = fields.find((f) => f.keyName === sort.fieldKey); if (fieldDef?.type === "formula" /* FORMULA */ && fieldDef.config.formulaExpression) { const sqlExpression = this.formulaService.parseToSQL(fieldDef.config.formulaExpression); query.orderBy(`(${sqlExpression})`, sort.order); } else { const castType = this.formulaService.getPostgresCastType(fieldDef?.type); query.orderBy(`(record.data->>'${sort.fieldKey}')${castType}`, sort.order); } } /** * Normalize pagination params */ normalizePagination(pagination) { const page = pagination?.page || 1; const limit = Math.min(pagination?.limit || 50, 100); const offset = pagination?.offset ?? (page - 1) * limit; return { page, limit, offset }; } /** * Build interval expression for is_within filter */ buildIntervalExpression(value) { const parts = []; if (value.days) parts.push(`${value.days} days`); if (value.weeks) parts.push(`${value.weeks * 7} days`); if (value.months) parts.push(`${value.months} months`); if (value.years) parts.push(`${value.years} years`); return parts.join(" ") || "0 days"; } /** * Map raw query results to clean response */ mapRawResults(raws) { return raws.map((row) => this.mapSingleRawResult(row)); } /** * Map single raw result */ mapSingleRawResult(row) { const cleanRecord = { id: row.record_id, ...row.record_data, createdAt: row.record_created_at, updatedAt: row.record_updated_at, createdBy: row.record_created_by, updatedBy: row.record_updated_by }; Object.keys(row).forEach((key) => { if (key.startsWith("formula_")) { const realKey = key.replace("formula_", ""); const value = row[key]; cleanRecord[realKey] = value !== null ? parseFloat(value) : null; } }); return cleanRecord; } }; RecordQueryService = __decorateClass([ Injectable6(), __decorateParam(0, InjectRepository6(UsrRecord)) ], RecordQueryService); // src/services/formula.service.ts import { Injectable as Injectable7 } from "@nestjs/common"; var FormulaService = class { /** * Parse formula expression thành SQL expression * Input: "{price} * {qty}" * Output: "COALESCE((record.data->>'price')::numeric, 0) * COALESCE((record.data->>'qty')::numeric, 0)" */ parseToSQL(expression, tableAlias = "record") { return expression.replace(/\{(\w+)\}/g, (_, keyName) => { return `COALESCE((${tableAlias}.data->>'${keyName}')::numeric, 0)`; }); } /** * Parse formula với type casting dựa trên field type */ parseToSQLWithType(expression, fields, tableAlias = "record") { return expression.replace(/\{(\w+)\}/g, (_, keyName) => { const field = fields.find((f) => f.keyName === keyName); const castType = this.getPostgresCastType(field?.type); const defaultValue = this.getDefaultValue(field?.type); return `COALESCE((${tableAlias}.data->>'${keyName}')${castType}, ${defaultValue})`; }); } /** * Validate formula expression */ validate(expression, availableFields) { const errors = []; const fieldPattern = /\{(\w+)\}/g; let match; while ((match = fieldPattern.exec(expression)) !== null) { const fieldName = match[1]; if (!availableFields.includes(fieldName)) { errors.push(`Field "${fieldName}" does not exist`); } } try { const testExpr = expression.replace(/\{(\w+)\}/g, "1"); if (testExpr.includes("{{") || testExpr.includes("}}")) { errors.push("Invalid bracket syntax"); } } catch { errors.push("Invalid formula syntax"); } return { valid: errors.length === 0, errors }; } /** * Extract field references from formula */ extractFieldReferences(expression) { const fieldPattern = /\{(\w+)\}/g; const fields = []; let match; while ((match = fieldPattern.exec(expression)) !== null) { if (!fields.includes(match[1])) { fields.push(match[1]); } } return fields; } /** * Get PostgreSQL cast type for field type */ getPostgresCastType(type) { switch (type) { case "number" /* NUMBER */: case "currency" /* CURRENCY */: case "percent" /* PERCENT */: case "rating" /* RATING */: return "::numeric"; case "boolean" /* BOOLEAN */: return "::boolean"; case "date" /* DATE */: case "datetime" /* DATETIME */: return "::timestamp"; default: return ""; } } /** * Get default value for field type */ getDefaultValue(type) { switch (type) { case "number" /* NUMBER */: case "currency" /* CURRENCY */: case "percent" /* PERCENT */: case "rating" /* RATING */: return "0"; case "boolean" /* BOOLEAN */: return "false"; case "text" /* TEXT */: case "long_text" /* LONG_TEXT */: case "email" /* EMAIL */: case "phone" /* PHONE */: case "url" /* URL */: return "''"; default: return "NULL"; } } /** * Build SQL expression for computed field */ buildComputedFieldSQL(field, tableAlias = "record") { if (field.type !== "formula" /* FORMULA */) { return null; } const expression = field.config.formulaExpression; if (!expression) { return null; } return this.parseToSQL(expression, tableAlias); } }; FormulaService = __decorateClass([ Injectable7() ], FormulaService); // src/services/validation.service.ts import { Injectable as Injectable8 } from "@nestjs/common"; var ValidationService = class { /** * Validate record data against field definitions */ validate(data, fields) { const errors = []; fields.forEach((field) => { const value = data[field.keyName]; const fieldErrors = this.validateField(value, field); errors.push(...fieldErrors); }); return { valid: errors.length === 0, errors }; } /** * Validate single field value */ validateField(value, field) { const errors = []; if (field.isRequired && this.isEmpty(value)) { errors.push({ field: field.keyName, message: `${field.name} is required`, code: "REQUIRED" }); return errors; } if (this.isEmpty(value)) { return errors; } switch (field.type) { case "email" /* EMAIL */: if (!this.isValidEmail(value)) { errors.push({ field: field.keyName, message: `${field.name} must be a valid email address`, code: "INVALID_EMAIL" }); } break; case "url" /* URL */: if (!this.isValidUrl(value, field.config)) { errors.push({ field: field.keyName, message: `${field.name} must be a valid URL`, code: "INVALID_URL" }); } break; case "phone" /* PHONE */: if (!this.isValidPhone(value)) { errors.push({ field: field.keyName, message: `${field.name} must be a valid phone number`, code: "INVALID_PHONE" }); } break; case "number" /* NUMBER */: case "currency" /* CURRENCY */: case "percent" /* PERCENT */: if (!this.isValidNumber(value)) { errors.push({ field: field.keyName, message: `${field.name} must be a valid number`, code: "INVALID_NUMBER" }); } break; case "rating" /* RATING */: const maxRating = field.config.maxRating || 5; if (!this.isValidRating(value, maxRating)) { errors.push({ field: field.keyName, message: `${field.name} must be between 1 and ${maxRating}`, code: "INVALID_RATING" }); } break; case "date" /* DATE */: case "datetime" /* DATETIME */: if (!this.isValidDate(value)) { errors.push({ field: field.keyName, message: `${field.name} must be a valid date`, code: "INVALID_DATE" }); } break; case "select" /* SELECT */: if (!this.isValidSelect(value, field.config)) { errors.push({ field: field.keyName, message: `${field.name} must be one of the allowed options`, code: "INVALID_SELECT" }); } break; case "multi_select" /* MULTI_SELECT */: if (!this.isValidMultiSelect(value, field.config)) { errors.push({ field: field.keyName, message: `${field.name} contains invalid options`, code: "INVALID_MULTI_SELECT" }); } break; case "boolean" /* BOOLEAN */: if (typeof value !== "boolean") { errors.push({ field: field.keyName, message: `${field.name} must be a boolean`, code: "INVALID_BOOLEAN" }); } break; case "long_text" /* LONG_TEXT */: if (field.config.maxLength && String(value).length > field.config.maxLength) { errors.push({ field: field.keyName, message: `${field.name} exceeds maximum length of ${field.config.maxLength}`, code: "MAX_LENGTH_EXCEEDED" }); } break; } return errors; } /** * Check if value is empty */ isEmpty(value) { return value === void 0 || value === null || value === ""; } /** * Validate email format */ isValidEmail(value) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(String(value)); } /** * Validate URL format */ isValidUrl(value, config) { try { const url = new URL(String(value)); if (config.urlType === "image") { return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname); } if (config.urlType === "video") { return /\.(mp4|webm|ogg|mov)$/i.test(url.pathname) || url.hostname.includes("youtube.com") || url.hostname.includes("vimeo.com"); } return true; } catch { return false; } } /** * Validate phone number format */ isValidPhone(value) { const phoneRegex = /^[\d\s\-+()]{7,20}$/; return phoneRegex.test(String(value)); } /** * Validate number */ isValidNumber(value) { return !isNaN(Number(value)) && isFinite(Number(value)); } /** * Validate rating */ isValidRating(value, maxRating) { const num = Number(value); return !isNaN(num) && num >= 0 && num <= maxRating; } /** * Validate date */ isValidDate(value) { const date = new Date(value); return !isNaN(date.getTime()); } /** * Validate select value */ isValidSelect(value, config) { if (!config.options || config.options.length === 0) { return true; } return config.options.some((opt) => opt.id === value || opt.label === value); } /** * Validate multi-select values */ isValidMultiSelect(value, config) { if (!Array.isArray(value)) { return false; } if (!config.options || config.options.length === 0) { return true; } return value.every( (v) => config.options.some((opt) => opt.id === v || opt.label === v) ); } }; ValidationService = __decorateClass([ Injectable8() ], ValidationService); // src/dynamic-table.module.ts var DYNAMIC_TABLE_OPTIONS = "DYNAMIC_TABLE_OPTIONS"; var SERVICES = [ WorkspaceService, TableService, FieldService, ViewService, RecordService, RecordQueryService, FormulaService, ValidationService ]; var DynamicTableModule = class { /** * Register module with synchronous options */ static forRoot(options = {}) { const optionsProvider = { provide: DYNAMIC_TABLE_OPTIONS, useValue: options }; return { module: DynamicTableModule, imports: [ TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES) ], providers: [optionsProvider, ...SERVICES], exports: [optionsProvider, ...SERVICES] }; } /** * Register module with asynchronous options */ static forRootAsync(options) { const asyncProviders = this.createAsyncProviders(options); return { module: DynamicTableModule, imports: [ ...options.imports || [], TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES) ], providers: [...asyncProviders, ...SERVICES], exports: [DYNAMIC_TABLE_OPTIONS, ...SERVICES] }; } /** * Create async providers */ static createAsyncProviders(options) { if (options.useExisting || options.useFactory) { return [this.createAsyncOptionsProvider(options)]; } if (options.useClass) { return [ this.createAsyncOptionsProvider(options), { provide: options.useClass, useClass: options.useClass } ]; } return [this.createAsyncOptionsProvider(options)]; } /** * Create async options provider */ static createAsyncOptionsProvider(options) { if (options.useFactory) { return { provide: DYNAMIC_TABLE_OPTIONS, useFactory: options.useFactory, inject: options.inject || [] }; } const inject = options.useExisting || options.useClass; return { provide: DYNAMIC_TABLE_OPTIONS, useFactory: async (optionsFactory) => optionsFactory.createDynamicTableOptions(), inject: inject ? [inject] : [] }; } }; DynamicTableModule = __decorateClass([ Global(), Module({}) ], DynamicTableModule); export { COMPUTED_FIELD_TYPES, DYNAMIC_TABLE_ENTITIES, DYNAMIC_TABLE_OPTIONS, DynamicTableModule, FIELD_TYPE_METADATA, FieldService, FieldType, FormulaService, RecordQueryService, RecordService, SysField, SysTable, SysView, SysWorkspace, TableService, UsrRecord, UsrRecordHistory, ValidationService, ViewService, WorkspaceService }; //# sourceMappingURL=index.mjs.map