import { HttpContext } from '@adonisjs/core/http' import { join, dirname } from 'node:path' import { mkdirSync, writeFileSync, readFileSync, existsSync, cpSync } from 'node:fs' import { modelToSchema } from '#services/swagger_model' import handlebars from 'handlebars' import { getDatabaseTables, getColumnsForTable, getForeignKeysReferencing, } from '#services/database_inspector' handlebars.registerHelper('eq', (a, b) => a === b) handlebars.registerHelper('not', (v) => !v) handlebars.registerHelper('or', (a, b) => a || b) handlebars.registerHelper('and', (a, b) => a && b) function renderTemplate(templatePath: string, context: any): string { const raw = readFileSync(templatePath, 'utf8') const template = handlebars.compile(raw) return template(context) } export default class MakeModuleController { capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1) } camelCase(str: string) { return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) } pascalCase(str: string) { return this.capitalize(this.camelCase(str)) } snakeCase(str: string) { return str.slice(0, str.length - 1) } mapDBTypeToTs(type: string): string { if (type.includes('int')) return 'number' if (type === 'boolean') return 'boolean' if (type.includes('timestamp') || type === 'date') return 'DateTime' return 'string' } mapDBTypeToValidator(type: string): string { if ( type.includes('int') || type === 'numeric' || type === 'decimal' || type === 'real' || type === 'double precision' ) { return 'number' } if (type === 'boolean') return 'boolean' if (type.includes('timestamp') || type === 'date') return 'date' return 'string' } mapDBTypeToFrontend(type: string): string { if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number' if (type === 'boolean') return 'boolean' if (type.includes('timestamp') || type === 'date') return 'IsoDateString' return 'string' } async generateRelationships(table: string) { const lines: string[] = [] const imports: Set = new Set() const columns = await getColumnsForTable(table) const references = await getForeignKeysReferencing(table) for (const col of columns) { if (col.column_name.endsWith('_id')) { const refTable = col.column_name.replace('_id', '') const refModel = this.capitalize(refTable) imports.add('belongsTo') lines.push( ` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo` ) } } for (const ref of references) { const relModel = this.capitalize(ref.table_name) imports.add('hasMany') lines.push( ` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany` ) } return { imports: Array.from(imports), code: lines, } } async tables({ response }: HttpContext) { const tables = await getDatabaseTables() return response.ok(tables) } async generate({ request, response }: HttpContext) { const { name, table, generate, form, formDefinition, dataTable, list, formDialog, menu, module, } = request.only([ 'name', 'table', 'generate', 'form', 'formDefinition', 'dataTable', 'list', 'formDialog', 'menu', 'module', ]) const appBase = process.cwd() const backendTplDir = join(appBase, 'app/templates/backend') const apiTplDir = join(appBase, 'app/templates/backend/api') const moduleName = this.capitalize(name) const modulePath = join(appBase, 'modules', moduleName) const snake = this.snakeCase(table) // Backend and admin are sibling workspaces under apps/. const clientBase = join(appBase, '..', 'admin', 'src', 'modules', moduleName) const templateModuleDir = join(appBase, '..', 'admin', 'src', 'modules', '_template') const paths = { // backend controller: join(modulePath, 'controller', `${snake}_controller.ts`), model: join(modulePath, 'model', `${snake}.ts`), // validatorCreate: join('app', 'validators', `${snake}_create_validator.ts`), // validatorUpdate: join('app', 'validators', `${snake}_update_validator.ts`), validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`), validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`), routes: join(modulePath, 'routes.ts'), policy: join(modulePath, 'policy', `${snake}_policy.ts`), policiesMain: join(appBase, 'app', 'policies', 'main.ts'), permissionsConfig: join(modulePath, 'config', 'permissions.ts'), // API subtree apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`), // apiValidatorCreate: join('app', 'validators', `${snake}_create_api_validator.ts`), // apiValidatorUpdate: join('app', 'validators', `${snake}_update_api_validator.ts`), apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`), apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`), apiRoutes: join(modulePath, 'api', 'routes.ts'), // tests testController: join( appBase, 'tests', 'modules', moduleName, `${this.snakeCase(table)}.spec.ts` ), testApi: join( appBase, 'tests', 'modules', moduleName, `${this.snakeCase(table)}.api.spec.ts` ), // frontend clientModule: clientBase, clientForm: join(clientBase, 'view', 'forms', 'MainForm', 'Component.tsx'), clientFormDefinition: join(clientBase, 'view', 'forms', 'MainForm', 'form.ts'), clientDatatable: join(clientBase, 'view', 'widgets', 'DataTable.tsx'), clientList: join(clientBase, 'pages', 'ListPage', 'index.tsx'), clientDialog: join(clientBase, 'view', 'widgets', 'ManageTrigger.tsx'), clientConfig: join(clientBase, 'config', 'meta.ts'), clientMenu: join(clientBase, 'config', 'menu.ts'), clientModuleConfig: join(clientBase, 'config', 'module.ts'), clientEntity: join(clientBase, 'entities', 'Entity.ts'), } if (existsSync(templateModuleDir)) { cpSync(templateModuleDir, paths.clientModule, { recursive: true }) } Object.values(paths).forEach((filePath) => { mkdirSync(dirname(filePath), { recursive: true }) }) const columns = await getColumnsForTable(table) const modelClass = this.capitalize(table) const relationship = await this.generateRelationships(table) const enhancedColumns = columns.map((col) => { const colName = col.column_name const camelName = this.camelCase(colName) const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '') return { name: colName, camelCase: camelName, type, isPrimary: colName === 'id', isPassword: colName === 'password', isEmail: colName === 'email', isDateTime: ['created_at', 'updated_at'].includes(colName), isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName), isRequired: col.is_nullable === 'NO', isNullable: col.is_nullable === 'YES', validatorType: this.mapDBTypeToValidator(col.data_type), needsTrim: this.mapDBTypeToValidator(col.data_type) === 'string' && colName !== 'email' && colName !== 'password', } }) const sampleFor = (col: any) => { const t = col.validatorType if (t === 'number') return 1 if (t === 'boolean') return false if (t === 'date') return `'${new Date().toISOString()}'` return `'sample_${col.camelCase}'` } const createFields = enhancedColumns .filter((c) => !c.isPrimary && !c.isSpecial) .map((c) => ({ camelCase: c.camelCase, sampleValue: sampleFor(c), column_name: c.name, })) const updateFields = enhancedColumns .filter((c) => !c.isPrimary && !c.isDateTime) .map((c) => ({ camelCase: c.camelCase, sampleValue: sampleFor(c), column_name: c.name, })) // minimal insert json for show/update/destroy fixtures const insertJson = `{ ${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')} }` const frontendFields = columns.map((col) => { return { name: col.column_name, type: this.mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''), } }) // FRONTEND writer writeFileSync(paths.clientForm, form ?? '', 'utf8') writeFileSync(paths.clientFormDefinition, formDefinition ?? '', 'utf8') writeFileSync(paths.clientDatatable, dataTable ?? '', 'utf8') writeFileSync(paths.clientList, list ?? '', 'utf8') writeFileSync(paths.clientDialog, formDialog ?? '', 'utf8') writeFileSync(paths.clientMenu, menu ?? '', 'utf8') writeFileSync(paths.clientModuleConfig, module ?? '', 'utf8') const configContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'config.hbs'), { name, }) writeFileSync(paths.clientConfig, configContent) const entityContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'entity.hbs'), { fields: frontendFields, }) writeFileSync(paths.clientEntity, entityContent) // BACKEND: model if (generate.includes('model')) { const content = renderTemplate(join(backendTplDir, 'model.hbs'), { name, pascalCaseName: this.pascalCase(name), capName: this.capitalize(name), className: modelClass, imports: relationship.imports.join(', '), columns: enhancedColumns, relationships: relationship.code, hasPassword: columns.some((col) => col.column_name === 'password'), }) await writeFileSync(paths.model, content) } // BACKEND: controller if (generate.includes('controller')) { const content = renderTemplate(join(backendTplDir, 'controller.hbs'), { pascalCaseName: this.pascalCase(name), modelName: modelClass, capName: this.capitalize(name), moduleName, tableName: table, snakeName: snake, name, }) writeFileSync(paths.controller, content) const testsTplDir = join(backendTplDir, 'tests') // controller tests const ctrlSpec = renderTemplate(join(testsTplDir, 'controller.spec.hbs'), { pascalCaseName: this.pascalCase(name), snakeCaseName: this.snakeCase(table), tableName: table, createFields, updateFields, insertJson, }) mkdirSync(dirname(paths.testController), { recursive: true }) writeFileSync(paths.testController, ctrlSpec, 'utf8') } // BACKEND: routes if (generate.includes('routes')) { const content = renderTemplate(join(backendTplDir, 'routes.hbs'), { name, capName: this.capitalize(name), className: moduleName, routeName: name, snakeCaseName: snake, }) writeFileSync(paths.routes, content) } // BACKEND: validators if (generate.includes('validator')) { const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), { className: moduleName, functionName: `create${moduleName}Validator`, fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial), }) writeFileSync(paths.validatorCreate, createContent) const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), { className: moduleName, functionName: `update${moduleName}Validator`, fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime), }) writeFileSync(paths.validatorUpdate, updateContent) } // BACKEND: policy + permissions if (generate.includes('policy')) { const policyTpl = join(backendTplDir, 'policy.hbs') const base = name.toLowerCase() const slugBase = base.endsWith('s') ? base : `${base}s` const policyContent = renderTemplate(policyTpl, { name, slugBase, moduleName, modelName: modelClass, pascalCaseName: this.pascalCase(name), }) writeFileSync(paths.policy, policyContent) if (!existsSync(paths.policiesMain)) { mkdirSync(dirname(paths.policiesMain), { recursive: true }) writeFileSync(paths.policiesMain, `export const policies = {}\n`) } const capName = this.capitalize(name) const key = `${modelClass}Policy` const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),` const current = readFileSync(paths.policiesMain, 'utf8') if (current.includes('export const policies =')) { if (!current.includes(importLine)) { const updated = current.replace( /export const policies\s*=\s*\{\s*/m, (m) => m + '\n' + importLine + '\n' ) writeFileSync(paths.policiesMain, updated) } } else { writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`) } const labelBase = this.capitalize(name.endsWith('s') ? name.slice(0, -1) : name) const perms = [ { action: 'view', slug: `${name}s.view`, name: `${labelBase} view` }, { action: 'create', slug: `${name}s.create`, name: `${labelBase} create` }, { action: 'update', slug: `${name}s.update`, name: `${labelBase} update` }, { action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` }, ] const fileContents = 'export default [\n' + perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') + '\n]\n' if (!existsSync(paths.permissionsConfig)) { mkdirSync(dirname(paths.permissionsConfig), { recursive: true }) writeFileSync(paths.permissionsConfig, fileContents, 'utf8') } } // API subtree if (generate.includes('api')) { const { default: genModel } = await import(paths.model) const test = await modelToSchema(genModel) console.warn(test) const apiCtx = { pascalCaseName: this.pascalCase(name), modelName: modelClass, capName: this.capitalize(name), moduleName, tableName: table, snakeCaseName: snake, name, snake, swaggerModel: await modelToSchema(genModel), } // Controller const apiControllerTpl = join(apiTplDir, 'controller.hbs') const apiControllerContent = renderTemplate(apiControllerTpl, apiCtx) writeFileSync(paths.apiController, apiControllerContent, 'utf8') // test const testsTplDir = join(backendTplDir, 'tests') const apiSpec = renderTemplate(join(testsTplDir, 'api.spec.hbs'), { snakeCaseName: this.snakeCase(table), createFields, }) writeFileSync(paths.testApi, apiSpec, 'utf8') // Validators const apiValidatorTpl = join(apiTplDir, 'validator.hbs') const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, { ...apiCtx, className: moduleName, functionName: `create${moduleName}ApiValidator`, fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial), }) writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8') const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, { ...apiCtx, className: moduleName, functionName: `update${moduleName}ApiValidator`, fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime), }) writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8') // Routes const apiRoutesTpl = join(apiTplDir, 'routes.hbs') const apiRoutesContent = renderTemplate(apiRoutesTpl, apiCtx) writeFileSync(paths.apiRoutes, apiRoutesContent, 'utf8') } return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` }) } public async columns({ request, response }: HttpContext) { const { table } = request.params() const columns = await getColumnsForTable(table) const enhancedColumns = columns.map((col) => { const colName = col.column_name const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '') return { name: colName, type, isDateTime: ['created_at', 'updated_at'].includes(colName), } }) return response.ok({ columns: enhancedColumns }) } }