diff --git a/apps/backend/.adonisjs/server/routes.d.ts b/apps/backend/.adonisjs/server/routes.d.ts index 1b5cef7..e2161f1 100644 --- a/apps/backend/.adonisjs/server/routes.d.ts +++ b/apps/backend/.adonisjs/server/routes.d.ts @@ -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} } diff --git a/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts b/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts index 745c3df..f9e11ec 100644 --- a/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts +++ b/apps/backend/app/base_modules/CustomField/controller/custom_fields_controller.ts @@ -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 }) + } } diff --git a/apps/backend/app/base_modules/CustomField/routes.ts b/apps/backend/app/base_modules/CustomField/routes.ts index 85d54c2..fe6c4bc 100644 --- a/apps/backend/app/base_modules/CustomField/routes.ts +++ b/apps/backend/app/base_modules/CustomField/routes.ts @@ -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()]) diff --git a/apps/backend/app/base_modules/CustomField/validators/custom_field_create_validator.ts b/apps/backend/app/base_modules/CustomField/validators/custom_field_create_validator.ts new file mode 100644 index 0000000..8dce853 --- /dev/null +++ b/apps/backend/app/base_modules/CustomField/validators/custom_field_create_validator.ts @@ -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(), + }) +) diff --git a/apps/backend/database/schema.ts b/apps/backend/database/schema.ts index f46a57c..0ae410e 100644 --- a/apps/backend/database/schema.ts +++ b/apps/backend/database/schema.ts @@ -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 diff --git a/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts b/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts index 1f31b83..e901055 100644 --- a/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts +++ b/apps/backend/tests/functional/custom_fields/custom_fields_api.spec.ts @@ -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) + }) }) diff --git a/docs/modules/Readme.md b/docs/modules/Readme.md index e43f0af..ce1eb20 100644 --- a/docs/modules/Readme.md +++ b/docs/modules/Readme.md @@ -2,6 +2,6 @@ Documentation for project-specific modules -| Module | Backend | Frontend | Documentation | -| ------ | ------- | -------- | ------------- | -| - | - | - | - | +| 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) |