Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.26% covered (warning)
89.26%
216 / 242
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AlertService
89.26% covered (warning)
89.26%
216 / 242
46.15% covered (danger)
46.15%
6 / 13
62.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getIndexData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 markNotificationRead
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildFilters
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
4
 resolveDates
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
3.65
 getDynamicAlerts
82.93% covered (warning)
82.93%
68 / 82
0.00% covered (danger)
0.00%
0 / 1
25.63
 getScopedChildren
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getEvaluations
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 latestEvaluationsByChild
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getAttendanceRows
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 getNotifications
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 buildSummary
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 settingsMap
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Services;
4
5use CodeIgniter\Shield\Entities\User;
6use DateTimeImmutable;
7
8class AlertService
9{
10    private AttendanceService $attendanceService;
11    private AuthorizationService $authorizationService;
12
13    public function __construct()
14    {
15        helper(['academy', 'url']);
16
17        $this->attendanceService    = service('attendance');
18        $this->authorizationService = service('authorization');
19    }
20
21    /**
22     * @param array<string, mixed> $input
23     * @return array<string, mixed>
24     */
25    public function getIndexData(array $input, User $user): array
26    {
27        $filters       = $this->buildFilters($input, (int) $user->id);
28        $dynamicAlerts = $this->getDynamicAlerts($filters);
29        $notifications = $this->getNotifications((int) $user->id, $filters['selected_notification_status']);
30
31        return [
32            'pageTitle' => 'Alerte',
33            'pageDescription' => 'Semnale operationale generate din evaluari, prezenta si vechimea ultimei evaluari, plus inbox-ul de notificari.',
34            'filters' => $filters,
35            'summary' => $this->buildSummary($dynamicAlerts, $notifications),
36            'dynamicAlerts' => $dynamicAlerts,
37            'notifications' => $notifications,
38        ];
39    }
40
41    public function markNotificationRead(int $notificationId, User $user): bool
42    {
43        $notification = db_connect()
44            ->table('notifications')
45            ->select('id, user_id')
46            ->where('id', $notificationId)
47            ->get()
48            ->getRowArray();
49
50        if ($notification === null || (int) $notification['user_id'] !== (int) $user->id) {
51            return false;
52        }
53
54        db_connect()
55            ->table('notifications')
56            ->where('id', $notificationId)
57            ->update([
58                'status' => 'read',
59                'read_at' => date('Y-m-d H:i:s'),
60            ]);
61
62        return true;
63    }
64
65    /**
66     * @param array<string, mixed> $input
67     * @return array<string, mixed>
68     */
69    private function buildFilters(array $input, int $userId): array
70    {
71        $settings      = $this->settingsMap();
72        $role          = $this->authorizationService->getPrimaryRole($userId);
73        $lockedTrainerId = $role === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
74        $period        = (string) ($input['period'] ?? ($settings['default_dashboard_period'] ?? '30'));
75        [$dateStart, $dateEnd] = $this->resolveDates($period, new DateTimeImmutable('today'));
76        $selectedType = (string) ($input['type'] ?? 'all');
77        $notificationStatus = (string) ($input['notification_status'] ?? 'all');
78
79        if (! in_array($selectedType, ['all', 'ready', 'almost', 'attendance', 'overdue'], true)) {
80            $selectedType = 'all';
81        }
82
83        if (! in_array($notificationStatus, ['all', 'unread', 'read'], true)) {
84            $notificationStatus = 'all';
85        }
86
87        return [
88            'current_role' => $role,
89            'coach_locked' => $lockedTrainerId !== null,
90            'selected_period' => $period,
91            'selected_type' => $selectedType,
92            'selected_notification_status' => $notificationStatus,
93            'date_start' => $dateStart,
94            'date_end' => $dateEnd,
95            'type_options' => [
96                'all' => 'Toate alertele',
97                'ready' => 'Ready',
98                'almost' => 'Almost Ready',
99                'attendance' => 'Absente ridicate',
100                'overdue' => 'Fara evaluare recenta',
101            ],
102            'notification_status_options' => [
103                'all' => 'Toate notificarile',
104                'unread' => 'Unread',
105                'read' => 'Read',
106            ],
107            'period_options' => [
108                '7' => 'Ultimele 7 zile',
109                '30' => 'Ultimele 30 zile',
110                '90' => 'Ultimele 90 zile',
111                'month' => 'Luna curenta',
112            ],
113            'locked_coach_id' => $lockedTrainerId,
114            'no_evaluation_days' => (int) ($settings['no_evaluation_days'] ?? 45),
115            'absence_alert_threshold' => (int) ($settings['absence_alert_threshold'] ?? 25),
116        ];
117    }
118
119    /**
120     * @return array{0: string, 1: string}
121     */
122    private function resolveDates(string $selectedPeriod, DateTimeImmutable $today): array
123    {
124        if ($selectedPeriod === 'month') {
125            return [
126                $today->modify('first day of this month')->format('Y-m-d'),
127                $today->format('Y-m-d'),
128            ];
129        }
130
131        $days = (int) $selectedPeriod;
132        if (! in_array($days, [7, 30, 90], true)) {
133            $days = 30;
134        }
135
136        return [
137            $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d'),
138            $today->format('Y-m-d'),
139        ];
140    }
141
142    /**
143     * @param array<string, mixed> $filters
144     * @return list<array<string, mixed>>
145     */
146    private function getDynamicAlerts(array $filters): array
147    {
148        $children       = $this->getScopedChildren($filters['locked_coach_id']);
149        $childIds       = array_values(array_map(static fn (array $child): int => (int) $child['id'], $children));
150        $allEvaluations = $this->getEvaluations($childIds, null, null);
151        $latestFull     = $this->latestEvaluationsByChild($allEvaluations, 'full-evaluation');
152        $latestAny      = $this->latestEvaluationsByChild($allEvaluations, null);
153        $attendanceRows = $this->getAttendanceRows($childIds, $filters['date_start'], $filters['date_end']);
154        $attendanceByChild = [];
155
156        foreach ($attendanceRows as $row) {
157            $childId = (int) $row['child_id'];
158            if (! isset($attendanceByChild[$childId])) {
159                $attendanceByChild[$childId] = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
160            }
161
162            $attendanceByChild[$childId][$row['status']]++;
163        }
164
165        $alerts = [];
166
167        foreach ($children as $child) {
168            if ($child['status'] === 'left') {
169                continue;
170            }
171
172            $childId   = (int) $child['id'];
173            $childName = (string) $child['full_name'];
174            $link      = url_to('children.show', $childId);
175            $latest    = $latestFull[$childId] ?? null;
176
177            if ($latest && $latest['final_status'] === 'READY') {
178                $alerts[] = [
179                    'type' => 'ready',
180                    'badge' => 'PROMOTION_READY',
181                    'title' => 'Ready pentru avansare',
182                    'child_name' => $childName,
183                    'description' => 'Ultimul full evaluation este READY cu scor ' . format_score($latest['final_score']) . '.',
184                    'meta' => ($child['level_name'] ?: '-') . ' / ' . ($child['coach_name'] ?: '-'),
185                    'url' => $link,
186                ];
187            } elseif ($latest && $latest['final_status'] === 'ALMOST READY') {
188                $alerts[] = [
189                    'type' => 'almost',
190                    'badge' => 'ALMOST_READY',
191                    'title' => 'Aproape gata',
192                    'child_name' => $childName,
193                    'description' => 'Copilul este aproape de pragul READY in ultimul full evaluation.',
194                    'meta' => ($child['level_name'] ?: '-') . ' / ' . ($child['coach_name'] ?: '-'),
195                    'url' => $link,
196                ];
197            }
198
199            $attendance = $attendanceByChild[$childId] ?? ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
200            if (array_sum($attendance) > 0) {
201                $attendanceRate = $this->attendanceService->calculateRateFromBreakdown($attendance);
202                $absenceRate    = 100 - $attendanceRate;
203
204                if ($absenceRate >= (float) $filters['absence_alert_threshold']) {
205                    $alerts[] = [
206                        'type' => 'attendance',
207                        'badge' => 'ATTENDANCE_ALERT',
208                        'title' => 'Absente ridicate',
209                        'child_name' => $childName,
210                        'description' => 'Absenta estimata la ' . format_percentage($absenceRate, 1) . ' in perioada selectata.',
211                        'meta' => ($child['group_name'] ?: '-') . ' / ' . ($child['coach_name'] ?: '-'),
212                        'url' => $link,
213                    ];
214                }
215            }
216
217            $latestEvaluation = $latestAny[$childId] ?? null;
218            if (! $latestEvaluation) {
219                continue;
220            }
221
222            $daysSinceEvaluation = (new DateTimeImmutable($filters['date_end']))->diff(new DateTimeImmutable($latestEvaluation['evaluation_date']))->days;
223            if ($daysSinceEvaluation >= (int) $filters['no_evaluation_days']) {
224                $alerts[] = [
225                    'type' => 'overdue',
226                    'badge' => 'EVALUATION_OVERDUE',
227                    'title' => 'Fara evaluare recenta',
228                    'child_name' => $childName,
229                    'description' => 'Ultima evaluare a fost inregistrata acum ' . $daysSinceEvaluation . ' zile.',
230                    'meta' => ($child['level_name'] ?: '-') . ' / ' . ($child['coach_name'] ?: '-'),
231                    'url' => $link,
232                ];
233            }
234        }
235
236        if ($filters['selected_type'] !== 'all') {
237            $alerts = array_values(array_filter(
238                $alerts,
239                static fn (array $alert): bool => $alert['type'] === $filters['selected_type'],
240            ));
241        }
242
243        usort($alerts, static function (array $left, array $right): int {
244            $priority = ['ready' => 1, 'almost' => 2, 'attendance' => 3, 'overdue' => 4];
245            $leftRank = $priority[$left['type']] ?? 99;
246            $rightRank = $priority[$right['type']] ?? 99;
247
248            if ($leftRank === $rightRank) {
249                return strcmp($left['child_name'], $right['child_name']);
250            }
251
252            return $leftRank <=> $rightRank;
253        });
254
255        return $alerts;
256    }
257
258    /**
259     * @return list<array<string, mixed>>
260     */
261    private function getScopedChildren(?int $lockedTrainerId): array
262    {
263        $builder = db_connect()
264            ->table('children c')
265            ->select('c.id, c.full_name, c.status, levels.name as level_name, groups.name as group_name, coaches.full_name as coach_name')
266            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
267            ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
268            ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
269            ->orderBy('c.full_name', 'ASC');
270
271        if ($lockedTrainerId !== null) {
272            $builder->where('c.primary_coach_id', $lockedTrainerId);
273        }
274
275        return $builder->get()->getResultArray();
276    }
277
278    /**
279     * @param list<int> $childIds
280     * @return list<array<string, mixed>>
281     */
282    private function getEvaluations(array $childIds, ?string $dateStart, ?string $dateEnd): array
283    {
284        if ($childIds === []) {
285            return [];
286        }
287
288        $builder = db_connect()
289            ->table('evaluations e')
290            ->select('e.child_id, e.evaluation_date, e.final_score, e.final_status, types.slug as type_slug')
291            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
292            ->whereIn('e.child_id', $childIds)
293            ->orderBy('e.evaluation_date', 'DESC')
294            ->orderBy('e.id', 'DESC');
295
296        if ($dateStart !== null && $dateEnd !== null) {
297            $builder->where('e.evaluation_date >=', $dateStart)->where('e.evaluation_date <=', $dateEnd);
298        }
299
300        return $builder->get()->getResultArray();
301    }
302
303    /**
304     * @param list<array<string, mixed>> $evaluations
305     * @return array<int, array<string, mixed>>
306     */
307    private function latestEvaluationsByChild(array $evaluations, ?string $typeSlug): array
308    {
309        $latest = [];
310
311        foreach ($evaluations as $evaluation) {
312            if ($typeSlug !== null && $evaluation['type_slug'] !== $typeSlug) {
313                continue;
314            }
315
316            $childId = (int) $evaluation['child_id'];
317            if (! isset($latest[$childId])) {
318                $latest[$childId] = $evaluation;
319            }
320        }
321
322        return $latest;
323    }
324
325    /**
326     * @param list<int> $childIds
327     * @return list<array<string, mixed>>
328     */
329    private function getAttendanceRows(array $childIds, string $dateStart, string $dateEnd): array
330    {
331        if ($childIds === []) {
332            return [];
333        }
334
335        return db_connect()
336            ->table('attendance_records ar')
337            ->select('ar.child_id, ar.status')
338            ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
339            ->whereIn('ar.child_id', $childIds)
340            ->where('sessions.is_cancelled', 0)
341            ->where('sessions.session_date >=', $dateStart)
342            ->where('sessions.session_date <=', $dateEnd)
343            ->get()
344            ->getResultArray();
345    }
346
347    /**
348     * @return list<array<string, mixed>>
349     */
350    private function getNotifications(int $userId, string $selectedStatus): array
351    {
352        $builder = db_connect()
353            ->table('notifications n')
354            ->select('n.*, children.full_name as child_name')
355            ->join('children', 'children.id = n.child_id', 'left')
356            ->where('n.user_id', $userId)
357            ->orderBy("CASE WHEN n.status = 'unread' THEN 0 ELSE 1 END", '', false)
358            ->orderBy('n.created_at', 'DESC');
359
360        if ($selectedStatus !== 'all') {
361            $builder->where('n.status', $selectedStatus);
362        }
363
364        $rows = $builder->get()->getResultArray();
365
366        foreach ($rows as &$row) {
367            $row['resolved_url'] = $row['action_url'] ?: (! empty($row['child_id']) ? url_to('children.show', $row['child_id']) : url_to('dashboard'));
368        }
369        unset($row);
370
371        return $rows;
372    }
373
374    /**
375     * @param list<array<string, mixed>> $dynamicAlerts
376     * @param list<array<string, mixed>> $notifications
377     * @return list<array<string, mixed>>
378     */
379    private function buildSummary(array $dynamicAlerts, array $notifications): array
380    {
381        $byType = ['ready' => 0, 'almost' => 0, 'attendance' => 0, 'overdue' => 0];
382        foreach ($dynamicAlerts as $alert) {
383            $type = (string) $alert['type'];
384            if (isset($byType[$type])) {
385                $byType[$type]++;
386            }
387        }
388
389        $unread = count(array_filter($notifications, static fn (array $row): bool => $row['status'] === 'unread'));
390
391        return [
392            ['label' => 'Alerte dinamice', 'value' => count($dynamicAlerts), 'meta' => 'Generate in timp real din DB'],
393            ['label' => 'Unread notifications', 'value' => $unread, 'meta' => 'Inbox-ul utilizatorului curent'],
394            ['label' => 'Ready', 'value' => $byType['ready'], 'meta' => 'Copii gata pentru discutie de avansare'],
395            ['label' => 'Attendance alerts', 'value' => $byType['attendance'], 'meta' => 'Absente peste pragul configurat'],
396        ];
397    }
398
399    /**
400     * @return array<string, string>
401     */
402    private function settingsMap(): array
403    {
404        $rows = db_connect()
405            ->table('settings')
406            ->select('`key`, value')
407            ->get()
408            ->getResultArray();
409
410        $settings = [];
411        foreach ($rows as $row) {
412            $settings[$row['key']] = $row['value'];
413        }
414
415        return $settings;
416    }
417}