| 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 |
3 | namespace App\Services;
4 |
5 | use CodeIgniter\Shield\Entities\User;
6 | use DateTimeImmutable;
7 |
8 | class 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 | }