Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.92% covered (warning)
80.92%
123 / 152
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardApiController
80.79% covered (warning)
80.79%
122 / 151
40.00% covered (danger)
40.00%
4 / 10
48.23
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
 actionWidgets
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 actionToggle
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
4.11
 actionCurrentEvents
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
3.23
 actionOverdueEvents
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
3.23
 actionEventDetails
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 actionEventAction
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
6.92
 handleComplete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 handleCancel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 handlePostpone
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
10.29
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\Dashboard\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Modules\Dashboard\Application\Service\DashboardCalendarActionServiceInterface;
13use App\Modules\Dashboard\Application\Service\DashboardWidgetsServiceInterface;
14use App\Shared\Infrastructure\Http\ApiResponseTrait;
15use DateTimeImmutable;
16use Psr\Http\Message\ResponseFactoryInterface;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19use Throwable;
20
21/**
22 * Dashboard REST API Controller.
23 *
24 * Exposes API endpoints for retrieving dashboard widgets, toggling visibility preferences,
25 * listing current and overdue calendar events, and executing quick actions on calendar records.
26 *
27 * Routes:
28 *   GET  /api/v1/dashboard/widgets
29 *   POST /api/v1/dashboard/widgets/toggle
30 *   GET  /api/v1/dashboard/widgets/current-events
31 *   GET  /api/v1/dashboard/widgets/overdue-events
32 *   GET  /api/v1/dashboard/events/{id}
33 *   POST /api/v1/dashboard/events/{id}/action
34 *
35 * @package App\Modules\Dashboard\Presentation\Api
36 */
37final readonly class DashboardApiController implements DashboardApiControllerInterface
38{
39    use ApiResponseTrait;
40
41    /**
42     * DashboardApiController constructor.
43     *
44     * @param DashboardWidgetsServiceInterface        $widgetsService  Dashboard widgets service.
45     * @param DashboardCalendarActionServiceInterface $actionService   Calendar action service.
46     * @param ResponseFactoryInterface                $responseFactory PSR-7 Response factory.
47     */
48    public function __construct(
49        private DashboardWidgetsServiceInterface        $widgetsService,
50        private DashboardCalendarActionServiceInterface $actionService,
51        private ResponseFactoryInterface                $responseFactory,
52    ) {
53    }
54
55    /**
56     * Returns catalog of available dashboard widgets with active status for current user.
57     *
58     * @param ServerRequestInterface $request PSR-7 server request.
59     * @param PermissionContext      $context Security permission context.
60     * @return ResponseInterface JSON API response.
61     */
62    public function actionWidgets(ServerRequestInterface $request, PermissionContext $context): ResponseInterface
63    {
64        try {
65            $userId = $context->actorUserId;
66            $widgets = $this->widgetsService->getAvailableWidgets($userId, $context);
67            $inactive = $this->widgetsService->getInactiveWidgets($userId, $context);
68
69            return $this->buildJsonResponse($this->responseFactory, [
70                'status'  => true,
71                'message' => 'Dashboard widgets retrieved successfully.',
72                'data'    => [
73                    'widgets'          => array_values($widgets),
74                    'inactive_widgets' => $inactive,
75                    'has_inactive'     => count($inactive) > 0,
76                ],
77            ]);
78        } catch (Throwable $e) {
79            return $this->buildJsonResponse($this->responseFactory, [
80                'status'  => false,
81                'message' => $e->getMessage(),
82            ], 500);
83        }
84    }
85
86    /**
87     * Toggles visibility of a dashboard widget for current user.
88     *
89     * @param ServerRequestInterface $request PSR-7 server request.
90     * @param PermissionContext      $context Security permission context.
91     * @return ResponseInterface JSON API response.
92     */
93    public function actionToggle(ServerRequestInterface $request, PermissionContext $context): ResponseInterface
94    {
95        try {
96            $body = (string) $request->getBody();
97            /** @var array<string, mixed> $data */
98            $data = json_decode($body, true) ?? [];
99            $widgetKey = (string) ($data['widget'] ?? '');
100            $enable = (bool) ($data['enable'] ?? false);
101
102            if ($widgetKey === '') {
103                return $this->buildJsonResponse($this->responseFactory, [
104                    'status'  => false,
105                    'message' => 'Missing widget identifier.',
106                ], 422);
107            }
108
109            $userId = $context->actorUserId;
110            $updatedKeys = $this->widgetsService->toggleWidget($userId, $widgetKey, $enable);
111            $inactive = $this->widgetsService->getInactiveWidgets($userId, $context);
112
113            return $this->buildJsonResponse($this->responseFactory, [
114                'status'  => true,
115                'message' => $enable ? 'Widget has been enabled.' : 'Widget has been hidden.',
116                'data'    => [
117                    'active_keys'      => $updatedKeys,
118                    'inactive_widgets' => $inactive,
119                    'has_inactive'     => count($inactive) > 0,
120                ],
121            ]);
122        } catch (Throwable $e) {
123            return $this->buildJsonResponse($this->responseFactory, [
124                'status'  => false,
125                'message' => $e->getMessage(),
126            ], 500);
127        }
128    }
129
130    /**
131     * Returns JSON list of current calendar events.
132     *
133     * @param ServerRequestInterface $request PSR-7 server request.
134     * @param PermissionContext      $context Security permission context.
135     * @return ResponseInterface JSON API response.
136     */
137    public function actionCurrentEvents(
138        ServerRequestInterface $request,
139        PermissionContext      $context
140    ): ResponseInterface {
141        try {
142            $params = $request->getQueryParams();
143            $limit = isset($params['limit']) ? (int) $params['limit'] : 5;
144            $events = $this->widgetsService->getCurrentCalendarEvents($context, $limit);
145            $total = $this->widgetsService->getCurrentCalendarEventsCount($context);
146
147            return $this->buildJsonResponse($this->responseFactory, [
148                'status'  => true,
149                'message' => 'Current events retrieved successfully.',
150                'data'    => [
151                    'events' => $events,
152                    'total'  => $total,
153                ],
154            ]);
155        } catch (Throwable $e) {
156            return $this->buildJsonResponse($this->responseFactory, [
157                'status'  => false,
158                'message' => $e->getMessage(),
159            ], 500);
160        }
161    }
162
163    /**
164     * Returns JSON list of overdue calendar events.
165     *
166     * @param ServerRequestInterface $request PSR-7 server request.
167     * @param PermissionContext      $context Security permission context.
168     * @return ResponseInterface JSON API response.
169     */
170    public function actionOverdueEvents(
171        ServerRequestInterface $request,
172        PermissionContext      $context
173    ): ResponseInterface {
174        try {
175            $params = $request->getQueryParams();
176            $limit = isset($params['limit']) ? (int) $params['limit'] : 5;
177            $events = $this->widgetsService->getOverdueCalendarEvents($context, $limit);
178            $total = $this->widgetsService->getOverdueCalendarEventsCount($context);
179
180            return $this->buildJsonResponse($this->responseFactory, [
181                'status'  => true,
182                'message' => 'Overdue events retrieved successfully.',
183                'data'    => [
184                    'events' => $events,
185                    'total'  => $total,
186                ],
187            ]);
188        } catch (Throwable $e) {
189            return $this->buildJsonResponse($this->responseFactory, [
190                'status'  => false,
191                'message' => $e->getMessage(),
192            ], 500);
193        }
194    }
195
196    /**
197     * Returns event detail summary for the interactive modal.
198     *
199     * @param ServerRequestInterface $request PSR-7 server request.
200     * @param int                    $id      Record identifier.
201     * @param PermissionContext      $context Security permission context.
202     * @return ResponseInterface JSON API response.
203     */
204    public function actionEventDetails(
205        ServerRequestInterface $request,
206        int                    $id,
207        PermissionContext      $context
208    ): ResponseInterface {
209        try {
210            $event = $this->actionService->getEventDetails($id, $context);
211            if ($event === null) {
212                return $this->buildJsonResponse($this->responseFactory, [
213                    'status'  => false,
214                    'message' => 'Nie odnaleziono zdarzenia kalendarzowego.',
215                ], 404);
216            }
217
218            return $this->buildJsonResponse($this->responseFactory, [
219                'status'  => true,
220                'message' => 'Event details retrieved successfully.',
221                'data'    => $event,
222            ]);
223        } catch (Throwable $e) {
224            return $this->buildJsonResponse($this->responseFactory, [
225                'status'  => false,
226                'message' => $e->getMessage(),
227            ], 500);
228        }
229    }
230
231    /**
232     * Handles quick event action (complete, cancel, postpone).
233     *
234     * @param ServerRequestInterface $request PSR-7 server request.
235     * @param int                    $id      Record identifier.
236     * @param PermissionContext      $context Security permission context.
237     * @return ResponseInterface JSON API response.
238     */
239    public function actionEventAction(
240        ServerRequestInterface $request,
241        int                    $id,
242        PermissionContext      $context
243    ): ResponseInterface {
244        try {
245            $body = (string) $request->getBody();
246            /** @var array<string, mixed> $data */
247            $data = json_decode($body, true) ?? [];
248            $action = (string) ($data['action'] ?? '');
249
250            return match ($action) {
251                'complete' => $this->handleComplete($id, $context),
252                'cancel'   => $this->handleCancel($id, $context),
253                'postpone' => $this->handlePostpone($id, $data, $context),
254                default    => $this->buildJsonResponse($this->responseFactory, [
255                    'status'  => false,
256                    'message' => 'Nieznana akcja: ' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8'),
257                ], 422),
258            };
259        } catch (Throwable $e) {
260            return $this->buildJsonResponse($this->responseFactory, [
261                'status'  => false,
262                'message' => $e->getMessage(),
263            ], 500);
264        }
265    }
266
267    /**
268     * Executes complete action.
269     */
270    private function handleComplete(int $id, PermissionContext $context): ResponseInterface
271    {
272        $success = $this->actionService->completeEvent($id, $context);
273        return $this->buildJsonResponse($this->responseFactory, [
274            'status'  => $success,
275            'message' => $success ? 'Event has been marked as completed.' : 'Status update error.',
276            'data'    => ['id' => $id, 'status' => 'completed'],
277        ], $success ? 200 : 400);
278    }
279
280    /**
281     * Executes cancel action.
282     */
283    private function handleCancel(int $id, PermissionContext $context): ResponseInterface
284    {
285        $success = $this->actionService->cancelEvent($id, $context);
286        return $this->buildJsonResponse($this->responseFactory, [
287            'status'  => $success,
288            'message' => $success ? 'Event has been cancelled.' : 'Error cancelling event.',
289            'data'    => ['id' => $id, 'status' => 'cancelled'],
290        ], $success ? 200 : 400);
291    }
292
293    /**
294     * Executes postpone action with relative minutes offset or explicit dates.
295     *
296     * @param array<string, mixed> $data Action payload.
297     */
298    private function handlePostpone(int $id, array $data, PermissionContext $context): ResponseInterface
299    {
300        $newStart = (string) ($data['start_date'] ?? $data['new_start_date'] ?? '');
301        $rawEnd = $data['end_date'] ?? $data['new_end_date'] ?? null;
302        $newEnd = $rawEnd !== null ? (string) $rawEnd : null;
303
304        if ($newStart === '' && isset($data['preset'])) {
305            $presetMap = [
306                '+30min'  => 30,
307                '+60min'  => 60,
308                '+90min'  => 90,
309                '+120min' => 120,
310                '+240min' => 240,
311                '+1day'   => 1440,
312                '+3days'  => 4320,
313                '+7days'  => 10080,
314                '+30days' => 43200,
315            ];
316            $presetKey = (string) $data['preset'];
317            if (isset($presetMap[$presetKey])) {
318                $data['offset_minutes'] = $presetMap[$presetKey];
319            }
320        }
321
322        if ($newStart === '' && isset($data['offset_minutes'])) {
323            $minutes = (int) $data['offset_minutes'];
324            $now = new DateTimeImmutable();
325            $target = $now->modify(sprintf('+%d minutes', $minutes));
326            $newStart = $target->format('Y-m-d H:i:s');
327            $newEnd = $target->modify('+1 hour')->format('Y-m-d H:i:s');
328        }
329
330        if ($newStart === '') {
331            return $this->buildJsonResponse($this->responseFactory, [
332                'status'  => false,
333                'message' => 'New start date or offset in minutes is required.',
334            ], 422);
335        }
336
337        $success = $this->actionService->postponeEvent($id, $newStart, $newEnd, $context);
338
339        return $this->buildJsonResponse($this->responseFactory, [
340            'status'  => $success,
341            'message' => $success ? 'Event schedule has been updated successfully.' : 'Error updating event schedule.',
342            'data'    => [
343                'id'         => $id,
344                'start_date' => $newStart,
345                'end_date'   => $newEnd,
346            ],
347        ], $success ? 200 : 400);
348    }
349}