Reorganize app workspaces and align Admin architecture

This commit is contained in:
Igor Martyniuk
2026-07-17 13:46:21 +00:00
parent ba691d3a0c
commit 0ba01cd111
183 changed files with 3745 additions and 4354 deletions
+103
View File
@@ -0,0 +1,103 @@
import type { HttpContext } from '@adonisjs/core/http'
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeCaseName}}'
import { create{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_create_api_validator'
import { update{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_update_api_validator'
export default class {{pascalCaseName}}ApiController {
/**
* GET /api/{{name}}
*/
/**
* @index
* @operationId get{{pascalCaseName}}
* @description Returns array of records
* @responseBody 200 - [{{{swaggerModel}}}]
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async index({ response }: HttpContext) {
const result = await {{pascalCaseName}}.all()
return response.ok(result)
}
/**
* GET /api/{{name}}/:id
*/
/**
* @show
* @operationId show{{pascalCaseName}}
* @description Returns record
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async show({ params, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
return response.ok(record)
}
/**
* POST /api/{{name}}
*/
/**
* @store
* @operationId store{{pascalCaseName}}
* @description Creates and Returns record
* @requestBody <create{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async store({ request, response }: HttpContext) {
const payload = await create{{pascalCaseName}}ApiValidator.validate(request.all())
const record = await {{pascalCaseName}}.create(payload)
return response.created(record)
}
/**
* PUT /api/{{name}}/:id
*/
/**
* @update
* @operationId update{{pascalCaseName}}
* @description Updates and Returns record
* @requestBody <update{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async update({ params, request, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
const payload = await update{{pascalCaseName}}ApiValidator.validate(request.all())
record.merge(payload)
await record.save()
return response.ok(record)
}
/**
* DELETE /api/{{name}}/:id
*/
/**
* @destroy
* @operationId destroy{{pascalCaseName}}
* @description Destroys record
* @paramPath id - The ID of the source - @required
* @responseBody 200 - { "message": "Deleted" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async destroy({ params, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
await record.delete()
return response.ok({ message: 'Deleted' })
}
}
+12
View File
@@ -0,0 +1,12 @@
import router from '@adonisjs/core/services/router'
const {{pascalCaseName}}ApiController = () => import('#modules/{{moduleName}}/api/controllers/{{snakeCaseName}}_api_controller')
router
.group(() => {
router.get('/', [{{pascalCaseName}}ApiController, 'index'])
router.get('/:id', [{{pascalCaseName}}ApiController, 'show'])
router.post('/', [{{pascalCaseName}}ApiController, 'store'])
router.put('/:id', [{{pascalCaseName}}ApiController, 'update'])
router.delete('/:id', [{{pascalCaseName}}ApiController, 'destroy'])
})
.prefix('/api/{{snakeCaseName}}')
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const {{functionName}} = vine.compile(
vine.object({
id: vine.number().optional(),
{{#each fields}}
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
{{/each}}
})
)
+81
View File
@@ -0,0 +1,81 @@
import { HttpContext } from '@adonisjs/core/http'
import { create{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_create_validator'
import { update{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_update_validator'
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeName}}'
import {{pascalCaseName}}Policy from '#modules/{{capName}}/policy/{{snakeName}}_policy'
export default class {{pascalCaseName}}Controller {
public async index({ request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}List')
const {
page = 1,
perPage = 10,
search = null,
sortBy = 'id',
sortOrder = 'desc',
} = request.all()
const query = {{pascalCaseName}}.query()
if (search) {
const columns = {{pascalCaseName}}.searchable || (await searchableColumns('{{tableName}}'))
addSearchFilter(query, columns, search)
}
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
return response.ok(result.toJSON())
}
public async store({ request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('create{{pascalCaseName}}')
const data = await create{{capName}}Validator.validate(request.all())
const record = await {{pascalCaseName}}.create(data)
if (record) {
return response.created(record.toJSON())
}
return response.badRequest({ message: 'Cannot create.' })
}
public async show({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}')
return response.ok(record.toJSON())
}
public async update({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const data = await update{{capName}}Validator.validate(request.all())
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('update{{pascalCaseName}}')
record.merge(data)
await record.save()
return response.ok(record.toJSON())
}
public async destroy({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('delete{{pascalCaseName}}')
await {{pascalCaseName}}.query().where('id', id).delete()
return response.ok({ message: 'Deleted.' })
}
}
+40
View File
@@ -0,0 +1,40 @@
import { BaseModel, column{{#if imports}}, {{imports}}{{/if}} } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
{{#if hasPassword}}
import hash from '@adonisjs/core/services/hash'
import { beforeSave } from '@adonisjs/lucid/orm'
{{/if}}
export default class {{pascalCaseName}} extends BaseModel {
static searchable = {
string: [{{#each columns}}{{#if (and (eq validatorType "string") (not isPassword))}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
number: [{{#each columns}}{{#if (eq validatorType "number")}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
}
{{#each columns}}
{{#if isPrimary}}
@column({ isPrimary: true, columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: {{type}}
{{else if isDateTime}}
@column.dateTime({ autoCreate: true{{#if (eq name "updated_at")}}, autoUpdate: true{{/if}}, columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: DateTime | null
{{else}}
@column({ columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: {{type}}
{{/if}}
{{/each}}
{{#each relationships}}
{{{this}}}
{{/each}}
{{#if hasPassword}}
@beforeSave()
static async hashPassword(model: {{className}}) {
if (model.$dirty.password) {
model.password = await hash.make(model.password)
}
}
{{/if}}
}
+36
View File
@@ -0,0 +1,36 @@
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
import { getAdminPermissionSlugs } from '#services/permission_service'
import Admins from '#base_modules/Admin/model/admin'
export default class {{pascalCaseName}}Policy extends BasePolicy {
async view{{pascalCaseName}}List(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.view')
}
async view{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.view')
}
async create{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.create')
}
async update{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.update')
}
async delete{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.delete')
}
}
+9
View File
@@ -0,0 +1,9 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const {{name}}Controller = () => import('#modules/{{capName}}/controller/{{snakeCaseName}}_controller')
router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use('*', [middleware.auth()])
// If you prefer only mutating actions protected, use this instead:
// router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use(['store', 'update', 'destroy'], [middleware.auth()])
@@ -0,0 +1,24 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
test.group('API /api/{{snakeCaseName}}', (group) => {
group.each.setup(async () => {
await db.beginGlobalTransaction()
return () => db.rollbackGlobalTransaction()
})
test('GET /api/{{snakeCaseName}} -> list', async ({ client }) => {
const res = await client.get('/api/{{snakeCaseName}}')
res.assertStatus(200)
})
test('POST /api/{{snakeCaseName}} -> create', async ({ client }) => {
const payload = {
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client.post('/api/{{snakeCaseName}}').json(payload)
res.assertStatus(201)
})
})
@@ -0,0 +1,106 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import { loginAsAdmin } from '../../utils/auth.js'
test.group('{{pascalCaseName}}Controller', (group) => {
group.each.setup(async () => {
await db.beginGlobalTransaction()
return () => db.rollbackGlobalTransaction()
})
test('index: lists paginated {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const res = await client.get('/{{snakeCaseName}}').header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
assert.exists(res.body().meta, 'pagination meta exists')
})
test('store: creates a new {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const payload = {
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client
.post('/{{snakeCaseName}}')
.header('Authorization', `Bearer ${token}`)
.json(payload)
res.assertStatus(201)
const body = res.body()
{{#each createFields}}
assert.property(body, '{{column_name}}')
{{/each}}
})
test('show: returns one {{snakeCaseName}} by id', async ({ client }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const res = await client
.get(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
})
test('update: updates {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const patch = {
{{#each updateFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client
.put(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
.json(patch)
res.assertStatus(200)
const body = res.body()
{{#each updateFields}}
assert.property(body, '{{column_name}}')
{{/each}}
})
test('destroy: deletes {{snakeCaseName}}', async ({ client }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const res = await client
.delete(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
})
})
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const {{functionName}} = vine.compile(
vine.object({
id: vine.number().optional(),
{{#each fields}}
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
{{/each}}
})
)
+9
View File
@@ -0,0 +1,9 @@
export const MODULE_NAME = '{{name}}'
export const MODULE_NAME_LABEL = MODULE_NAME.replace(/^./, (char) => char.toUpperCase()); // prettier-ignore
export const MODULE_API_ENDPOINT = '/' + MODULE_NAME
export const MODULE_ROOT_PATH = '/' + MODULE_NAME
export const MODULE_PERMISSION_ROOT = MODULE_NAME + 's'
export const MODULE_PERMISSION_CREATE = MODULE_PERMISSION_ROOT + '.create'
export const MODULE_PERMISSION_UPDATE = MODULE_PERMISSION_ROOT + '.update'
export const MODULE_PERMISSION_DELETE = MODULE_PERMISSION_ROOT + '.delete'
export const MODULE_PERMISSION_VIEW = MODULE_PERMISSION_ROOT + '.view'
+1
View File
@@ -0,0 +1 @@
{{detaTable}}
@@ -0,0 +1,7 @@
import type { IsoDateString } from '@crux-kit/types';
export interface Entity {
{{#each fields}}
{{name}}: {{type}}
{{/each}}
}
+1
View File
@@ -0,0 +1 @@
{{form}}