TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+37 -461
View File
@@ -1,483 +1,59 @@
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 } from '#services/database_inspector'
import { mapDBTypeToTs } from '#services/module_generator/db_types'
import { generateModule } from '#services/module_generator/generate_module'
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)
}
ModuleAsset,
ModuleLayoutConfig,
ModuleTableConfig,
} from '#services/module_generator/types'
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<string> = 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<typeof ${refModel}>`
)
}
}
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<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
async tables({ response }: HttpContext) {
const tables = await getDatabaseTables()
return response.ok(tables)
}
async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
return response.ok({
columns: columns.map((col) => ({
name: col.column_name,
type: mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
isDateTime: ['created_at', 'updated_at'].includes(col.column_name),
})),
})
}
async generate({ request, response }: HttpContext) {
const {
name,
table,
generate,
form,
formDefinition,
dataTable,
list,
formDialog,
menu,
module,
} = request.only([
const { name, table, generate, form, datatable } = 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'),
'datatable',
]) as {
name: string
table: string
generate: ModuleAsset[]
form: ModuleLayoutConfig | null
datatable: ModuleTableConfig | null
}
if (existsSync(templateModuleDir)) {
cpSync(templateModuleDir, paths.clientModule, { recursive: true })
if (typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(name)) {
return response.unprocessableEntity({ message: 'Invalid module name' })
}
if (typeof table !== 'string' || !/^[a-z_][a-z0-9_]*$/i.test(table)) {
return response.unprocessableEntity({ message: 'Invalid table name' })
}
if (!Array.isArray(generate)) {
return response.unprocessableEntity({ message: 'Invalid generate list' })
}
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')
}
const moduleName = await generateModule({ name, table, generate, form, datatable })
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 })
}
}