Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
DashboardWidgetsService
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
11 / 11
34
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
 isCalendarAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentCalendarEvents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCurrentCalendarEventsCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getOverdueCalendarEvents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getOverdueCalendarEventsCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getAvailableWidgets
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 getInactiveWidgets
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getActiveWidgetKeys
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 toggleWidget
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 resolveActivePdo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
13use App\Core\Preference\Domain\Model\PreferenceScope;
14use App\Core\Preference\Domain\Model\UserPreference;
15use App\Core\Preference\Domain\Repository\UserPreferenceRepositoryInterface;
16use App\Modules\Dashboard\Application\Service\Widget\CalendarWidgetDataLoader;
17use PDO;
18use Throwable;
19
20/**
21 * Dashboard Widgets Service.
22 *
23 * Manages calendar dashboard widgets data extraction, active widget state resolution,
24 * and user preference persistence for dashboard tiles.
25 *
26 * @package App\Modules\Dashboard\Application\Service
27 */
28final readonly class DashboardWidgetsService implements DashboardWidgetsServiceInterface
29{
30    public const string PREF_KEY = 'dashboard.widgets';
31    public const string MODULE_NAME = 'dashboard';
32    public const string WIDGET_CURRENT = 'current_calendar_events';
33    public const string WIDGET_OVERDUE = 'overdue_calendar_events';
34
35    public const array DEFAULT_ACTIVE_WIDGETS = [
36        self::WIDGET_CURRENT,
37        self::WIDGET_OVERDUE,
38    ];
39
40    private CalendarWidgetDataLoader $calendarDataLoader;
41
42    /**
43     * DashboardWidgetsService constructor.
44     *
45     * @param PDO                                   $pdo                    Primary database PDO connection.
46     * @param UserPreferenceRepositoryInterface    $preferenceRepository   User preference repository.
47     * @param InstanceContextManagerInterface|null  $instanceContextManager Optional instance context manager.
48     * @param PDO|null                              $clientPdo              Optional client database connection.
49     * @param string                                $tablePrefix            Database table prefix.
50     * @param string                                $appProfile             Application profile ('admin'|'client').
51     * @param CalendarWidgetDataLoader|null         $calendarDataLoader     Calendar widget data loader helper.
52     */
53    public function __construct(
54        private PDO $pdo,
55        private UserPreferenceRepositoryInterface $preferenceRepository,
56        private ?InstanceContextManagerInterface $instanceContextManager = null,
57        private ?PDO $clientPdo = null,
58        private string $tablePrefix = 'a_',
59        private string $appProfile = 'client',
60        ?CalendarWidgetDataLoader $calendarDataLoader = null
61    ) {
62        $this->calendarDataLoader = $calendarDataLoader ?? new CalendarWidgetDataLoader($this->tablePrefix);
63    }
64
65    /**
66     * Checks whether calendar features and widgets are active for the deployment profile.
67     *
68     * @return bool True if calendar module is available (client profile).
69     */
70    public function isCalendarAvailable(): bool
71    {
72        return $this->appProfile !== 'admin';
73    }
74
75    /**
76     * Returns current ongoing or upcoming calendar events.
77     *
78     * @param PermissionContext $context Security permission context.
79     * @param int               $limit   Maximum records to return.
80     * @return array<int, array<string, mixed>> List of current events.
81     */
82    public function getCurrentCalendarEvents(PermissionContext $context, int $limit = 5): array
83    {
84        if (!$this->isCalendarAvailable() || $context->actorUserId <= 0) {
85            return [];
86        }
87
88        $pdo = $this->resolveActivePdo();
89        return $this->calendarDataLoader->getCurrentCalendarEvents($pdo, $context->actorUserId, $limit);
90    }
91
92    /**
93     * Returns total count of current ongoing or upcoming calendar events.
94     *
95     * @param PermissionContext $context Security permission context.
96     * @return int Total count of upcoming calendar events.
97     */
98    public function getCurrentCalendarEventsCount(PermissionContext $context): int
99    {
100        if (!$this->isCalendarAvailable() || $context->actorUserId <= 0) {
101            return 0;
102        }
103
104        $pdo = $this->resolveActivePdo();
105        return $this->calendarDataLoader->getCurrentCalendarEventsCount($pdo, $context->actorUserId);
106    }
107
108    /**
109     * Returns overdue calendar events.
110     *
111     * @param PermissionContext $context Security permission context.
112     * @param int               $limit   Maximum records to return.
113     * @return array<int, array<string, mixed>> List of overdue events.
114     */
115    public function getOverdueCalendarEvents(PermissionContext $context, int $limit = 5): array
116    {
117        if (!$this->isCalendarAvailable() || $context->actorUserId <= 0) {
118            return [];
119        }
120
121        $pdo = $this->resolveActivePdo();
122        return $this->calendarDataLoader->getOverdueCalendarEvents($pdo, $context->actorUserId, $limit);
123    }
124
125    /**
126     * Returns total count of overdue calendar events requiring immediate attention.
127     *
128     * @param PermissionContext $context Security permission context.
129     * @return int Total count of overdue calendar events.
130     */
131    public function getOverdueCalendarEventsCount(PermissionContext $context): int
132    {
133        if (!$this->isCalendarAvailable() || $context->actorUserId <= 0) {
134            return 0;
135        }
136
137        $pdo = $this->resolveActivePdo();
138        return $this->calendarDataLoader->getOverdueCalendarEventsCount($pdo, $context->actorUserId);
139    }
140
141    /**
142     * Returns all available widget catalog definitions with active status for user.
143     *
144     * @param int               $userId  Active user ID.
145     * @param PermissionContext $context Permission context for count retrieval.
146     * @return array<string, array<string, mixed>> Catalog of available widgets.
147     */
148    public function getAvailableWidgets(int $userId, PermissionContext $context): array
149    {
150        if (!$this->isCalendarAvailable()) {
151            return [];
152        }
153
154        $activeKeys = $this->getActiveWidgetKeys($userId);
155        $currentCount = $this->getCurrentCalendarEventsCount($context);
156        $overdueCount = $this->getOverdueCalendarEventsCount($context);
157
158        return [
159            self::WIDGET_CURRENT => [
160                'key'         => self::WIDGET_CURRENT,
161                'name'        => self::WIDGET_CURRENT,
162                'title'       => 'Current events',
163                'label'       => 'Current events',
164                'description' => 'Scheduled and ongoing meetings and tasks in the calendar',
165                'icon'        => 'bi bi-calendar-check',
166                'badge_class' => 'bg-primary text-white',
167                'is_active'   => in_array(self::WIDGET_CURRENT, $activeKeys, true),
168                'count'       => $currentCount,
169            ],
170            self::WIDGET_OVERDUE => [
171                'key'         => self::WIDGET_OVERDUE,
172                'name'        => self::WIDGET_OVERDUE,
173                'title'       => 'Overdue events',
174                'label'       => 'Overdue events',
175                'description' => 'Overdue tasks and events past their deadline',
176                'icon'        => 'bi bi-exclamation-triangle-fill',
177                'badge_class' => 'bg-danger text-white',
178                'is_active'   => in_array(self::WIDGET_OVERDUE, $activeKeys, true),
179                'count'       => $overdueCount,
180            ],
181        ];
182    }
183
184    /**
185     * Returns list of inactive widget catalog items for the widget picker.
186     *
187     * @param int               $userId  Active user ID.
188     * @param PermissionContext $context Permission context.
189     * @return array<int, array<string, mixed>> Inactive widget definitions.
190     */
191    public function getInactiveWidgets(int $userId, PermissionContext $context): array
192    {
193        $all = $this->getAvailableWidgets($userId, $context);
194        $inactive = [];
195
196        foreach ($all as $item) {
197            if (!$item['is_active']) {
198                $inactive[] = $item;
199            }
200        }
201
202        return $inactive;
203    }
204
205    /**
206     * Resolves active widget keys for specified user from preferences.
207     *
208     * @param int $userId User ID.
209     * @return list<string> List of active widget keys.
210     */
211    public function getActiveWidgetKeys(int $userId): array
212    {
213        if (!$this->isCalendarAvailable()) {
214            return [];
215        }
216
217        if ($userId > 0) {
218            try {
219                $scope = new PreferenceScope(userId: $userId, moduleName: self::MODULE_NAME);
220                $raw = $this->preferenceRepository->findValue($scope, self::PREF_KEY);
221
222                if (is_array($raw) && $raw !== []) {
223                    return array_values(array_filter($raw, 'is_string'));
224                }
225            } catch (Throwable) {
226                // Fallback to default
227            }
228        }
229
230        return self::DEFAULT_ACTIVE_WIDGETS;
231    }
232
233    /**
234     * Toggles a widget state (enable/disable) for the current user.
235     *
236     * @param int    $userId    User primary key.
237     * @param string $widgetKey Widget identifier.
238     * @param bool   $enable    True to activate, false to hide.
239     * @return list<string> Updated list of active widget keys.
240     */
241    public function toggleWidget(int $userId, string $widgetKey, bool $enable): array
242    {
243        if (!$this->isCalendarAvailable()) {
244            return [];
245        }
246
247        $current = $this->getActiveWidgetKeys($userId);
248
249        if ($enable) {
250            if (!in_array($widgetKey, $current, true)) {
251                $current[] = $widgetKey;
252            }
253        } else {
254            $current = array_values(array_filter($current, static fn(string $k): bool => $k !== $widgetKey));
255        }
256
257        if ($userId > 0) {
258            $scope = new PreferenceScope(userId: $userId, moduleName: self::MODULE_NAME);
259            $pref = UserPreference::create($scope, self::PREF_KEY, $current);
260            $this->preferenceRepository->save($pref);
261        }
262
263        return $current;
264    }
265
266    /**
267     * Resolves active PDO connection considering remote client instance context.
268     *
269     * @return PDO Active PDO handle.
270     */
271    public function resolveActivePdo(): PDO
272    {
273        if (
274            $this->instanceContextManager !== null
275            && $this->instanceContextManager->isRemote()
276            && $this->clientPdo !== null
277        ) {
278            return $this->clientPdo;
279        }
280
281        return $this->pdo;
282    }
283}