Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.29% covered (success)
95.29%
81 / 85
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RelationMmApiController
95.24% covered (success)
95.24%
80 / 84
75.00% covered (warning)
75.00%
6 / 8
27
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
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 picker
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
8
 link
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 unlink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 extractColumnFilters
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
13.12
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 jsonResponse
100.00% covered (success)
100.00%
4 / 4
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\Application\Service\RelationMmManager;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use App\Shared\Infrastructure\Http\ApiResponseTrait;
14use Nyholm\Psr7\Factory\Psr17Factory;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Twig\Environment as TwigEnvironment;
18
19/**
20 * Relation M:M REST and HTMX API Controller.
21 *
22 * Handles Many-to-Many record association operations:
23 * - Listing linked records in detail tabs
24 * - Rendering modal picker of candidates to link
25 * - Creating association links (link)
26 * - Removing association links (unlink)
27 *
28 * @package App\Core\Engine\Presentation\Api
29 */
30final readonly class RelationMmApiController
31{
32    use ApiResponseTrait;
33
34    /**
35     * RelationMmApiController constructor.
36     *
37     * @param RelationMmManager $relationManager Relation application service.
38     * @param TwigEnvironment   $twig            Twig template engine.
39     * @param Psr17Factory      $psr17           PSR-17 response factory.
40     */
41    public function __construct(
42        private RelationMmManager $relationManager,
43        private TwigEnvironment   $twig,
44        private Psr17Factory      $psr17,
45    ) {
46    }
47
48    /**
49     * Lists linked records and renders the partial HTML tab table.
50     *
51     * @param string $sourceModuleName Source module machine name.
52     * @param int    $sourceRecordId   Source record ID.
53     * @param string $targetModuleName Target module machine name.
54     * @return ResponseInterface HTML or JSON response.
55     */
56    public function list(
57        string $sourceModuleName,
58        int    $sourceRecordId,
59        string $targetModuleName,
60    ): ResponseInterface {
61        $relation = $this->relationManager->resolveMmRelation($sourceModuleName, $targetModuleName);
62        $data = $this->relationManager->fetchRelatedRecords(
63            $sourceModuleName,
64            $sourceRecordId,
65            $targetModuleName
66        );
67
68        $params = array_merge($data, [
69            'relation'           => $relation,
70            'allowed_actions'    => $relation !== null ? $relation->allowedActions : ['create', 'select', 'refresh'],
71            'source_module_name' => $sourceModuleName,
72            'source_record_id'   => $sourceRecordId,
73            'target_module_name' => $targetModuleName,
74        ]);
75
76        $html = $this->twig->render('engine/partials/relation_mm_tab_content.twig', $params);
77        return $this->htmlResponse($html);
78    }
79
80    /**
81     * Renders the candidate picker modal content or table rows.
82     *
83     * @param ServerRequestInterface $request          PSR-7 server request.
84     * @param string                 $sourceModuleName Source module machine name.
85     * @param int                    $sourceRecordId   Source record ID.
86     * @param string                 $targetModuleName Target module machine name.
87     * @return ResponseInterface HTML modal or rows response.
88     */
89    public function picker(
90        ServerRequestInterface $request,
91        string                 $sourceModuleName,
92        int                    $sourceRecordId,
93        string                 $targetModuleName,
94    ): ResponseInterface {
95        $queryParams = $request->getQueryParams();
96        $query = isset($queryParams['q']) ? (string) $queryParams['q'] : '';
97        $page = isset($queryParams['page']) ? (int) $queryParams['page'] : 1;
98        $perPage = isset($queryParams['limit']) ? (int) $queryParams['limit'] : 10;
99        $sortField = isset($queryParams['sort']) ? (string) $queryParams['sort'] : 'id';
100        $sortOrder = isset($queryParams['order']) ? (string) $queryParams['order'] : 'DESC';
101
102        $columnFilters = $this->extractColumnFilters($queryParams);
103
104        $result = $this->relationManager->fetchAvailableRecordsToLink(
105            $sourceModuleName,
106            $sourceRecordId,
107            $targetModuleName,
108            [
109                'page'           => $page,
110                'per_page'       => $perPage,
111                'sort_field'     => $sortField,
112                'sort_order'     => $sortOrder,
113                'column_filters' => $columnFilters,
114                'general_query'  => $query,
115            ]
116        );
117
118        $isPartial = isset($queryParams['partial']) && (string) $queryParams['partial'] === '1';
119        $template = $isPartial
120            ? 'engine/partials/modal_relation_mm_picker_rows.twig'
121            : 'engine/partials/modal_relation_mm_picker.twig';
122
123        $html = $this->twig->render($template, array_merge($result, [
124            'source_module_name' => $sourceModuleName,
125            'source_record_id'   => $sourceRecordId,
126            'target_module_name' => $targetModuleName,
127        ]));
128
129        return $this->htmlResponse($html);
130    }
131
132    /**
133     * Links a target record to the source record and returns updated tab HTML.
134     *
135     * @param ServerRequestInterface $request          PSR-7 server request.
136     * @param string                 $sourceModuleName Source module machine name.
137     * @param int                    $sourceRecordId   Source record ID.
138     * @param string                 $targetModuleName Target module machine name.
139     * @param PermissionContext      $context          Security context.
140     * @return ResponseInterface Rendered tab HTML or JSON error.
141     */
142    public function link(
143        ServerRequestInterface $request,
144        string                 $sourceModuleName,
145        int                    $sourceRecordId,
146        string                 $targetModuleName,
147        PermissionContext      $context,
148    ): ResponseInterface {
149        $body = (array) ($request->getParsedBody() ?? []);
150        $targetId = isset($body['target_id']) ? (int) $body['target_id'] : 0;
151
152        if ($targetId <= 0) {
153            $rawBody = (string) $request->getBody();
154            $json = json_decode($rawBody, true);
155            if (is_array($json) && isset($json['target_id'])) {
156                $targetId = (int) $json['target_id'];
157            }
158        }
159
160        if ($targetId <= 0) {
161            return $this->jsonResponse(['error' => 'Missing target record ID.'], 422);
162        }
163
164        $this->relationManager->linkRecords(
165            $sourceModuleName,
166            $sourceRecordId,
167            $targetModuleName,
168            $targetId,
169            $context->actorUserId
170        );
171
172        return $this->list($sourceModuleName, $sourceRecordId, $targetModuleName);
173    }
174
175    /**
176     * Unlinks a target record from the source record and returns updated tab HTML.
177     *
178     * @param string $sourceModuleName Source module machine name.
179     * @param int    $sourceRecordId   Source record ID.
180     * @param string $targetModuleName Target module machine name.
181     * @param int    $targetRecordId   Target record ID to unlink.
182     * @return ResponseInterface Rendered tab HTML.
183     */
184    public function unlink(
185        string $sourceModuleName,
186        int    $sourceRecordId,
187        string $targetModuleName,
188        int    $targetRecordId,
189    ): ResponseInterface {
190        $this->relationManager->unlinkRecords(
191            $sourceModuleName,
192            $sourceRecordId,
193            $targetModuleName,
194            $targetRecordId
195        );
196
197        return $this->list($sourceModuleName, $sourceRecordId, $targetModuleName);
198    }
199
200    /**
201     * Extracts column filters from query parameters.
202     *
203     * @param array<string, mixed> $queryParams
204     * @return array<string, string>
205     */
206    private function extractColumnFilters(array $queryParams): array
207    {
208        $columnFilters = [];
209        if (isset($queryParams['filters']) && is_array($queryParams['filters'])) {
210            foreach ($queryParams['filters'] as $fKey => $fVal) {
211                if (is_string($fKey) && (is_string($fVal) || is_numeric($fVal))) {
212                    $columnFilters[$fKey] = (string) $fVal;
213                }
214            }
215        }
216        return $columnFilters;
217    }
218
219    /**
220     * Helper to render HTML response.
221     *
222     * @param string $html Rendered HTML content.
223     * @return ResponseInterface PSR-7 response.
224     */
225    private function htmlResponse(string $html): ResponseInterface
226    {
227        $response = $this->psr17->createResponse(200)
228            ->withHeader('Content-Type', 'text/html; charset=UTF-8');
229        $response->getBody()->write($html);
230        return $response;
231    }
232
233    /**
234     * Helper to render JSON response.
235     *
236     * @param array<string, mixed> $payload Response payload array.
237     * @param int                  $status  HTTP status code.
238     * @return ResponseInterface PSR-7 response.
239     */
240    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
241    {
242        $response = $this->psr17->createResponse($status)
243            ->withHeader('Content-Type', 'application/json; charset=utf-8');
244        $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE));
245        return $response;
246    }
247}