Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.41% covered (warning)
87.41%
236 / 270
88.10% covered (warning)
88.10%
37 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralEngineApiController
87.73% covered (warning)
87.73%
236 / 269
88.10% covered (warning)
88.10%
37 / 42
142.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 getCrudService
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchModuleContext
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 fetchGridContext
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
8
 fetchRecord
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 fetchNavigation
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
20.74
 actionList
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 actionCalendarEvents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionCalendarUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionRelatedCalendarEvents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionRelatedWorkTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionSchema
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 tryRemoteSchema
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 resolveLocalSchemaResponse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 actionRead
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 actionNavigation
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
16.51
 actionCreate
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 actionUpdate
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 actionDelete
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 actionStatus
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
7.19
 actionTimeline
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 actionFields
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 fetchHierarchy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionHierarchy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionToggleFavorite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionTogglePin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionRelationOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionBulkActionsList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionBulkEditableFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionBulkAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchLinkedEmailForTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchLinkedEmailsForRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchLinkedEmailsForTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rescanAndLinkEmailsForRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unlinkEmailFromRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unlinkEmailFromTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 linkEmailToRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 linkEmailToTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchAvailableEmailsToLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchTicketEmailContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchActiveMailboxes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handleException
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\Action\Application\Service\ActionRegistryService;
12use App\Core\Engine\Application\Service\BulkActionService;
13use App\Core\Engine\Application\Service\HierarchyService;
14use App\Core\Engine\Application\Service\RecordEmailLinkService;
15use App\Core\Engine\Application\Service\RecordFavoriteService;
16use App\Core\Engine\Application\Service\RecordModuleContextBuilder;
17use App\Core\Engine\Application\Service\RecordPinService;
18use App\Core\Engine\Application\Service\RelationMmManager;
19use App\Core\Engine\Application\Service\SystemLevelResolver;
20use App\Core\Engine\Application\Service\UniversalCrudService;
21use App\Core\Engine\Domain\Exception\ModuleNotFoundException;
22use App\Core\Engine\Domain\Exception\PermissionDeniedException;
23use App\Core\Engine\Domain\Exception\RecordNotFoundException;
24use App\Core\Engine\Domain\Exception\ValidationException;
25use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException;
26use App\Core\Engine\Domain\Model\FieldMetadata;
27use App\Core\Engine\Domain\Model\PermissionContext;
28use App\Core\Engine\Domain\Model\RecordNavigationResult;
29use App\Core\Grid\GridRequest;
30use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
31use App\Core\Instance\Application\Service\RemoteInstanceEngineGatewayInterface;
32use App\Shared\Infrastructure\Http\ApiResponseTrait;
33use Nyholm\Psr7\Factory\Psr17Factory;
34use PDO;
35use Psr\Http\Message\ResponseInterface;
36use Psr\Http\Message\ServerRequestInterface;
37
38/**
39 * Central Engine API Controller.
40 *
41 * Single REST API controller serving all module data operations.
42 * Instantiated directly in index.php with manual constructor injection.
43 *
44 * Handled routes:
45 *   GET    /api/v1/engine/{module}/list          -> actionList()
46 *   GET    /api/v1/engine/{module}/schema        -> actionSchema()
47 *   GET    /api/v1/engine/{module}/{id}          -> actionRead()
48 *   GET    /api/v1/engine/{module}/{id}/navigation -> actionNavigation()
49 *   GET    /api/v1/engine/{module}/{id}/hierarchy  -> actionHierarchy()
50 *   POST   /api/v1/engine/{module}               -> actionCreate()
51 *   PUT    /api/v1/engine/{module}/{id}          -> actionUpdate()
52 *   DELETE /api/v1/engine/{module}/{id}          -> actionDelete()
53use App\Core\Action\Application\Service\ActionRegistryService;
54use App\Core\Engine\Application\Service\BulkActionService;
55use App\Core\Engine\Application\Service\HierarchyService;
56use App\Core\Engine\Application\Service\RecordEmailLinkService;
57use App\Core\Engine\Application\Service\RecordFavoriteService;
58use App\Core\Engine\Application\Service\RecordModuleContextBuilder;
59use App\Core\Engine\Application\Service\RecordPinService;
60use App\Core\Engine\Application\Service\RelationMmManager;
61use App\Core\Engine\Application\Service\SystemLevelResolver;
62use App\Core\Engine\Application\Service\UniversalCrudService;
63use App\Core\Engine\Domain\Exception\ModuleNotFoundException;
64use App\Core\Engine\Domain\Exception\PermissionDeniedException;
65use App\Core\Engine\Domain\Exception\RecordNotFoundException;
66use App\Core\Engine\Domain\Exception\ValidationException;
67use App\Modules\Structure\Domain\Exception\StructureReassignRequiredException;
68use App\Core\Engine\Domain\Model\FieldMetadata;
69use App\Core\Engine\Domain\Model\PermissionContext;
70use App\Core\Engine\Domain\Model\RecordNavigationResult;
71use App\Core\Grid\GridRequest;
72use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
73use App\Core\Instance\Application\Service\RemoteInstanceEngineGatewayInterface;
74use App\Shared\Infrastructure\Http\ApiResponseTrait;
75use Nyholm\Psr7\Factory\Psr17Factory;
76use PDO;
77use Psr\Http\Message\ResponseInterface;
78use Psr\Http\Message\ServerRequestInterface;
79
80/**
81 * Central Engine API Controller.
82 *
83 * Single REST API controller serving all module data operations.
84 * Instantiated directly in index.php with manual constructor injection.
85 *
86 * Handled routes:
87 *   GET    /api/v1/engine/{module}/list          -> actionList()
88 *   GET    /api/v1/engine/{module}/schema        -> actionSchema()
89 *   GET    /api/v1/engine/{module}/{id}          -> actionRead()
90 *   GET    /api/v1/engine/{module}/{id}/navigation -> actionNavigation()
91 *   GET    /api/v1/engine/{module}/{id}/hierarchy  -> actionHierarchy()
92 *   POST   /api/v1/engine/{module}               -> actionCreate()
93 *   PUT    /api/v1/engine/{module}/{id}          -> actionUpdate()
94 *   DELETE /api/v1/engine/{module}/{id}          -> actionDelete()
95 *   GET    /api/v1/engine/{module}/bulk-actions  -> actionBulkActionsList()
96 *   GET    /api/v1/engine/{module}/bulk-editable-fields -> actionBulkEditableFields()
97 *   POST   /api/v1/engine/{module}/bulk-action   -> actionBulkAction()
98 *
99 * @package App\Core\Engine\Presentation\Api
100 */
101final readonly class CentralEngineApiController // NOSONAR
102{
103    use ApiResponseTrait;
104
105    /**
106     * CentralEngineApiController constructor.
107     *
108     * @param UniversalCrudService                    $crudService             Central engine service.
109     * @param Psr17Factory                            $psr17                   PSR-17 response factory.
110     * @param InstanceContextManagerInterface|null    $instanceContextManager  Instance context manager.
111     * @param RemoteInstanceEngineGatewayInterface|null $remoteGateway          Remote instance gateway.
112     * @param HierarchyService|null                   $hierarchyService        Hierarchy tree service.
113     * @param BulkActionService|null                  $bulkActionService       Bulk action processing service.
114     * @param ActionRegistryService|null              $actionRegistryService   Action registry service.
115     * @param PDO|null                                $pdo                     Database connection handle.
116     * @param string                                  $tablePrefix             Database table prefix.
117     */
118    private RecordEmailLinkService $emailLinkService;
119    private RecordCalendarApiController $calendarApiController;
120    private RecordBulkActionsApiController $bulkApiController;
121    private RecordRelationsApiController $relationsApiController;
122    private RecordModuleContextBuilder $moduleContextBuilder;
123
124    public function __construct(
125        private UniversalCrudService                    $crudService,
126        private Psr17Factory                            $psr17,
127        private ?InstanceContextManagerInterface        $instanceContextManager = null,
128        private ?RemoteInstanceEngineGatewayInterface   $remoteGateway = null,
129        private ?HierarchyService                       $hierarchyService = null,
130        private ?BulkActionService                      $bulkActionService = null,
131        private ?ActionRegistryService                  $actionRegistryService = null,
132        private ?PDO                                    $pdo = null,
133        private string                                  $tablePrefix = 'a_',
134        private ?RelationMmManager                      $relationMmManager = null,
135        private ?RecordFavoriteService                  $favoriteService = null,
136        private ?RecordPinService                       $pinService = null,
137        ?RecordEmailLinkService                         $emailLinkService = null,
138        ?RecordCalendarApiController                    $calendarApiController = null,
139        ?RecordBulkActionsApiController                 $bulkApiController = null,
140        ?RecordRelationsApiController                   $relationsApiController = null,
141        ?RecordModuleContextBuilder                     $moduleContextBuilder = null,
142    ) {
143        $this->emailLinkService = $emailLinkService ?? new RecordEmailLinkService($this->pdo, $this->tablePrefix);
144        $this->calendarApiController = $calendarApiController
145            ?? new RecordCalendarApiController($this->crudService, $this->psr17);
146        $this->bulkApiController = $bulkApiController ?? new RecordBulkActionsApiController(
147            $this->crudService,
148            $this->psr17,
149            $this->bulkActionService,
150            $this->actionRegistryService
151        );
152        $this->relationsApiController = $relationsApiController ?? new RecordRelationsApiController(
153            $this->crudService,
154            $this->psr17,
155            $this->hierarchyService,
156            $this->pdo,
157            $this->favoriteService,
158            $this->pinService
159        );
160        $this->moduleContextBuilder = $moduleContextBuilder
161            ?? new RecordModuleContextBuilder(
162                $this->crudService->getMetadataRepository(),
163                $this->crudService->getProfileRepository()
164            );
165    }
166
167    /**
168     * Returns the underlying UniversalCrudService instance.
169     */
170    public function getCrudService(): UniversalCrudService
171    {
172        return $this->crudService;
173    }
174
175    /**
176     * Fetches basic UI rendering context for a module.
177     *
178     * @param string            $moduleName      Module machine name.
179     * @param PermissionContext $context         Security context.
180     * @param bool              $requireWritable If write access is required.
181     * @return array<string, mixed> Module UI context containing metadata, fields, sections, and options.
182     */
183    public function fetchModuleContext(
184        string            $moduleName,
185        PermissionContext $context,
186        bool              $requireWritable = false
187    ): array {
188        $contextData = $this->moduleContextBuilder->buildModuleContext($moduleName, $context, $requireWritable);
189        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
190            $activeInstance = $this->instanceContextManager->getActiveInstance();
191            if ($activeInstance !== null) {
192                $clientUsers = $this->remoteGateway->fetchClientUsers($activeInstance);
193                if (!empty($clientUsers)) {
194                    $contextData['user_options'] = $clientUsers;
195                }
196            }
197        }
198        $contextData['bulk_edit_limit'] = $this->bulkActionService?->getBulkEditLimit() ?? 100;
199
200        return $contextData;
201    }
202
203    /**
204     * Fetches complete grid rendering context for a module.
205     *
206     * @param string            $moduleName Module machine name.
207     * @param int|null          $filterId   Optional active filter ID.
208     * @param PermissionContext $context    Security context.
209     * @return array<string, mixed> Complete grid view data array.
210     */
211    public function fetchGridContext(
212        string            $moduleName,
213        ?int              $filterId,
214        PermissionContext $context,
215        ?int              $gridFilterId = null,
216        ?string           $level = null
217    ): array {
218        $contextData = $this->fetchModuleContext($moduleName, $context);
219        $meta        = $this->crudService->getMetadataRepository();
220        $module      = $contextData['module'];
221        $fields      = $contextData['fields'];
222
223        $activeGridFilter = $meta->findFilterGrid($module->id, $gridFilterId);
224        $activeGridFilterId = $activeGridFilter->id > 0 ? $activeGridFilter->id : null;
225
226        $contextData['grid_filters']         = $meta->findModuleFiltersGrid($module->id, $context);
227        $contextData['active_grid_filter']   = $activeGridFilter;
228        $contextData['grid_relations']       = $meta->findGridRelationsByModule($module->id, $activeGridFilterId);
229
230        $contextData['module_filters'] = $meta->findModuleFilters($module->id, $context);
231        $contextData['filter']         = $meta->findFilter($module->id, $filterId);
232
233        $levelResolver = $this->crudService->getSystemLevelResolver();
234        if ($levelResolver->isLevelScopedModule($moduleName)) {
235            $contextData['system_levels'] = $levelResolver->getAvailableLevels();
236            $contextData['current_level'] = $level ?? SystemLevelResolver::DEFAULT_LEVEL;
237        }
238
239        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
240            $contextData['faceted_filter_options'] = [];
241        } elseif ($module->tableName !== null && $module->tableName !== '') {
242            $contextData['faceted_filter_options'] = $meta->findFacetedFilterOptions($module, $fields, $context);
243        } else {
244            $provider = $this->crudService->getDataProviderRegistry()?->findProvider($moduleName);
245            $contextData['faceted_filter_options'] = $provider !== null
246                ? $provider->fetchFacetedFilterOptions($module, $context)
247                : [];
248        }
249
250        return $contextData;
251    }
252
253    /**
254     * Reads a single module record by ID via CRUD service.
255     *
256     * @param string            $moduleName Module machine name.
257     * @param int               $id         Record identifier.
258     * @param PermissionContext $context    Security context.
259     * @return array<string, mixed> Record data array.
260     */
261    public function fetchRecord(
262        string            $moduleName,
263        int               $id,
264        PermissionContext $context,
265        ?string           $level = null
266    ): array {
267        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
268            $activeInstance = $this->instanceContextManager->getActiveInstance();
269            if ($activeInstance !== null) {
270                $remoteRes = $this->remoteGateway->fetchRecord($activeInstance, $moduleName, $id);
271                /** @var array<string, mixed> */
272                return $remoteRes['data']['record'] ?? $remoteRes['record'] ?? $remoteRes['data'] ?? $remoteRes;
273            }
274        }
275
276        return $this->crudService->read($moduleName, $id, $context, $level);
277    }
278
279    /**
280     * Resolves navigation state for a record within an active list filter context.
281     *
282     * @param string            $moduleName Module machine name.
283     * @param int               $id         Record identifier.
284     * @param GridRequest       $request    List query and filter parameters.
285     * @param PermissionContext $context    Security context.
286     * @param int|null          $filterId   Optional active filter ID.
287     * @return RecordNavigationResult Navigation resolution result.
288     */
289    public function fetchNavigation(
290        string            $moduleName,
291        int               $id,
292        GridRequest       $request,
293        PermissionContext $context,
294        ?int              $filterId = null,
295    ): RecordNavigationResult {
296        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
297            $activeInstance = $this->instanceContextManager->getActiveInstance();
298            if ($activeInstance !== null) {
299                $remoteRes = $this->remoteGateway->fetchNavigation(
300                    $activeInstance,
301                    $moduleName,
302                    $id,
303                    $request,
304                    $filterId
305                );
306                $data = $remoteRes['data'] ?? $remoteRes;
307                if (is_array($data)) {
308                    return RecordNavigationResult::fromArray($data);
309                }
310            }
311        }
312
313        return $this->crudService->getNavigation($moduleName, $id, $request, $context, $filterId);
314    }
315
316    /**
317     * Handles GET /api/v1/engine/{module}/list with pagination.
318     *
319     * @param ServerRequestInterface $request PSR-7 request.
320     * @param string                 $module  Module name from routing.
321     * @param PermissionContext      $context Security context.
322     * @return ResponseInterface JSON response with paginated list data.
323     */
324    public function actionList(
325        ServerRequestInterface $request,
326        string                 $module,
327        PermissionContext      $context
328    ): ResponseInterface {
329        try {
330            $gridRequest = GridRequest::fromRequest($request);
331            $params      = $request->getQueryParams();
332            $filterId    = isset($params['filter_id']) ? (int) $params['filter_id'] : null;
333
334            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
335                $activeInstance = $this->instanceContextManager->getActiveInstance();
336                if ($activeInstance !== null) {
337                    $remoteRes = $this->remoteGateway->fetchList($activeInstance, $module, $gridRequest, $filterId);
338                    $data = $remoteRes['data'] ?? $remoteRes;
339                    return $this->jsonSuccess($this->psr17, is_array($data) ? $data : []);
340                }
341            }
342
343            $result = $this->crudService->list($module, $gridRequest, $context, $filterId);
344            $rows   = $result->rows;
345
346            if (!empty($params['fields']) && is_string($params['fields'])) {
347                $requestedFields = array_flip(array_map('trim', explode(',', $params['fields'])));
348                $rows = array_map(
349                    static fn(array $row): array => array_intersect_key($row, $requestedFields),
350                    $rows
351                );
352            }
353
354            return $this->jsonSuccess($this->psr17, [
355                'records'       => $rows,
356                'total'         => $result->totalRecords,
357                'page'          => $result->gridRequest->page,
358                'limit'         => $result->gridRequest->limit,
359                'total_pages'   => $result->totalPages,
360                'sort_by'       => $result->gridRequest->sortColumn,
361                'sort_order'    => $result->gridRequest->sortDirection,
362                'status_counts' => $result->statusCounts,
363            ]);
364        } catch (\Throwable $e) {
365            return $this->handleException($e);
366        }
367    }
368
369    /**
370     * Handles GET /api/v1/engine/{module}/calendar-events for EventCalendar rendering.
371     *
372     * @param ServerRequestInterface $request PSR-7 server request.
373     * @param string                 $module  Module machine name.
374     * @param PermissionContext      $context Security context.
375     * @return ResponseInterface JSON response with calendar events array.
376     */
377    public function actionCalendarEvents(
378        ServerRequestInterface $request,
379        string                 $module,
380        PermissionContext      $context
381    ): ResponseInterface {
382        return $this->calendarApiController->actionCalendarEvents($request, $module, $context);
383    }
384
385    /**
386     * Handles GET /api/v1/engine/{module}/calendar-users for calendar user filter panel.
387     *
388     * @param ServerRequestInterface $request PSR-7 server request.
389     * @param string                 $module  Module machine name.
390     * @param PermissionContext      $context Security context.
391     * @return ResponseInterface JSON response with users list matching active filter.
392     */
393    public function actionCalendarUsers(
394        ServerRequestInterface $request,
395        string                 $module,
396        PermissionContext      $context
397    ): ResponseInterface {
398        return $this->calendarApiController->actionCalendarUsers($request, $module, $context);
399    }
400
401    /**
402     * Handles GET /api/v1/engine/{module}/{id}/calendar-events for related calendar tab.
403     *
404     * @param ServerRequestInterface $request Incoming HTTP request.
405     * @param string                 $module  Parent module name.
406     * @param int                    $id      Parent record primary key.
407     * @param PermissionContext      $context User permission context.
408     * @return ResponseInterface JSON response with related events list.
409     */
410    public function actionRelatedCalendarEvents(
411        ServerRequestInterface $request,
412        string                 $module,
413        int                    $id,
414        PermissionContext      $context
415    ): ResponseInterface {
416        return $this->calendarApiController->actionRelatedCalendarEvents($request, $module, $id, $context);
417    }
418
419    /**
420     * Handles GET /api/v1/engine/{module}/{id}/work-time-records for related work time tab.
421     *
422     * @param ServerRequestInterface $request Incoming HTTP request.
423     * @param string                 $module  Parent module name.
424     * @param int                    $id      Parent record primary key.
425     * @param PermissionContext      $context User permission context.
426     * @return ResponseInterface JSON response with related work time records.
427     */
428    public function actionRelatedWorkTime(
429        ServerRequestInterface $request,
430        string                 $module,
431        int                    $id,
432        PermissionContext      $context
433    ): ResponseInterface {
434        return $this->calendarApiController->actionRelatedWorkTime($request, $module, $id, $context);
435    }
436
437    /**
438     * Handles GET /api/v1/engine/{module}/schema for dynamic forms with ETag HTTP caching.
439     *
440     * @param string                      $module  Module name from routing.
441     * @param PermissionContext           $context Security context.
442     * @param ServerRequestInterface|null $request Optional HTTP request for conditional ETag evaluation.
443     * @return ResponseInterface JSON response with field schema.
444     */
445    public function actionSchema(
446        string                  $module,
447        PermissionContext       $context,
448        ?ServerRequestInterface $request = null,
449    ): ResponseInterface {
450        try {
451            $remoteResponse = $this->tryRemoteSchema($module);
452            if ($remoteResponse !== null) {
453                return $remoteResponse;
454            }
455
456            return $this->resolveLocalSchemaResponse($module, $context, $request);
457        } catch (\Throwable $e) {
458            return $this->handleException($e);
459        }
460    }
461
462    /**
463     * Attempts to fetch schema from remote instance gateway.
464     */
465    private function tryRemoteSchema(string $module): ?ResponseInterface
466    {
467        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
468            $activeInstance = $this->instanceContextManager->getActiveInstance();
469            if ($activeInstance !== null) {
470                $remoteRes = $this->remoteGateway->fetchSchema($activeInstance, $module);
471                $data = $remoteRes['data'] ?? $remoteRes;
472                return $this->jsonSuccess($this->psr17, is_array($data) ? $data : []);
473            }
474        }
475        return null;
476    }
477
478    /**
479     * Resolves local schema response with ETag HTTP conditional caching.
480     */
481    private function resolveLocalSchemaResponse(
482        string $module,
483        PermissionContext $context,
484        ?ServerRequestInterface $request
485    ): ResponseInterface {
486        $schema = $this->crudService->schema($module, $context);
487        $etag   = '"' . hash('sha256', (string) json_encode($schema)) . '"';
488
489        if ($request !== null) {
490            $ifNoneMatch = $request->getHeaderLine('If-None-Match');
491            if ($ifNoneMatch !== '' && (trim($ifNoneMatch, '"') === trim($etag, '"') || $ifNoneMatch === '*')) {
492                return $this->psr17->createResponse(304)
493                    ->withHeader('ETag', $etag)
494                    ->withHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
495            }
496        }
497
498        return $this->jsonSuccess($this->psr17, ['schema' => $schema])
499            ->withHeader('ETag', $etag)
500            ->withHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
501    }
502
503    /**
504     * Handles GET /api/v1/engine/{module}/{id} for single record read.
505     *
506     * @param string            $module  Module name from routing.
507     * @param int               $id      Record primary key.
508     * @param PermissionContext $context Security context.
509     * @return ResponseInterface JSON response with record data.
510     */
511    public function actionRead(
512        string            $module,
513        int               $id,
514        PermissionContext $context
515    ): ResponseInterface {
516        try {
517            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
518                $activeInstance = $this->instanceContextManager->getActiveInstance();
519                if ($activeInstance !== null) {
520                    $remoteRes = $this->remoteGateway->fetchRecord($activeInstance, $module, $id);
521                    $data = $remoteRes['data'] ?? $remoteRes;
522                    return $this->jsonSuccess($this->psr17, is_array($data) ? $data : ['record' => $data]);
523                }
524            }
525
526            $record = $this->crudService->read($module, $id, $context);
527
528            return $this->jsonSuccess($this->psr17, ['record' => $record]);
529        } catch (\Throwable $e) {
530            return $this->handleException($e);
531        }
532    }
533
534    /**
535     * Handles GET /api/v1/engine/{module}/{id}/navigation for record navigation metadata.
536     *
537     * @param ServerRequestInterface $request PSR-7 request.
538     * @param string                 $module  Module name from routing.
539     * @param int                    $id      Record primary key.
540     * @param PermissionContext      $context Security context.
541     * @return ResponseInterface JSON response with navigation data.
542     */
543    public function actionNavigation(
544        ServerRequestInterface $request,
545        string                 $module,
546        int                    $id,
547        PermissionContext      $context
548    ): ResponseInterface {
549        try {
550            $gridRequest = GridRequest::fromRequest($request);
551            $params      = $request->getQueryParams();
552            $filterId    = isset($params['filter_id']) ? (int) $params['filter_id'] : null;
553
554            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
555                $activeInstance = $this->instanceContextManager->getActiveInstance();
556                if ($activeInstance !== null) {
557                    $remoteRes = $this->remoteGateway->fetchNavigation(
558                        $activeInstance,
559                        $module,
560                        $id,
561                        $gridRequest,
562                        $filterId
563                    );
564                    $data = $remoteRes['data'] ?? $remoteRes;
565                    return $this->jsonSuccess($this->psr17, is_array($data) ? $data : []);
566                }
567            }
568
569            $result = $this->crudService->getNavigation($module, $id, $gridRequest, $context, $filterId);
570
571            return $this->jsonSuccess($this->psr17, $result->toArray());
572        } catch (\Throwable $e) {
573            return $this->handleException($e);
574        }
575    }
576
577    /**
578     * Handles POST /api/v1/engine/{module} for record creation.
579     *
580     * @param ServerRequestInterface $request PSR-7 request with JSON body.
581     * @param string                 $module  Module name from routing.
582     * @param PermissionContext      $context Security context.
583     * @return ResponseInterface JSON response with the new record ID.
584     */
585    public function actionCreate(
586        ServerRequestInterface $request,
587        string                 $module,
588        PermissionContext      $context
589    ): ResponseInterface {
590        try {
591            $input = $this->parseJsonBody($request);
592
593            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
594                $activeInstance = $this->instanceContextManager->getActiveInstance();
595                if ($activeInstance !== null) {
596                    $remoteRes = $this->remoteGateway->createRecord($activeInstance, $module, $input);
597                    $data = $remoteRes['data'] ?? $remoteRes;
598                    return $this->jsonSuccess($this->psr17, is_array($data) ? $data : ['id' => $data], 201);
599                }
600            }
601
602            $newId = $this->crudService->create($module, $input, $context);
603
604            $queryParams  = $request->getQueryParams();
605            $sourceModule = (string) ($input['source_module'] ?? $queryParams['source_module'] ?? '');
606            $sourceId     = (int) ($input['source_id'] ?? $queryParams['source_id'] ?? 0);
607            if ($sourceModule !== '' && $sourceId > 0 && $this->relationMmManager !== null) {
608                $this->relationMmManager->linkRecords(
609                    $sourceModule,
610                    $sourceId,
611                    $module,
612                    $newId,
613                    $context->actorUserId
614                );
615            }
616
617            return $this->jsonSuccess($this->psr17, ['id' => $newId], 201);
618        } catch (\Throwable $e) {
619            return $this->handleException($e);
620        }
621    }
622
623    /**
624     * Handles PUT /api/v1/engine/{module}/{id} for record update.
625     *
626     * @param ServerRequestInterface $request PSR-7 request with JSON body.
627     * @param string                 $module  Module name from routing.
628     * @param int                    $id      Record identifier.
629     * @param PermissionContext      $context Security context.
630     * @return ResponseInterface JSON response.
631     */
632    public function actionUpdate(
633        ServerRequestInterface $request,
634        string                 $module,
635        int                    $id,
636        PermissionContext      $context
637    ): ResponseInterface {
638        try {
639            $input = $this->parseJsonBody($request);
640
641            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
642                $activeInstance = $this->instanceContextManager->getActiveInstance();
643                if ($activeInstance !== null) {
644                    $remoteRes = $this->remoteGateway->updateRecord($activeInstance, $module, $id, $input);
645                    $data = $remoteRes['data'] ?? $remoteRes;
646                    return $this->jsonSuccess($this->psr17, is_array($data) ? $data : ['id' => $id, 'updated' => true]);
647                }
648            }
649
650            $this->crudService->update($module, $id, $input, $context);
651
652            return $this->jsonSuccess($this->psr17, ['id' => $id, 'updated' => true]);
653        } catch (\Throwable $e) {
654            return $this->handleException($e);
655        }
656    }
657
658    /**
659     * Handles DELETE /api/v1/engine/{module}/{id} for record deletion.
660     *
661     * @param string            $module  Module name from routing.
662     * @param int               $id      Record primary key.
663     * @param PermissionContext $context Security context.
664     * @return ResponseInterface JSON empty success response.
665     */
666    public function actionDelete(
667        string            $module,
668        int               $id,
669        PermissionContext $context
670    ): ResponseInterface {
671        try {
672            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
673                $activeInstance = $this->instanceContextManager->getActiveInstance();
674                if ($activeInstance !== null) {
675                    $this->remoteGateway->deleteRecord($activeInstance, $module, $id);
676                    return $this->jsonSuccess($this->psr17, []);
677                }
678            }
679
680            $this->crudService->delete($module, $id, $context);
681
682            return $this->jsonSuccess($this->psr17, []);
683        } catch (\Throwable $e) {
684            return $this->handleException($e);
685        }
686    }
687
688    /**
689     * Handles lifecycle status transition for a record (1=Available, 2=Archived, 3=Trash).
690     *
691     * @param string            $module  Module machine name.
692     * @param int               $id      Record primary key.
693     * @param int               $status  Target status code.
694     * @param PermissionContext $context Security context.
695     * @return ResponseInterface JSON success response.
696     */
697    public function actionStatus(
698        string            $module,
699        int               $id,
700        int               $status,
701        PermissionContext $context
702    ): ResponseInterface {
703        try {
704            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
705                $activeInstance = $this->instanceContextManager->getActiveInstance();
706                if ($activeInstance !== null) {
707                    $this->remoteGateway->updateStatus($activeInstance, $module, $id, $status);
708                    return $this->jsonSuccess($this->psr17, ['id' => $id, 'record_status' => $status]);
709                }
710            }
711
712            $this->crudService->updateRecordStatus($module, $id, $status, $context);
713
714            return $this->jsonSuccess($this->psr17, ['id' => $id, 'record_status' => $status]);
715        } catch (\Throwable $e) {
716            return $this->handleException($e);
717        }
718    }
719
720    /**
721     * Handles audit timeline event stream requests for a record.
722     *
723     * @param string                      $module  Module machine name.
724     * @param int                         $id      Record primary key.
725     * @param PermissionContext           $context Security context.
726     * @param ServerRequestInterface|null $request HTTP request.
727     * @return ResponseInterface JSON API response.
728     */
729    public function actionTimeline(
730        string                  $module,
731        int                     $id,
732        PermissionContext       $context,
733        ?ServerRequestInterface $request = null,
734    ): ResponseInterface {
735        try {
736            $params = $request !== null ? $request->getQueryParams() : [];
737            $page = max(1, (int) ($params['page'] ?? 1));
738            $limit = max(1, min(100, (int) ($params['limit'] ?? 20)));
739
740            $timeline = $this->crudService->getTimeline($module, $id, $context);
741            $totalCount = count($timeline);
742            $offset = ($page - 1) * $limit;
743            $pagedEvents = array_slice($timeline, $offset, $limit);
744            $hasMore = ($offset + $limit) < $totalCount;
745
746            return $this->jsonSuccess($this->psr17, [
747                'module'      => $module,
748                'record_id'   => $id,
749                'count'       => count($pagedEvents),
750                'total_count' => $totalCount,
751                'page'        => $page,
752                'limit'       => $limit,
753                'has_more'    => $hasMore,
754                'events'      => $pagedEvents,
755            ]);
756        } catch (\Throwable $e) {
757            return $this->handleException($e);
758        }
759    }
760
761    /**
762     * Handles GET /api/v1/engine/{module}/fields for fetching field definitions of a module.
763     *
764     * @param string            $moduleNameOrId Module machine name or numeric ID.
765     * @param PermissionContext $context        Security context.
766     * @return ResponseInterface JSON list of fields.
767     */
768    public function actionFields(
769        string            $moduleNameOrId,
770        PermissionContext $context
771    ): ResponseInterface {
772        try {
773            if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
774                $activeInstance = $this->instanceContextManager->getActiveInstance();
775                if ($activeInstance !== null) {
776                    $remoteRes = $this->remoteGateway->fetchFields($activeInstance, $moduleNameOrId);
777                    /** @var array<mixed> */
778                    $data = $remoteRes['data'] ?? $remoteRes['fields'] ?? $remoteRes;
779                    return $this->jsonSuccess($this->psr17, $data);
780                }
781            }
782
783            $meta = $this->crudService->getMetadataRepository();
784            $module = ctype_digit($moduleNameOrId)
785                ? $meta->findModuleById((int) $moduleNameOrId)
786                : $meta->findModule($moduleNameOrId);
787
788            $this->crudService->getGuard()->assertReadAccess($module, $context);
789            $fields = $meta->findFields($module->id);
790            $data = array_map(static fn(FieldMetadata $f): array => $f->toArray(), $fields);
791
792            return $this->jsonSuccess($this->psr17, $data);
793        } catch (\Throwable $e) {
794            return $this->handleException($e);
795        }
796    }
797
798    /**
799     * Fetches record hierarchy tree and related records count.
800     *
801     * @param string            $moduleName Module machine name.
802     * @param int               $id         Record primary key.
803     * @param PermissionContext $context    Security context.
804     * @return array<string, mixed> Hierarchical tree payload.
805     */
806    public function fetchHierarchy(
807        string            $moduleName,
808        int               $id,
809        PermissionContext $context
810    ): array {
811        return $this->relationsApiController->fetchHierarchy($moduleName, $id, $context);
812    }
813
814    /**
815     * Handles GET /api/v1/engine/{module}/{id}/hierarchy.
816     *
817     * @param string            $moduleName Module machine name.
818     * @param int               $id         Record primary key.
819     * @param PermissionContext $context    Security context.
820     * @return ResponseInterface JSON response with tree payload.
821     */
822    public function actionHierarchy(
823        string            $moduleName,
824        int               $id,
825        PermissionContext $context
826    ): ResponseInterface {
827        return $this->relationsApiController->actionHierarchy($moduleName, $id, $context);
828    }
829
830    /**
831     * Handles POST /api/v1/engine/{module}/{id}/favorite.
832     * Toggles per-user favorite state for a record.
833     *
834     * @param string            $moduleName Module machine name.
835     * @param int               $id         Record primary key.
836     * @param PermissionContext $context    Security context.
837     * @return ResponseInterface JSON response with new is_favorite state.
838     */
839    public function actionToggleFavorite(
840        string            $moduleName,
841        int               $id,
842        PermissionContext $context
843    ): ResponseInterface {
844        return $this->relationsApiController->actionToggleFavorite($moduleName, $id, $context);
845    }
846
847    /**
848     * Handles POST /api/v1/engine/{module}/{id}/pin.
849     * Toggles shared pinned state for a record.
850     *
851     * @param string            $moduleName Module machine name.
852     * @param int               $id         Record primary key.
853     * @param PermissionContext $context    Security context.
854     * @return ResponseInterface JSON response with new is_pinned state.
855     */
856    public function actionTogglePin(
857        string            $moduleName,
858        int               $id,
859        PermissionContext $context
860    ): ResponseInterface {
861        return $this->relationsApiController->actionTogglePin($moduleName, $id, $context);
862    }
863
864    /**
865     * Handles GET /api/v1/engine/{module}/relation-options.
866     * Searches relation options for autocomplete and modal pickers with cycle exclusion.
867     *
868     * @param ServerRequestInterface $request    PSR-7 request.
869     * @param string                 $moduleName Source module name.
870     * @param PermissionContext      $context    Security context.
871     * @return ResponseInterface JSON response with matching items.
872     */
873    public function actionRelationOptions(
874        ServerRequestInterface $request,
875        string                 $moduleName,
876        PermissionContext      $context
877    ): ResponseInterface {
878        return $this->relationsApiController->actionRelationOptions($request, $moduleName, $context);
879    }
880
881    /**
882     * Lists active bulk actions configured for the module.
883     *
884     * @param string            $moduleName Module machine name.
885     * @param PermissionContext $context    Security context.
886     * @return ResponseInterface JSON response with bulk actions array.
887     */
888    public function actionBulkActionsList(string $moduleName, PermissionContext $context): ResponseInterface
889    {
890        return $this->bulkApiController->actionBulkActionsList($moduleName, $context);
891    }
892
893    /**
894     * Lists all picklist / editable fields suitable for bulk editing in the module.
895     *
896     * @param string            $moduleName Module machine name.
897     * @param PermissionContext $context    Security context.
898     * @return ResponseInterface JSON response with editable fields array.
899     */
900    public function actionBulkEditableFields(string $moduleName, PermissionContext $context): ResponseInterface
901    {
902        return $this->bulkApiController->actionBulkEditableFields($moduleName, $context);
903    }
904
905    /**
906     * Enqueues a mass bulk action (edit, archive, restore, delete) for background execution.
907     *
908     * @param ServerRequestInterface $request    PSR-7 request.
909     * @param string                 $moduleName Module machine name.
910     * @param PermissionContext      $context    Security context.
911     * @return ResponseInterface JSON response with job details.
912     */
913    public function actionBulkAction(
914        ServerRequestInterface $request,
915        string $moduleName,
916        PermissionContext $context
917    ): ResponseInterface {
918        return $this->bulkApiController->actionBulkAction($request, $moduleName, $context);
919    }
920
921    /**
922     * Fetches first linked email record for backward compatibility.
923     *
924     * @param int $ticketId Target ticket record ID.
925     * @return array<string, mixed>|null First linked email record or null.
926     */
927    public function fetchLinkedEmailForTicket(int $ticketId): ?array
928    {
929        return $this->emailLinkService->fetchLinkedEmailsForRecord('tickets', $ticketId)[0] ?? null;
930    }
931
932    /**
933     * Fetches email records linked to a specific record (tickets, contacts, companies).
934     *
935     * @param string $moduleName Module machine name.
936     * @param int    $recordId   Target record ID.
937     * @return array<int, array<string, mixed>> List of linked email records.
938     */
939    public function fetchLinkedEmailsForRecord(string $moduleName, int $recordId): array
940    {
941        return $this->emailLinkService->fetchLinkedEmailsForRecord($moduleName, $recordId);
942    }
943
944    /**
945     * Backward-compatible alias for fetching ticket-linked emails.
946     */
947    public function fetchLinkedEmailsForTicket(int $ticketId): array
948    {
949        return $this->emailLinkService->fetchLinkedEmailsForRecord('tickets', $ticketId);
950    }
951
952    /**
953     * Rescans existing unlinked emails and attaches matching messages to contact, company, or ticket.
954     *
955     * @param string $moduleName Module machine name ('contacts', 'companies', 'tickets').
956     * @param int    $recordId   Source record ID.
957     * @return array{matched_count: int, linked_emails: array<int, array<string, mixed>>} Rescan outcome.
958     */
959    public function rescanAndLinkEmailsForRecord(string $moduleName, int $recordId): array
960    {
961        return $this->emailLinkService->rescanAndLinkEmailsForRecord($moduleName, $recordId);
962    }
963
964    /**
965     * Unlinks an email message from a record.
966     *
967     * @param string $moduleName Module machine name.
968     * @param int    $recordId   Target record ID.
969     * @param int    $emailId    Target email record ID to unlink.
970     * @return bool True if record was updated.
971     */
972    public function unlinkEmailFromRecord(string $moduleName, int $recordId, int $emailId): bool
973    {
974        return $this->emailLinkService->unlinkEmailFromRecord($moduleName, $recordId, $emailId);
975    }
976
977    /**
978     * Backward-compatible alias for unlinking ticket email.
979     */
980    public function unlinkEmailFromTicket(int $ticketId, int $emailId): bool
981    {
982        return $this->emailLinkService->unlinkEmailFromRecord('tickets', $ticketId, $emailId);
983    }
984
985    /**
986     * Links an email message to a record.
987     *
988     * @param string $moduleName Module machine name.
989     * @param int    $recordId   Target record ID.
990     * @param int    $emailId    Target email record ID to link.
991     * @return bool True if record was updated.
992     */
993    public function linkEmailToRecord(string $moduleName, int $recordId, int $emailId): bool
994    {
995        return $this->emailLinkService->linkEmailToRecord($moduleName, $recordId, $emailId);
996    }
997
998    /**
999     * Backward-compatible alias for linking ticket email.
1000     */
1001    public function linkEmailToTicket(int $ticketId, int $emailId): bool
1002    {
1003        return $this->emailLinkService->linkEmailToRecord('tickets', $ticketId, $emailId);
1004    }
1005
1006    /**
1007     * Fetches candidate email records available to link with a record.
1008     *
1009     * @param string|int  $moduleOrId Target module name or ticket ID.
1010     * @param string|null $query      Optional text search filter.
1011     * @param int         $limit      Maximum records to fetch.
1012     * @return array<int, array<string, mixed>> List of available email candidate records.
1013     */
1014    public function fetchAvailableEmailsToLink(string|int $moduleOrId, ?string $query = null, int $limit = 20): array
1015    {
1016        return $this->emailLinkService->fetchAvailableEmailsToLink($moduleOrId, $query, $limit);
1017    }
1018
1019    /**
1020     * Fetches email context (ticket_no, subject, linked contact email) for compose dialog.
1021     *
1022     * @param string|int $moduleOrId Target module name or ticket ID.
1023     * @param int|null   $recordId   Record primary key.
1024     * @return array<string, mixed>|null Context data or null.
1025     */
1026    public function fetchTicketEmailContext(string|int $moduleOrId, ?int $recordId = null): ?array
1027    {
1028        return $this->emailLinkService->fetchTicketEmailContext($moduleOrId, $recordId);
1029    }
1030
1031    /**
1032     * Fetches active client mailboxes for compose dialog sender selector.
1033     *
1034     * @return array<int, array<string, mixed>> List of active mailboxes.
1035     */
1036    public function fetchActiveMailboxes(): array
1037    {
1038        return $this->emailLinkService->fetchActiveMailboxes();
1039    }
1040
1041    /**
1042     * Maps thrown domain exceptions to PSR-7 JSON responses.
1043     *
1044     * @param \Throwable $e Thrown exception.
1045     * @return ResponseInterface HTTP JSON error response.
1046     */
1047    private function handleException(\Throwable $e): ResponseInterface
1048    {
1049        return $this->handleApiException($this->psr17, $e);
1050    }
1051}