Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
56 / 60
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
OwnershipApiController
93.22% covered (success)
93.22%
55 / 59
50.00% covered (danger)
50.00%
2 / 4
15.07
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
 autocomplete
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 formatUserOption
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
5.05
 formatStructureOption
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
5.02
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\Modules\Structure\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Structure\Domain\Model\StructureNode;
12use App\Modules\Structure\Domain\Repository\StructureRepositoryInterface;
13use App\Modules\User\Domain\Repository\UserRepositoryInterface;
14use App\Shared\Infrastructure\Http\ApiResponseTrait;
15use Psr\Http\Message\ResponseFactoryInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18
19/**
20 * Ownership Autocomplete REST API Controller.
21 *
22 * Exposes /api/v1/engine/ownership/autocomplete endpoint returning grouped
23 * users and organizational structure nodes for Owner and Co-Owners selection.
24 *
25 * @package App\Modules\Structure\Presentation\Api
26 */
27final readonly class OwnershipApiController
28{
29    use ApiResponseTrait;
30
31    /**
32     * OwnershipApiController constructor.
33     *
34     * @param ResponseFactoryInterface $responseFactory PSR-7 response factory.
35     * @param UserRepositoryInterface $userRepository Users domain repository.
36     * @param StructureRepositoryInterface $structureRepository Structure domain repository.
37     */
38    public function __construct(
39        private ResponseFactoryInterface $responseFactory,
40        private UserRepositoryInterface $userRepository,
41        private StructureRepositoryInterface $structureRepository
42    ) {
43    }
44
45    /**
46     * Handles search autocomplete query for owners and co-owners.
47     *
48     * @param ServerRequestInterface $request Incoming HTTP server request.
49     * @return ResponseInterface JSON response.
50     */
51    public function autocomplete(ServerRequestInterface $request): ResponseInterface
52    {
53        $queryParams = $request->getQueryParams();
54        $query = trim((string)($queryParams['q'] ?? ''));
55        $limit = max(1, min(100, (int)($queryParams['limit'] ?? 50)));
56
57        $results = [];
58
59        // 1. Fetch matching active users
60        $users = $this->userRepository->searchAutocomplete($query, $limit);
61        foreach ($users as $user) {
62            $results[] = $this->formatUserOption($user);
63        }
64
65        // 2. Fetch active structure nodes
66        $nodes = $this->structureRepository->findAllActive();
67        $queryLower = mb_strtolower($query);
68
69        foreach ($nodes as $node) {
70            $option = $this->formatStructureOption($node, $queryLower);
71            if ($option !== null) {
72                $results[] = $option;
73            }
74        }
75
76        return $this->jsonStatusResponse(
77            $this->responseFactory,
78            true,
79            'Ownership options fetched successfully',
80            $results
81        );
82    }
83
84    /**
85     * Formats raw user record into autocomplete option array.
86     *
87     * @param array<string, mixed> $user User record.
88     * @return array<string, mixed> Option payload.
89     */
90    private function formatUserOption(array $user): array
91    {
92        $fullName = !empty($user['first_name']) || !empty($user['last_name'])
93            ? trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))
94            : ($user['username'] ?? '');
95
96        $display = $fullName !== '' && $fullName !== ($user['username'] ?? '')
97            ? sprintf('%s (%s)', $user['username'], $fullName)
98            : ($user['username'] ?? ('User #' . $user['id']));
99
100        return [
101            'id'         => (string)$user['id'],
102            'value'      => (string)$user['id'],
103            'username'   => $display,
104            'label'      => $display,
105            'email'      => (string)($user['email'] ?? ''),
106            'group'      => 'Users',
107            'owner_type' => 'user',
108            'disabled'   => false,
109        ];
110    }
111
112    /**
113     * Formats structure node into autocomplete option array or returns null if not matching query.
114     *
115     * @param StructureNode $node Structure node.
116     * @param string        $queryLower Lowercase search query.
117     * @return array<string, mixed>|null Option payload or null.
118     */
119    private function formatStructureOption(StructureNode $node, string $queryLower): ?array
120    {
121        $name = $node->getName();
122        if ($queryLower !== '' && !str_contains(mb_strtolower($name), $queryLower)) {
123            return null;
124        }
125
126        $hasUsers = $node->hasAssignedUsers();
127        $usersCount = $node->getAssignedUsersCount();
128        $usersPreview = implode(', ', array_slice($node->getAssignedUserNames(), 0, 3));
129        if ($usersCount > 3) {
130            $usersPreview .= sprintf(' (+%d)', $usersCount - 3);
131        }
132
133        $label = $hasUsers
134            ? sprintf('%s (%d users: %s)', $name, $usersCount, $usersPreview)
135            : $name . ' (No assigned users)';
136
137        return [
138            'id'            => 'struct_' . $node->getId(),
139            'value'         => 'struct_' . $node->getId(),
140            'username'      => $name,
141            'label'         => $label,
142            'group'         => $node->getStructureType()->label(),
143            'owner_type'    => 'structure',
144            'structure_id'  => $node->getId(),
145            'users_count'   => $usersCount,
146            'users_preview' => $usersPreview,
147            'disabled'      => !$hasUsers,
148        ];
149    }
150}