Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
210 / 210
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
PicklistValuesApiController
100.00% covered (success)
100.00%
209 / 209
100.00% covered (success)
100.00%
15 / 15
58
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
11
 update
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
10
 delete
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
9
 reorder
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 validateValueInput
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 checkDuplicateValue
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parseIsActive
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 parseIsEditable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 resetDefaultPicklistValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fetchNextSortOrder
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 slugify
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isSystemPicklist
100.00% covered (success)
100.00%
4 / 4
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\Modules\Automation\Queue\Application\Handler\PicklistValueReassignHandler;
13use App\Modules\Automation\Queue\Infrastructure\Repository\SqlQueueRepository;
14use App\Shared\Infrastructure\Http\ApiResponseTrait;
15use Nyholm\Psr7\Factory\Psr17Factory;
16use PDO;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19
20/**
21 * Picklist Values Management REST and HTMX API Controller.
22 *
23 * Provides endpoints for managing picklist dictionary options (CRUD, SortableJS reordering,
24 * and safe deletion with record value reassignment).
25 *
26 * Handled routes:
27 *   GET    /htmx/engine/system_picklists/{id}/values          -> list()
28 *   POST   /htmx/engine/system_picklists/{id}/values          -> create()
29 *   PUT    /htmx/engine/system_picklists/{id}/values/{valId}  -> update()
30 *   DELETE /htmx/engine/system_picklists/{id}/values/{valId}  -> delete()
31 *   POST   /htmx/engine/system_picklists/{id}/values/reorder  -> reorder()
32 *
33 * @package App\Core\Engine\Presentation\Api
34 */
35final readonly class PicklistValuesApiController
36{
37    use ApiResponseTrait;
38
39    private const string ERR_SYSTEM_PICKLIST = 'System picklist values cannot be modified.';
40
41    /**
42     * PicklistValuesApiController constructor.
43     *
44     * @param PDO          $pdo   Database connection.
45     * @param Psr17Factory $psr17 PSR-17 response factory.
46     */
47    public function __construct(
48        private PDO          $pdo,
49        private Psr17Factory $psr17,
50    ) {
51    }
52
53    /**
54     * Lists all values for a specific picklist.
55     *
56     * @param int|string      $id         Picklist primary key from route argument.
57     * @param int|string|null $picklistId Optional alias for picklist primary key.
58     * @return ResponseInterface JSON response with values array.
59     */
60    public function list(int|string $id = 0, int|string|null $picklistId = null): ResponseInterface
61    {
62        $targetId = (int) ($picklistId ?? $id);
63        $sql = 'SELECT id, picklist_id, value, short_code, label, sort_order, color, icon_class, '
64            . 'is_default, is_editable, is_active, is_pending_delete, created_at, updated_at '
65            . 'FROM a_core_picklist_value_records '
66            . 'WHERE picklist_id = :picklist_id '
67            . 'ORDER BY sort_order ASC, id ASC';
68
69        $stmt = $this->pdo->prepare($sql);
70        $stmt->execute([':picklist_id' => $targetId]);
71        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
72
73        return $this->jsonResponse([
74            'success' => true,
75            'data'    => $rows,
76        ]);
77    }
78
79    /**
80     * Creates a new picklist value.
81     *
82     * @param ServerRequestInterface $request    PSR-7 request.
83     * @param int|string             $id         Picklist primary key from route.
84     * @param PermissionContext|null $context    Security context.
85     * @param int|string|null        $picklistId Optional alias for picklist primary key.
86     * @return ResponseInterface JSON response with created item data.
87     */
88    public function create(
89        ServerRequestInterface $request,
90        int|string             $id = 0,
91        ?PermissionContext     $context = null,
92        int|string|null        $picklistId = null,
93    ): ResponseInterface {
94        $targetId = (int) ($picklistId ?? $id);
95        if ($this->isSystemPicklist($targetId)) {
96            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_PICKLIST], 403);
97        }
98
99        $body      = $this->parseJsonBody($request);
100        $label     = trim((string) ($body['label'] ?? ''));
101        $value     = trim((string) ($body['value'] ?? '')) ?: $this->slugify($label);
102        $shortCode = !empty($body['short_code']) ? trim((string) $body['short_code']) : null;
103        $color     = !empty($body['color']) ? trim((string) $body['color']) : null;
104        $icon      = !empty($body['icon_class']) ? trim((string) $body['icon_class']) : null;
105        $isDef     = !empty($body['is_default']) ? 1 : 0;
106        $isEdit    = $this->parseIsEditable($body);
107        $isAct     = $this->parseIsActive($body);
108
109        $error = $this->validateValueInput($targetId, $label, $value, $shortCode);
110        if ($error !== null) {
111            return $this->jsonResponse(['success' => false, 'error' => $error], 422);
112        }
113
114        if ($isDef === 1) {
115            $this->resetDefaultPicklistValue($targetId);
116        }
117
118        $nextSort = $this->fetchNextSortOrder($targetId);
119
120        $insertSql = 'INSERT INTO a_core_picklist_value_records '
121            . '(picklist_id, value, short_code, label, sort_order, color, icon_class, is_default, is_editable, '
122            . 'is_active, created_by, owner, created_at, updated_at) '
123            . 'VALUES (:pid, :val, :scode, :lbl, :sort, :clr, :ico, :def, :edit, :act, :uid, :uid, NOW(), NOW())';
124
125        $userId = ($context !== null && $context->actorUserId > 0) ? $context->actorUserId : 1;
126        $stmt = $this->pdo->prepare($insertSql);
127        $stmt->execute([
128            ':pid'   => $targetId,
129            ':val'   => $value,
130            ':scode' => $shortCode,
131            ':lbl'   => $label,
132            ':sort'  => $nextSort,
133            ':clr'   => $color,
134            ':ico'   => $icon,
135            ':def'   => $isDef,
136            ':edit'  => $isEdit,
137            ':act'   => $isAct,
138            ':uid'   => $userId,
139        ]);
140
141        $createdId = (int) $this->pdo->lastInsertId();
142
143        return $this->jsonResponse([
144            'success' => true,
145            'data'    => [
146                'id'          => $createdId,
147                'picklist_id' => $targetId,
148                'value'       => $value,
149                'short_code'  => $shortCode,
150                'label'       => $label,
151                'color'       => $color,
152                'icon_class'  => $icon,
153                'is_default'  => $isDef,
154                'is_active'   => $isAct,
155                'sort_order'  => $nextSort,
156            ],
157        ], 201);
158    }
159
160    /**
161     * Updates an existing picklist value.
162     *
163     * @param ServerRequestInterface $request PSR-7 request.
164     * @param int|string $id Route parameter for picklist ID.
165     * @param int|string $valId Route parameter for value ID.
166     * @param int|string|null $picklistId Alias parameter for picklist ID.
167     * @param int|string|null $valueId Alias parameter for value ID.
168     * @return ResponseInterface JSON response indicating status.
169     */
170    public function update(
171        ServerRequestInterface $request,
172        int|string             $id = 0,
173        int|string             $valId = 0,
174        int|string|null        $picklistId = null,
175        int|string|null        $valueId = null,
176    ): ResponseInterface {
177        $actualPicklistId = (int) ($picklistId ?? $id);
178        $actualValueId = (int) ($valueId ?? $valId);
179
180        if ($this->isSystemPicklist($actualPicklistId)) {
181            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_PICKLIST], 403);
182        }
183
184        $body      = $this->parseJsonBody($request);
185        $label     = trim((string) ($body['label'] ?? ''));
186        $value     = trim((string) ($body['value'] ?? '')) ?: $this->slugify($label);
187        $shortCode = array_key_exists('short_code', $body) && $body['short_code'] !== ''
188            ? trim((string) $body['short_code']) : null;
189        $color     = !empty($body['color']) ? trim((string) $body['color']) : null;
190        $icon      = !empty($body['icon_class']) ? trim((string) $body['icon_class']) : null;
191        $isDef     = !empty($body['is_default']) ? 1 : 0;
192        $isEdit    = $this->parseIsEditable($body);
193        $isAct     = $this->parseIsActive($body);
194
195        $error = $this->validateValueInput($actualPicklistId, $label, $value, $shortCode, $actualValueId);
196        if ($error !== null) {
197            return $this->jsonResponse(['success' => false, 'error' => $error], 422);
198        }
199
200        if ($isDef === 1) {
201            $this->resetDefaultPicklistValue($actualPicklistId);
202        }
203
204        $updateSql = 'UPDATE a_core_picklist_value_records '
205            . 'SET value = :val, short_code = :scode, label = :lbl, color = :clr, icon_class = :ico, '
206            . 'is_default = :def, is_editable = :edit, is_active = :act '
207            . 'WHERE id = :id AND picklist_id = :pid';
208
209        $updateStmt = $this->pdo->prepare($updateSql);
210        $updateStmt->execute([
211            ':val'   => $value,
212            ':scode' => $shortCode,
213            ':lbl'   => $label,
214            ':clr'   => $color,
215            ':ico'   => $icon,
216            ':def'   => $isDef,
217            ':edit'  => $isEdit,
218            ':act'   => $isAct,
219            ':id'    => $actualValueId,
220            ':pid'   => $actualPicklistId,
221        ]);
222
223        return $this->jsonResponse(['success' => true]);
224    }
225
226    /**
227     * Deletes a picklist value with optional record value reassignment.
228     *
229     * @param ServerRequestInterface $request PSR-7 request.
230     * @param int|string $id Route parameter for picklist ID.
231     * @param int|string $valId Route parameter for value ID.
232     * @param int|string|null $picklistId Alias parameter for picklist ID.
233     * @param int|string|null $valueId Alias parameter for value ID.
234     * @return ResponseInterface JSON response indicating status.
235     */
236    public function delete(
237        ServerRequestInterface $request,
238        int|string             $id = 0,
239        int|string             $valId = 0,
240        int|string|null        $picklistId = null,
241        int|string|null        $valueId = null,
242    ): ResponseInterface {
243        $actualPicklistId = (int) ($picklistId ?? $id);
244        $actualValueId = (int) ($valueId ?? $valId);
245
246        if ($this->isSystemPicklist($actualPicklistId)) {
247            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_PICKLIST], 403);
248        }
249
250        $body = $this->parseJsonBody($request);
251        $reassignToId = isset($body['reassign_to_value_id']) && (int) $body['reassign_to_value_id'] > 0
252            ? (int) $body['reassign_to_value_id']
253            : null;
254
255        $stmt = $this->pdo->prepare(
256            'SELECT id, value, label FROM a_core_picklist_value_records WHERE id = :id AND picklist_id = :pid'
257        );
258        $stmt->execute([':id' => $actualValueId, ':pid' => $actualPicklistId]);
259        /** @var array{id: int|string, value: string, label: string}|false $currentRecord */
260        $currentRecord = $stmt->fetch(PDO::FETCH_ASSOC);
261
262        if (!$currentRecord) {
263            return $this->jsonResponse(['success' => false, 'error' => 'Picklist value not found.'], 404);
264        }
265
266        $targetValCode = null;
267        if ($reassignToId !== null && $reassignToId !== $actualValueId) {
268            $targetStmt = $this->pdo->prepare(
269                'SELECT value FROM a_core_picklist_value_records WHERE id = :id AND picklist_id = :pid'
270            );
271            $targetStmt->execute([':id' => $reassignToId, ':pid' => $actualPicklistId]);
272            $targetValCode = $targetStmt->fetchColumn() ?: null;
273        }
274
275        $markStmt = $this->pdo->prepare(
276            'UPDATE a_core_picklist_value_records SET is_pending_delete = 1, is_active = 0 '
277            . 'WHERE id = :id AND picklist_id = :pid'
278        );
279        $markStmt->execute([':id' => $actualValueId, ':pid' => $actualPicklistId]);
280
281        $currentLabel = (string) ($currentRecord['label'] ?? '');
282        $currentValue = (string) ($currentRecord['value'] ?? '');
283
284        $queueRepo = new SqlQueueRepository($this->pdo);
285        $jobId = $queueRepo->enqueue(
286            PicklistValueReassignHandler::JOB_TYPE,
287            "Reassign and delete: {$currentLabel}",
288            [
289                'picklist_id'      => $actualPicklistId,
290                'old_value_id'     => $actualValueId,
291                'new_value_id'     => $reassignToId,
292                'old_value_code'   => $currentValue,
293                'new_value_code'   => $targetValCode !== null ? (string)$targetValCode : null,
294            ]
295        );
296
297        return $this->jsonResponse([
298            'success' => true,
299            'queued'  => true,
300            'job_id'  => $jobId,
301            'message' => "The task to remove and reassign value" .
302                " '{$currentLabel}' has been queued.",
303        ]);
304    }
305
306    /**
307     * Reorders picklist values based on array of IDs.
308     *
309     * @param ServerRequestInterface $request PSR-7 request.
310     * @param int|string $id Route parameter for picklist ID.
311     * @param int|string|null $picklistId Alias parameter for picklist ID.
312     * @return ResponseInterface JSON response indicating status.
313     */
314    public function reorder(
315        ServerRequestInterface $request,
316        int|string             $id = 0,
317        int|string|null        $picklistId = null,
318    ): ResponseInterface {
319        $actualPicklistId = (int) ($picklistId ?? $id);
320
321        if ($this->isSystemPicklist($actualPicklistId)) {
322            return $this->jsonResponse(['success' => false, 'error' => self::ERR_SYSTEM_PICKLIST], 403);
323        }
324
325        $body = $this->parseJsonBody($request);
326        $order = isset($body['order']) && is_array($body['order']) ? $body['order'] : [];
327
328        if (empty($order)) {
329            return $this->jsonResponse(['success' => false, 'error' => 'Order array is required.'], 422);
330        }
331
332        $stmt = $this->pdo->prepare(
333            'UPDATE a_core_picklist_value_records SET sort_order = :sort WHERE id = :id AND picklist_id = :pid'
334        );
335
336        $sort = 10;
337        foreach ($order as $itemValId) {
338            $stmt->execute([
339                ':sort' => $sort,
340                ':id'   => (int) $itemValId,
341                ':pid'  => $actualPicklistId,
342            ]);
343            $sort += 10;
344        }
345
346        return $this->jsonResponse(['success' => true]);
347    }
348
349    /**
350     * Validates label, short code format, and uniqueness of picklist value code.
351     */
352    private function validateValueInput(
353        int     $picklistId,
354        string  $label,
355        string  $value,
356        ?string $shortCode = null,
357        ?int    $excludeId = null
358    ): ?string {
359        if ($label === '') {
360            return 'Label is required.';
361        }
362
363        if ($shortCode !== null && preg_match('/\s/', $shortCode)) {
364            return 'Short code cannot contain whitespace.';
365        }
366
367        return $this->checkDuplicateValue($picklistId, $value, $excludeId);
368    }
369
370    /**
371     * Checks if value code is already in use for the picklist.
372     */
373    private function checkDuplicateValue(int $picklistId, string $value, ?int $excludeId): ?string
374    {
375        $sql = 'SELECT id FROM a_core_picklist_value_records WHERE picklist_id = :pid AND value = :val';
376        $params = [':pid' => $picklistId, ':val' => $value];
377        if ($excludeId !== null) {
378            $sql .= ' AND id != :id';
379            $params[':id'] = $excludeId;
380        }
381
382        $stmt = $this->pdo->prepare($sql);
383        $stmt->execute($params);
384        if ($stmt->fetch()) {
385            return $excludeId === null ? 'Value code already exists.' : 'Value code already in use.';
386        }
387
388        return null;
389    }
390
391    /**
392     * Parses active boolean flag from body payload.
393     *
394     * @param array<string, mixed> $body
395     */
396    private function parseIsActive(array $body): int
397    {
398        if (!isset($body['is_active'])) {
399            return 1;
400        }
401        return (int) $body['is_active'] === 1 ? 1 : 0;
402    }
403
404    /**
405     * Parses is_editable flag from request body.
406     *
407     * @param array<string, mixed> $body
408     */
409    private function parseIsEditable(array $body): int
410    {
411        if (!array_key_exists('is_editable', $body)) {
412            return 1;
413        }
414        return !empty($body['is_editable']) ? 1 : 0;
415    }
416
417    /**
418     * Resets default flag on all picklist items.
419     */
420    private function resetDefaultPicklistValue(int $picklistId): void
421    {
422        $resetStmt = $this->pdo->prepare(
423            'UPDATE a_core_picklist_value_records SET is_default = 0 WHERE picklist_id = :pid'
424        );
425        $resetStmt->execute([':pid' => $picklistId]);
426    }
427
428    /**
429     * Fetches next sort order value.
430     */
431    private function fetchNextSortOrder(int $picklistId): int
432    {
433        $sortStmt = $this->pdo->prepare(
434            'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM a_core_picklist_value_records WHERE picklist_id = :pid'
435        );
436        $sortStmt->execute([':pid' => $picklistId]);
437        return (int) $sortStmt->fetchColumn();
438    }
439
440    /**
441     * Generates a safe lowercase alphanumeric snake_case slug from a label.
442     *
443     * @param string $label Input label.
444     * @return string Slugified identifier.
445     */
446    private function slugify(string $label): string
447    {
448        $slug = mb_strtolower($label, 'UTF-8');
449        $slug = (string) preg_replace('/[^\w]+/u', '_', $slug);
450        return trim($slug, '_') ?: 'val_' . time();
451    }
452
453    /**
454     * Checks if a picklist is marked as a protected system picklist.
455     *
456     * @param int $picklistId Picklist primary key.
457     * @return bool True if picklist is system.
458     */
459    private function isSystemPicklist(int $picklistId): bool
460    {
461        $stmt = $this->pdo->prepare('SELECT is_system FROM a_core_picklist_records WHERE id = :id');
462        $stmt->execute([':id' => $picklistId]);
463        $val = $stmt->fetchColumn();
464
465        return (int) $val === 1;
466    }
467
468    /**
469     * Creates a standardized JSON response using ApiResponseTrait.
470     *
471     * @param array<string, mixed> $payload Response payload.
472     * @param int                  $status  HTTP status code.
473     * @return ResponseInterface PSR-7 response.
474     */
475    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
476    {
477        return $this->buildJsonResponse($this->psr17, $payload, $status);
478    }
479}