This commit is contained in:
2026-09-04 13:46:18 +03:00
parent f5c9132422
commit de5a6ed3a2
7 changed files with 181 additions and 3 deletions
+20
View File
@@ -14,6 +14,13 @@ export type ScannedRoutes = {
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'auth.user': { paramsTuple?: []; params?: {} }
'contacts.index': { paramsTuple?: []; params?: {} }
'contacts.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'contacts.store': { paramsTuple?: []; params?: {} }
'contacts.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'contacts.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'custom_fields.index': { paramsTuple?: []; params?: {} }
'custom_fields.store': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.rename': { paramsTuple?: []; params?: {} }
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
@@ -34,6 +41,7 @@ export type ScannedRoutes = {
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tenant.current': { paramsTuple?: []; params?: {} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
@@ -59,6 +67,9 @@ export type ScannedRoutes = {
'admin.index': { paramsTuple?: []; params?: {} }
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'auth.user': { paramsTuple?: []; params?: {} }
'contacts.index': { paramsTuple?: []; params?: {} }
'contacts.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'custom_fields.index': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
@@ -68,6 +79,7 @@ export type ScannedRoutes = {
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.index': { paramsTuple?: []; params?: {} }
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tenant.current': { paramsTuple?: []; params?: {} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
@@ -85,6 +97,9 @@ export type ScannedRoutes = {
'admin.index': { paramsTuple?: []; params?: {} }
'admin.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'auth.user': { paramsTuple?: []; params?: {} }
'contacts.index': { paramsTuple?: []; params?: {} }
'contacts.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'custom_fields.index': { paramsTuple?: []; params?: {} }
'root': { paramsTuple?: []; params?: {} }
'file_manager.raw': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'file_manager.show': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
@@ -94,6 +109,7 @@ export type ScannedRoutes = {
'permission.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.index': { paramsTuple?: []; params?: {} }
'role.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tenant.current': { paramsTuple?: []; params?: {} }
'public_web_manager.blog_list': { paramsTuple?: []; params?: {} }
'public_web_manager.sitemap': { paramsTuple?: []; params?: {} }
'public_web_manager.resolve': { paramsTuple?: []; params?: {} }
@@ -109,6 +125,7 @@ export type ScannedRoutes = {
PUT: {
'admin.update_profile': { paramsTuple?: []; params?: {} }
'admin.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'contacts.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'file_manager.rename': { paramsTuple?: []; params?: {} }
'permission.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -119,6 +136,8 @@ export type ScannedRoutes = {
'admin.store': { paramsTuple?: []; params?: {} }
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'contacts.store': { paramsTuple?: []; params?: {} }
'custom_fields.store': { paramsTuple?: []; params?: {} }
'file_manager.create_folder': { paramsTuple?: []; params?: {} }
'file_manager.upload': { paramsTuple?: []; params?: {} }
'file_manager.duplicate': { paramsTuple?: []; params?: {} }
@@ -137,6 +156,7 @@ export type ScannedRoutes = {
}
DELETE: {
'admin.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'contacts.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'file_manager.destroy': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
'permission.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'role.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -1,5 +1,6 @@
import type { HttpContext } from '@adonisjs/core/http'
import CustomField from '#base_modules/CustomField/model/custom_field'
import { createCustomFieldValidator } from '#base_modules/CustomField/validators/custom_field_create_validator'
export default class CustomFieldsController {
/**
@@ -13,4 +14,27 @@ export default class CustomFieldsController {
const fields = await CustomField.query().where('tenant_id', tenant.id).orderBy('id', 'asc')
return response.ok({ data: fields })
}
/**
* @summary Create custom-field metadata for contacts in the current tenant
* @requestBody {"entity_type":"contact","name":"Customer tier","key":"customer_tier","field_type":"select","required":true,"options":{"values":["bronze","silver","gold"]}}
* @responseBody 201 - {"data":{"id":1,"entity_type":"contact","name":"Customer tier","key":"customer_tier","field_type":"select","required":true,"options":{"values":["bronze","silver","gold"]},"status":"active"}}
* @responseBody 401 - {"message":"Unauthorized"}
* @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"}
* @responseBody 422 - {"errors":[{"message":"The entity_type field must be contact","rule":"literal","field":"entity_type"}]}
*/
public async store({ request, response, tenant }: HttpContext) {
const input = await createCustomFieldValidator.validate(request.all())
const field = await CustomField.create({
tenantId: tenant.id,
entityType: input.entity_type,
name: input.name,
key: input.key,
fieldType: input.field_type,
required: input.required ?? false,
options: input.options ?? null,
status: 'active',
})
return response.created({ data: field })
}
}
@@ -7,6 +7,7 @@ const CustomFieldsController = () =>
router
.group(() => {
router.get('/', [CustomFieldsController, 'index'])
router.post('/', [CustomFieldsController, 'store'])
})
.prefix('/api/custom-fields')
.use([middleware.auth(), middleware.tenant()])
@@ -0,0 +1,17 @@
import vine from '@vinejs/vine'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
export const createCustomFieldValidator = vine.compile(
vine.object({
entity_type: vine.literal('contact'),
name: vine.string().trim().minLength(1),
key: vine
.string()
.trim()
.minLength(1)
.regex(/^[a-z][a-z0-9_]*$/),
field_type: vine.enum(['text', 'number', 'boolean', 'date', 'select'] as const),
required: vine.boolean().optional(),
options: jsonObjectSchema.optional(),
})
)
+80
View File
@@ -22,6 +22,21 @@ export class AdminRoleSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class AdminTenantSchema extends BaseModel {
static $columns = ['adminId', 'createdAt', 'id', 'tenantId', 'updatedAt'] as const
$columns = AdminTenantSchema.$columns
@column()
declare adminId: number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare tenantId: number
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class AdminSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'firstName', 'id', 'lastName', 'password', 'status', 'updatedAt'] as const
$columns = AdminSchema.$columns
@@ -68,6 +83,54 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class ContactSchema extends BaseModel {
static $columns = ['createdAt', 'displayName', 'email', 'id', 'phone', 'status', 'tenantId', 'updatedAt'] as const
$columns = ContactSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare displayName: string
@column()
declare email: string | null
@column({ isPrimary: true })
declare id: number
@column()
declare phone: string | null
@column()
declare status: string
@column()
declare tenantId: number
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class CustomFieldSchema extends BaseModel {
static $columns = ['createdAt', 'entityType', 'fieldType', 'id', 'key', 'name', 'options', 'required', 'status', 'tenantId', 'updatedAt'] as const
$columns = CustomFieldSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare entityType: string
@column()
declare fieldType: string
@column({ isPrimary: true })
declare id: number
@column()
declare key: string
@column()
declare name: string
@column()
declare options: any | null
@column()
declare required: boolean
@column()
declare status: string
@column()
declare tenantId: number
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
export class PermissionSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'name', 'slug', 'updatedAt'] as const
$columns = PermissionSchema.$columns
@@ -111,6 +174,23 @@ export class RoleSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class TenantSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'name', 'slug', 'status', 'updatedAt'] as const
$columns = TenantSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare slug: string
@column()
declare status: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class WebComponentSchema extends BaseModel {
static $columns = ['createdAt', 'data', 'id', 'pageId', 'sortOrder', 'type', 'updatedAt'] as const
$columns = WebComponentSchema.$columns
@@ -40,4 +40,40 @@ test.group('Custom fields API', (group) => {
response.assertStatus(200)
assert.equal(response.body().data[0].key, 'customer_tier')
})
test('creates a contact field owned by the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Create Metadata GmbH',
slug: 'create-metadata-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const response = await client
.post('/api/custom-fields')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
.json({
entity_type: 'contact',
name: 'Customer tier',
key: 'customer_tier',
field_type: 'select',
required: true,
options: { values: ['bronze', 'silver', 'gold'] },
})
response.assertStatus(201)
assert.deepInclude(response.body().data, {
entity_type: 'contact',
name: 'Customer tier',
key: 'customer_tier',
field_type: 'select',
required: true,
options: { values: ['bronze', 'silver', 'gold'] },
status: 'active',
})
const created = await CustomField.find(response.body().data.id)
assert.equal(created?.tenantId, tenant.id)
})
})
+2 -2
View File
@@ -3,5 +3,5 @@
Documentation for project-specific modules
| Module | Backend | Frontend | Documentation |
| ------ | ------- | -------- | ------------- |
| - | - | - | - |
| ------------- | ---------------------------------------------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Custom Fields | `GET`/`POST` `/api/custom-fields` — authenticated, tenant-scoped metadata for contact dynamic fields | Not yet implemented | [ERP foundation next steps](../../.hermes/plans/2026-09-01_144533-erp-foundation-next-steps.md#step-5-implement-metadata-and-custom-fields-configuration) |