Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.10% covered (success)
96.10%
320 / 333
69.57% covered (warning)
69.57%
16 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleFieldsApiController
96.08% covered (success)
96.08%
319 / 332
69.57% covered (warning)
69.57%
16 / 23
93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
91.11% covered (success)
91.11%
41 / 45
0.00% covered (danger)
0.00%
0 / 1
5.02
 reorder
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 processReorderPayload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
8
 reorderItems
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 reorderSectionsMap
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
9
 reorderFlatList
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 toggleFieldFlag
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
4
 toggleGlobalSearch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toggleQuickCreate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateDisplayMode
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
5
 create
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 validateCreatePayload
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 fieldKeyExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 insertFieldRecord
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
12
 resolveTargetModuleAlias
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveNextSortOrder
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 resolveRelationMetadata
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 resolveHiddenViews
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
8.12
 parseJsonBody
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 invalidateCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonResponse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
13use App\Shared\Infrastructure\Http\ApiResponseTrait;
14use Nyholm\Psr7\Factory\Psr17Factory;
15use PDO;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18
19/**
20 * Module Fields Management REST and HTMX API Controller.
21 *
22 * Provides endpoints for managing module field definitions (list, SortableJS reordering,
23 * and deleting non-system fields).
24 *
25 * Handled routes:
26 *   GET    /htmx/engine/system_modules/{id}/fields          -> list()
27 *   POST   /htmx/engine/system_modules/{id}/fields/reorder  -> reorder()
28 *   DELETE /htmx/engine/system_modules/{id}/fields/{fieldId} -> delete()
29 *
30 * @package App\Core\Engine\Presentation\Api
31 */
32final readonly class ModuleFieldsApiController
33{
34    use ApiResponseTrait;
35
36    private const string ERR_SYSTEM_FIELD = 'System fields cannot be deleted.';
37    private const string ERR_FIELD_NOT_FOUND = 'Field not found.';
38    private const string ERR_INVALID_ORDER = 'Invalid field order payload.';
39    private const string WHERE_ID_AND_MODULE = 'WHERE id = :id AND module_id = :module_id';
40
41    /**
42     * ModuleFieldsApiController constructor.
43     *
44     * @param PDO                                  $pdo          Database connection.
45     * @param Psr17Factory                         $psr17        PSR-17 response factory.
46     * @param MetadataRepositoryInterface|null     $metadataRepo Optional metadata repository for cache invalidation.
47     */
48    public function __construct(
49        private PDO                         $pdo,
50        private Psr17Factory                $psr17,
51        private ?MetadataRepositoryInterface $metadataRepo = null,
52    ) {
53    }
54
55    /**
56     * Lists all fields belonging to a specific module along with section grouping data.
57     *
58     * @param int $moduleId Module primary key.
59     * @return ResponseInterface JSON response with fields and sections data.
60     */
61    public function list(int $moduleId): ResponseInterface
62    {
63        $stmt = $this->pdo->prepare(
64            'SELECT f.id, f.module_id, f.uitype_id, f.field_key, f.label, '
65            . 'f.section_id, s.label AS section_label, '
66            . 'f.column_expression, f.column_expression AS column_name, '
67            . 'f.is_mandatory, f.is_mandatory AS is_required, '
68            . 'f.is_unique, f.is_system, f.is_readonly, '
69            . 'f.is_sortable, f.is_filterable, '
70            . 'f.is_summary, f.is_quick_create, f.is_global_search, '
71            . 'f.sort_order, f.filter_options AS options, f.validation_rules, '
72            . 'f.default_value, f.placeholder, f.hidden_views, '
73            . 'u.name AS uitype_name, u.label AS uitype_label, '
74            . 'u.category AS uitype_category '
75            . 'FROM a_core_field_records f '
76            . 'JOIN a_core_uitype_records u ON u.id = f.uitype_id '
77            . 'LEFT JOIN a_core_section_records s ON s.id = f.section_id '
78            . 'WHERE f.module_id = :module_id '
79            . 'ORDER BY f.sort_order ASC, f.id ASC'
80        );
81        $stmt->execute([':module_id' => $moduleId]);
82        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
83
84        foreach ($rows as &$row) {
85            $rules = [];
86            if (!empty($row['validation_rules'])) {
87                $decoded = json_decode((string) $row['validation_rules'], true);
88                if (is_array($decoded)) {
89                    $rules = $decoded;
90                }
91            }
92            $row['form_display_mode'] = $rules['form_display_mode']
93                ?? $rules['display_mode']
94                ?? (in_array($row['field_key'], ['name', 'subject', 'label', 'title'], true)
95                    ? 'value_only'
96                    : 'icon_label_value');
97        }
98        unset($row);
99
100        $secStmt = $this->pdo->prepare(
101            'SELECT id, name, label, icon_class, sort_order '
102            . 'FROM a_core_section_records '
103            . 'WHERE module_id = :module_id AND is_active = 1 '
104            . 'ORDER BY sort_order ASC, id ASC'
105        );
106        $secStmt->execute([':module_id' => $moduleId]);
107        $sections = $secStmt->fetchAll(PDO::FETCH_ASSOC);
108
109        return $this->jsonResponse([
110            'success'  => true,
111            'data'     => $rows,
112            'sections' => $sections,
113        ]);
114    }
115
116    /**
117     * Updates the sort order and section assignment of fields via SortableJS drag & drop.
118     *
119     * @param ServerRequestInterface $request  PSR-7 request containing ordered fields or sections map.
120     * @param int                    $moduleId Module primary key.
121     * @return ResponseInterface JSON response indicating reorder outcome.
122     */
123    public function reorder(ServerRequestInterface $request, int $moduleId): ResponseInterface
124    {
125        $body = $this->parseJsonBody($request);
126        $items = $body['items'] ?? null;
127        $sectionsMap = $body['sections'] ?? null;
128        /** @var mixed $orderRaw */
129        $orderRaw = $body['order'] ?? $body['field_ids'] ?? [];
130
131        $this->pdo->beginTransaction();
132        try {
133            $updatedCount = $this->processReorderPayload($items, $sectionsMap, $orderRaw, $moduleId);
134            if ($updatedCount === -1) {
135                return $this->jsonResponse(['success' => false, 'error' => self::ERR_INVALID_ORDER], 422);
136            }
137
138            $this->pdo->commit();
139            $this->invalidateCache($moduleId);
140
141            return $this->jsonResponse([
142                'success' => true,
143                'data'    => ['updated_count' => $updatedCount],
144            ]);
145        } catch (\Throwable $e) {
146            if ($this->pdo->inTransaction()) {
147                $this->pdo->rollBack();
148            }
149            return $this->jsonResponse(['success' => false, 'error' => $e->getMessage()], 500);
150        }
151    }
152
153    /**
154     * Dispatches reordering based on available payload structure.
155     */
156    private function processReorderPayload(
157        mixed $items,
158        mixed $sectionsMap,
159        mixed $orderRaw,
160        int $moduleId
161    ): int {
162        return match (true) {
163            is_array($items) && !empty($items) => $this->reorderItems($items, $moduleId),
164            is_array($sectionsMap) && !empty($sectionsMap) => $this->reorderSectionsMap($sectionsMap, $moduleId),
165            is_array($orderRaw) && !empty($orderRaw) => $this->reorderFlatList($orderRaw, $moduleId),
166            default => -1,
167        };
168    }
169
170    /**
171     * Reorders fields by items array.
172     */
173    private function reorderItems(array $items, int $moduleId): int
174    {
175        $stmt = $this->pdo->prepare(
176            'UPDATE a_core_field_records SET sort_order = :sort_order, '
177            . 'section_id = :section_id, updated_at = NOW() '
178            . self::WHERE_ID_AND_MODULE
179        );
180        $updatedCount = 0;
181        foreach ($items as $item) {
182            $fieldId = (int) ($item['id'] ?? 0);
183            $secId = isset($item['section_id']) && $item['section_id'] !== '' && (int) $item['section_id'] > 0
184                ? (int) $item['section_id']
185                : null;
186            $sortOrder = (int) ($item['sort_order'] ?? 10);
187            if ($fieldId > 0) {
188                $stmt->execute([
189                    ':sort_order' => $sortOrder,
190                    ':section_id' => $secId,
191                    ':id'         => $fieldId,
192                    ':module_id'  => $moduleId,
193                ]);
194                $updatedCount++;
195            }
196        }
197        return $updatedCount;
198    }
199
200    /**
201     * Reorders fields by section mapping dictionary.
202     */
203    private function reorderSectionsMap(array $sectionsMap, int $moduleId): int
204    {
205        $stmt = $this->pdo->prepare(
206            'UPDATE a_core_field_records SET sort_order = :sort_order, '
207            . 'section_id = :section_id, updated_at = NOW() '
208            . self::WHERE_ID_AND_MODULE
209        );
210        $updatedCount = 0;
211        foreach ($sectionsMap as $secKey => $fieldIds) {
212            $secId = ($secKey !== '' && $secKey !== '0' && is_numeric($secKey) && (int) $secKey > 0)
213                ? (int) $secKey
214                : null;
215            if (is_array($fieldIds)) {
216                $sortIndex = 10;
217                foreach ($fieldIds as $rawId) {
218                    $fieldId = (int) $rawId;
219                    if ($fieldId > 0) {
220                        $stmt->execute([
221                            ':sort_order' => $sortIndex,
222                            ':section_id' => $secId,
223                            ':id'         => $fieldId,
224                            ':module_id'  => $moduleId,
225                        ]);
226                        $sortIndex += 10;
227                        $updatedCount++;
228                    }
229                }
230            }
231        }
232        return $updatedCount;
233    }
234
235    /**
236     * Reorders flat field IDs list sequentially.
237     */
238    private function reorderFlatList(array $orderRaw, int $moduleId): int
239    {
240        $stmt = $this->pdo->prepare(
241            'UPDATE a_core_field_records SET sort_order = :sort_order, updated_at = NOW() '
242            . self::WHERE_ID_AND_MODULE
243        );
244        $sortIndex = 10;
245        $updatedCount = 0;
246        foreach ($orderRaw as $rawId) {
247            $fieldId = (int) $rawId;
248            if ($fieldId > 0) {
249                $stmt->execute([
250                    ':sort_order' => $sortIndex,
251                    ':id'         => $fieldId,
252                    ':module_id'  => $moduleId,
253                ]);
254                $sortIndex += 10;
255                $updatedCount++;
256            }
257        }
258        return $updatedCount;
259    }
260
261    /**
262     * Deletes a non-system field from a module.
263     *
264     * @param int $moduleId Module primary key.
265     * @param int $fieldId  Field primary key.
266     * @return ResponseInterface JSON response indicating delete outcome.
267     */
268    public function delete(int $moduleId, int $fieldId): ResponseInterface
269    {
270        $stmt = $this->pdo->prepare(
271            'SELECT id, is_system FROM a_core_field_records WHERE id = :id AND module_id = :module_id'
272        );
273        $stmt->execute([':id' => $fieldId, ':module_id' => $moduleId]);
274        $row = $stmt->fetch(PDO::FETCH_ASSOC);
275
276        if (!$row) {
277            return $this->jsonResponse(['success' => false, 'error' => self::ERR_FIELD_NOT_FOUND], 404);
278        }
279
280        if (!empty($row['is_system'])) {
281            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_FIELD], 403);
282        }
283
284        $deleteStmt = $this->pdo->prepare(
285            'DELETE FROM a_core_field_records WHERE id = :id AND module_id = :module_id'
286        );
287        $deleteStmt->execute([':id' => $fieldId, ':module_id' => $moduleId]);
288        $this->invalidateCache($moduleId);
289
290        return $this->jsonResponse([
291            'success' => true,
292            'data'    => ['deleted_id' => $fieldId],
293        ]);
294    }
295
296    /**
297     * Toggles global search availability flag for a specific field.
298     *
299     * @param int $moduleId Module primary key.
300     * @param int $fieldId  Field primary key.
301     * @return ResponseInterface JSON response indicating new toggle state.
302     */
303    /**
304     * Toggles a boolean flag column on a module field record.
305     *
306     * @param int    $moduleId   Module primary key.
307     * @param int    $fieldId    Field primary key.
308     * @param string $columnName Column name to toggle.
309     * @return ResponseInterface JSON response.
310     */
311    private function toggleFieldFlag(int $moduleId, int $fieldId, string $columnName): ResponseInterface
312    {
313        $allowed = ['is_global_search', 'is_quick_create'];
314        if (!in_array($columnName, $allowed, true)) {
315            return $this->jsonResponse(['success' => false, 'error' => 'Invalid column name'], 400);
316        }
317
318        $stmt = $this->pdo->prepare(
319            "SELECT id, {$columnName}, is_system FROM a_core_field_records "
320            . self::WHERE_ID_AND_MODULE
321        );
322        $stmt->execute([':id' => $fieldId, ':module_id' => $moduleId]);
323        /** @var array<string, mixed>|false $row */
324        $row = $stmt->fetch(PDO::FETCH_ASSOC);
325
326        if (!$row) {
327            return $this->jsonResponse(['success' => false, 'error' => self::ERR_FIELD_NOT_FOUND], 404);
328        }
329
330        $newVal = !empty($row[$columnName]) ? 0 : 1;
331        $upd = $this->pdo->prepare(
332            "UPDATE a_core_field_records SET {$columnName} = :val, updated_at = NOW() WHERE id = :id"
333        );
334        $upd->execute([':val' => $newVal, ':id' => $fieldId]);
335
336        $this->invalidateCache($moduleId);
337
338        return $this->jsonResponse([
339            'success' => true,
340            'data'    => [
341                'field_id'   => $fieldId,
342                $columnName  => $newVal,
343            ],
344        ]);
345    }
346
347    /**
348     * Toggles global search availability flag for a specific field.
349     *
350     * @param int $moduleId Module primary key.
351     * @param int $fieldId  Field primary key.
352     * @return ResponseInterface JSON response indicating new toggle state.
353     */
354    public function toggleGlobalSearch(int $moduleId, int $fieldId): ResponseInterface
355    {
356        return $this->toggleFieldFlag($moduleId, $fieldId, 'is_global_search');
357    }
358
359    /**
360     * Toggles quick create availability flag for a specific field.
361     *
362     * @param int $moduleId Module primary key.
363     * @param int $fieldId  Field primary key.
364     * @return ResponseInterface JSON response indicating new toggle state.
365     */
366    public function toggleQuickCreate(int $moduleId, int $fieldId): ResponseInterface
367    {
368        return $this->toggleFieldFlag($moduleId, $fieldId, 'is_quick_create');
369    }
370
371    /**
372     * Updates the form presentation display mode for a specific field.
373     *
374     * @param ServerRequestInterface $request  PSR-7 request containing new 'display_mode'.
375     * @param int                    $moduleId Module primary key.
376     * @param int                    $fieldId  Field primary key.
377     * @return ResponseInterface JSON response indicating new display mode.
378     */
379    public function updateDisplayMode(ServerRequestInterface $request, int $moduleId, int $fieldId): ResponseInterface
380    {
381        $body = $this->parseJsonBody($request);
382        $displayMode = (string) ($body['display_mode'] ?? $body['form_display_mode'] ?? '');
383        $allowedModes = [
384            \App\Core\Engine\Domain\Model\FieldMetadata::DISPLAY_MODE_ICON_LABEL_VALUE,
385            \App\Core\Engine\Domain\Model\FieldMetadata::DISPLAY_MODE_VALUE_ONLY,
386            \App\Core\Engine\Domain\Model\FieldMetadata::DISPLAY_MODE_LABEL_VALUE,
387            \App\Core\Engine\Domain\Model\FieldMetadata::DISPLAY_MODE_STACKED_CARD,
388        ];
389
390        if (!in_array($displayMode, $allowedModes, true)) {
391            return $this->jsonResponse(['success' => false, 'error' => 'Invalid display mode.'], 422);
392        }
393
394        $stmt = $this->pdo->prepare(
395            'SELECT id, validation_rules FROM a_core_field_records '
396            . self::WHERE_ID_AND_MODULE
397        );
398        $stmt->execute([':id' => $fieldId, ':module_id' => $moduleId]);
399        /** @var array<string, mixed>|false $row */
400        $row = $stmt->fetch(PDO::FETCH_ASSOC);
401
402        if (!$row) {
403            return $this->jsonResponse(['success' => false, 'error' => self::ERR_FIELD_NOT_FOUND], 404);
404        }
405
406        $rules = [];
407        if (!empty($row['validation_rules'])) {
408            $decoded = json_decode((string) $row['validation_rules'], true);
409            if (is_array($decoded)) {
410                $rules = $decoded;
411            }
412        }
413
414        $rules['form_display_mode'] = $displayMode;
415        $encodedRules = (string) json_encode($rules, JSON_UNESCAPED_UNICODE);
416
417        $upd = $this->pdo->prepare(
418            'UPDATE a_core_field_records SET validation_rules = :rules, updated_at = NOW() WHERE id = :id'
419        );
420        $upd->execute([':rules' => $encodedRules, ':id' => $fieldId]);
421
422        $this->invalidateCache($moduleId);
423
424        return $this->jsonResponse([
425            'success' => true,
426            'data'    => [
427                'field_id'          => $fieldId,
428                'form_display_mode' => $displayMode,
429            ],
430        ]);
431    }
432
433    /**
434     * Creates a new field definition within the specified module.
435     *
436     * @param ServerRequestInterface $request  Incoming PSR-7 request.
437     * @param int                    $moduleId Target module primary key.
438     * @param PermissionContext      $context  Current actor permission context.
439     * @return ResponseInterface JSON response with created field data.
440     */
441    public function create(
442        ServerRequestInterface $request,
443        int $moduleId,
444        PermissionContext $context
445    ): ResponseInterface {
446        $body = $this->parseJsonBody($request);
447        $label = trim((string) ($body['label'] ?? ''));
448        $fieldKey = trim((string) ($body['field_key'] ?? ''));
449        $uitypeId = (int) ($body['uitype_id'] ?? 0);
450
451        $validationError = $this->validateCreatePayload($label, $uitypeId, $fieldKey, $moduleId);
452        if ($validationError !== null) {
453            return $this->jsonResponse(['success' => false, 'error' => $validationError], 422);
454        }
455
456        if ($fieldKey === '') {
457            $fieldKey = strtolower((string) preg_replace('/[^a-z0-9_]/', '_', $label));
458        }
459
460        $newId = $this->insertFieldRecord($moduleId, $fieldKey, $label, $uitypeId, $body, $context);
461        $this->invalidateCache($moduleId);
462
463        return $this->jsonResponse([
464            'success' => true,
465            'data'    => [
466                'id'        => $newId,
467                'field_key' => $fieldKey,
468                'label'     => $label,
469                'uitype_id' => $uitypeId,
470            ],
471        ], 201);
472    }
473
474    /**
475     * Validates input payload for creating a module field.
476     */
477    private function validateCreatePayload(string $label, int $uitypeId, string $fieldKey, int $moduleId): ?string
478    {
479        if ($label === '') {
480            return 'Label is required.';
481        }
482        if ($uitypeId <= 0) {
483            return 'Valid UiType is required.';
484        }
485
486        return ($fieldKey !== '' && $this->fieldKeyExists($moduleId, $fieldKey))
487            ? sprintf('Field with key "%s" already exists in this module.', $fieldKey)
488            : null;
489    }
490
491    /**
492     * Checks if a field key already exists within the target module.
493     */
494    private function fieldKeyExists(int $moduleId, string $fieldKey): bool
495    {
496        $stmt = $this->pdo->prepare(
497            'SELECT id FROM a_core_field_records WHERE module_id = :module_id AND field_key = :field_key LIMIT 1'
498        );
499        $stmt->execute([':module_id' => $moduleId, ':field_key' => $fieldKey]);
500        return $stmt->fetchColumn() !== false;
501    }
502
503    /**
504     * Inserts the field record into the database.
505     *
506     * @param array<string, mixed> $body
507     */
508    private function insertFieldRecord(
509        int $moduleId,
510        string $fieldKey,
511        string $label,
512        int $uitypeId,
513        array $body,
514        PermissionContext $context
515    ): int {
516        $tableAlias = $this->resolveTargetModuleAlias($moduleId);
517        $columnExpression = $tableAlias . '.' . $fieldKey;
518        $sortOrder = $this->resolveNextSortOrder($moduleId);
519
520        $secId = !empty($body['section_id']) && (int) $body['section_id'] > 0 ? (int) $body['section_id'] : null;
521        $picklistId = !empty($body['picklist_id']) && (int) $body['picklist_id'] > 0
522            ? (int) $body['picklist_id']
523            : null;
524        $relModule = !empty($body['relation_module']) ? trim((string) $body['relation_module']) : null;
525        [$relTable, $relKey] = $this->resolveRelationMetadata($relModule);
526
527        $placeholder = !empty($body['placeholder']) ? trim((string) $body['placeholder']) : null;
528        $defaultValue = !empty($body['default_value']) ? trim((string) $body['default_value']) : null;
529        $hiddenViews = $this->resolveHiddenViews($body['hidden_views'] ?? null);
530
531        $stmt = $this->pdo->prepare(
532            'INSERT INTO a_core_field_records ('
533            . 'module_id, field_key, column_expression, label, uitype_id, '
534            . 'section_id, picklist_id, relation_module, relation_table, relation_key, '
535            . 'placeholder, default_value, hidden_views, '
536            . 'is_mandatory, is_unique, is_quick_create, is_global_search, '
537            . 'is_sortable, is_filterable, is_system, is_readonly, is_summary, '
538            . 'sort_order, created_by, owner, special_access, created_at, updated_at'
539            . ') VALUES ('
540            . ':module_id, :field_key, :column_expression, :label, :uitype_id, '
541            . ':section_id, :picklist_id, :relation_module, :relation_table, :relation_key, '
542            . ':placeholder, :default_value, :hidden_views, '
543            . ':is_mandatory, :is_unique, :is_quick_create, :is_global_search, '
544            . '1, 1, 0, 0, 0, '
545            . ':sort_order, :created_by, :owner, 1, NOW(), NOW()'
546            . ')'
547        );
548        $stmt->execute([
549            ':module_id'         => $moduleId,
550            ':field_key'         => $fieldKey,
551            ':column_expression' => $columnExpression,
552            ':label'             => $label,
553            ':uitype_id'         => $uitypeId,
554            ':section_id'        => $secId,
555            ':picklist_id'       => $picklistId,
556            ':relation_module'   => $relModule,
557            ':relation_table'    => $relTable,
558            ':relation_key'      => $relKey,
559            ':placeholder'       => $placeholder,
560            ':default_value'     => $defaultValue,
561            ':hidden_views'      => $hiddenViews,
562            ':is_mandatory'      => !empty($body['is_mandatory']) ? 1 : 0,
563            ':is_unique'         => !empty($body['is_unique']) ? 1 : 0,
564            ':is_quick_create'   => !empty($body['is_quick_create']) ? 1 : 0,
565            ':is_global_search'  => !empty($body['is_global_search']) ? 1 : 0,
566            ':sort_order'        => $sortOrder,
567            ':created_by'        => $context->actorUserId,
568            ':owner'             => $context->actorUserId,
569        ]);
570
571        return (int) $this->pdo->lastInsertId();
572    }
573
574    /**
575     * Resolves table alias for target module.
576     */
577    private function resolveTargetModuleAlias(int $moduleId): string
578    {
579        $stmt = $this->pdo->prepare('SELECT table_alias FROM a_core_module_records WHERE id = :id LIMIT 1');
580        $stmt->execute([':id' => $moduleId]);
581        $alias = $stmt->fetchColumn();
582        return ($alias !== false && $alias !== '') ? (string) $alias : 'c';
583    }
584
585    /**
586     * Resolves next sort_order within module.
587     */
588    private function resolveNextSortOrder(int $moduleId): int
589    {
590        $stmt = $this->pdo->prepare(
591            'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM a_core_field_records WHERE module_id = :module_id'
592        );
593        $stmt->execute([':module_id' => $moduleId]);
594        return (int) $stmt->fetchColumn();
595    }
596
597    /**
598     * Resolves relation table and primary key from relation module name.
599     *
600     * @return array{0: string|null, 1: string|null}
601     */
602    private function resolveRelationMetadata(?string $relationModule): array
603    {
604        if ($relationModule === null || $relationModule === '') {
605            return [null, null];
606        }
607        $stmt = $this->pdo->prepare(
608            'SELECT table_name, primary_key FROM a_core_module_records WHERE name = :name LIMIT 1'
609        );
610        $stmt->execute([':name' => $relationModule]);
611        $row = $stmt->fetch(PDO::FETCH_ASSOC);
612        if (is_array($row)) {
613            $table = !empty($row['table_name']) ? (string) $row['table_name'] : null;
614            $key = !empty($row['primary_key']) ? (string) $row['primary_key'] : 'id';
615            return [$table, $key];
616        }
617        return [null, null];
618    }
619
620    /**
621     * Normalizes hidden_views parameter into comma-separated string or null.
622     */
623    private function resolveHiddenViews(mixed $hiddenViews): ?string
624    {
625        if (is_array($hiddenViews)) {
626            $filtered = array_filter(array_map('trim', array_map('strval', $hiddenViews)));
627            return !empty($filtered) ? implode(',', $filtered) : null;
628        }
629        if (is_string($hiddenViews) && trim($hiddenViews) !== '') {
630            return trim($hiddenViews);
631        }
632        return null;
633    }
634
635    /**
636     * Parses request body into an array.
637     *
638     * @param ServerRequestInterface $request PSR-7 server request.
639     * @return array<string, mixed> Parsed body associative array.
640     */
641    private function parseJsonBody(ServerRequestInterface $request): array
642    {
643        $parsed = $request->getParsedBody();
644        if (is_array($parsed) && !empty($parsed)) {
645            return $parsed;
646        }
647
648        $raw = (string) $request->getBody();
649        if ($raw === '') {
650            return [];
651        }
652
653        $decoded = json_decode($raw, true);
654        return is_array($decoded) ? $decoded : [];
655    }
656
657    use ModuleConfigApiControllerTrait;
658
659    /**
660     * Safely invalidates metadata repository cache when available.
661     *
662     * @param int|null $moduleId Optional module ID.
663     */
664    private function invalidateCache(?int $moduleId = null): void
665    {
666        $this->invalidateMetadataCache($this->metadataRepo, $moduleId);
667    }
668
669    /**
670     * Creates a standardized JSON response using ApiResponseTrait.
671     *
672     * @param array<string, mixed> $payload Response payload.
673     * @param int                  $status  HTTP status code.
674     * @return ResponseInterface PSR-7 response.
675     */
676    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
677    {
678        return $this->buildJsonResponse($this->psr17, $payload, $status);
679    }
680}