Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.10% |
320 / 333 |
|
69.57% |
16 / 23 |
CRAP | |
0.00% |
0 / 1 |
| ModuleFieldsApiController | |
96.08% |
319 / 332 |
|
69.57% |
16 / 23 |
93 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| list | |
91.11% |
41 / 45 |
|
0.00% |
0 / 1 |
5.02 | |||
| reorder | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
| processReorderPayload | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
8 | |||
| reorderItems | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
6 | |||
| reorderSectionsMap | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
9 | |||
| reorderFlatList | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
| delete | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
| toggleFieldFlag | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
4 | |||
| toggleGlobalSearch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toggleQuickCreate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| updateDisplayMode | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
5 | |||
| create | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
3 | |||
| validateCreatePayload | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| fieldKeyExists | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| insertFieldRecord | |
98.08% |
51 / 52 |
|
0.00% |
0 / 1 |
12 | |||
| resolveTargetModuleAlias | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| resolveNextSortOrder | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| resolveRelationMetadata | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
6.17 | |||
| resolveHiddenViews | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
8.12 | |||
| parseJsonBody | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| invalidateCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| jsonResponse | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Engine\Presentation\Api; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 12 | use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface; |
| 13 | use App\Shared\Infrastructure\Http\ApiResponseTrait; |
| 14 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 15 | use PDO; |
| 16 | use Psr\Http\Message\ResponseInterface; |
| 17 | use 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 | */ |
| 32 | final 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 | } |