Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.64% covered (danger)
48.64%
161 / 331
38.89% covered (danger)
38.89%
7 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
CoachesService
48.64% covered (danger)
48.64%
161 / 331
38.89% covered (danger)
38.89%
7 / 18
992.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIndexData
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 getFormData
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
210
 save
68.66% covered (warning)
68.66%
46 / 67
0.00% covered (danger)
0.00%
0 / 1
23.88
 getProfileData
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
5.03
 buildFilters
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getCoachRows
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 buildCoachStats
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 normalizePayload
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 validateCredentials
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
10.41
 normalizeAvailabilityInput
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 defaultAvailability
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 availabilityMap
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 syncAvailability
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getCoachWithUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 availabilityRows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 availabilityRowsForMany
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 db
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services;
4
5use App\Models\CoachAvailabilityModel;
6use App\Models\CoachModel;
7use App\Models\RoleModel;
8use App\Models\UserRoleModel;
9use CodeIgniter\Shield\Entities\User;
10use CodeIgniter\Shield\Models\UserModel;
11
12class CoachesService
13{
14    private AuthorizationService $authorizationService;
15
16    public function __construct()
17    {
18        helper('academy');
19
20        $this->authorizationService = service('authorization');
21    }
22
23    /**
24     * @param array<string, mixed> $input
25     * @return array<string, mixed>
26     */
27    public function getIndexData(array $input, User $user): array
28    {
29        $filters = $this->buildFilters($input, (int) $user->id);
30        $rows    = $this->getCoachRows($filters);
31        $coachIds = array_map(static fn (array $row): int => (int) $row['id'], $rows);
32        $stats   = $this->buildCoachStats($coachIds);
33
34        foreach ($rows as &$row) {
35            $coachId                   = (int) $row['id'];
36            $row['active_children']    = $stats['children'][$coachId] ?? 0;
37            $row['evaluations_count']  = $stats['evaluations'][$coachId] ?? 0;
38            $row['avg_child_score']    = $stats['scores'][$coachId] ?? null;
39            $row['avg_child_score_label'] = format_score($row['avg_child_score']);
40            $row['availability_summary'] = $stats['availability'][$coachId] ?? 'Program neconfigurat';
41        }
42        unset($row);
43
44        return [
45            'pageTitle' => 'Traineri',
46            'pageDescription' => 'Echipa academiei, capacitatea curenta si incarcarea operationala pe trainer.',
47            'filters' => $filters,
48            'coaches' => $rows,
49            'canManage' => $filters['current_role'] === 'admin',
50        ];
51    }
52
53    /**
54     * @param array<string, mixed> $input
55     * @param array<string, string> $errors
56     * @return array<string, mixed>|null
57     */
58    public function getFormData(User $user, ?int $coachId = null, array $input = [], array $errors = []): ?array
59    {
60        $coach = $coachId !== null ? $this->getCoachWithUser($coachId) : null;
61        if ($coachId !== null && $coach === null) {
62            return null;
63        }
64
65        $values = [
66            'full_name' => '',
67            'display_role' => 'Trainer',
68            'phone' => '',
69            'email' => '',
70            'avatar_path' => '',
71            'is_active' => '1',
72            'notes' => '',
73            'username' => '',
74            'password' => '',
75            'password_confirmation' => '',
76            'availability' => $this->defaultAvailability(),
77        ];
78
79        if ($coach !== null) {
80            $values = [
81                'full_name' => $coach['full_name'],
82                'display_role' => $coach['display_role'],
83                'phone' => $coach['phone'],
84                'email' => $coach['email'],
85                'avatar_path' => $coach['avatar_path'],
86                'is_active' => (string) ((int) $coach['is_active']),
87                'notes' => $coach['notes'],
88                'username' => $coach['username'],
89                'password' => '',
90                'password_confirmation' => '',
91                'availability' => $this->availabilityMap($coachId),
92            ];
93        }
94
95        foreach ($input as $key => $value) {
96            if ($key === 'availability' && is_array($value)) {
97                $values['availability'] = $this->normalizeAvailabilityInput($value);
98                continue;
99            }
100
101            if (array_key_exists($key, $values) && is_string($value)) {
102                $values[$key] = trim($value);
103            }
104        }
105
106        return [
107            'pageTitle' => $coachId === null ? 'Trainer nou' : 'Editare trainer',
108            'pageDescription' => $coachId === null
109                ? 'Creeaza profilul de trainer, contul de acces si disponibilitatea saptamanala.'
110                : 'Actualizeaza datele trainerului fara sa pierzi istoricul evaluarilor efectuate.',
111            'coach' => $coach,
112            'values' => $values,
113            'errors' => $errors,
114            'roleOptions' => ['Head Trainer', 'Trainer', 'Assistant Trainer'],
115            'submitUrl' => $coachId === null ? url_to('coaches.create') : url_to('coaches.update', $coachId),
116            'cancelUrl' => $coachId === null ? url_to('coaches.index') : url_to('coaches.show', $coachId),
117            'canManage' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
118        ];
119    }
120
121    /**
122     * @param array<string, mixed> $input
123     * @return array{success: bool, coach_id?: int, errors?: array<string, string>}
124     */
125    public function save(array $input, User $user, ?int $coachId = null): array
126    {
127        $validation = service('validation');
128        $validation->setRules(config('Validation')->coach);
129
130        $payload = $this->normalizePayload($input);
131
132        if (! $validation->run($payload)) {
133            return ['success' => false, 'errors' => $validation->getErrors()];
134        }
135
136        $extraErrors = $this->validateCredentials($payload, $coachId);
137        if ($extraErrors !== []) {
138            return ['success' => false, 'errors' => $extraErrors];
139        }
140
141        $existing = $coachId !== null ? $this->getCoachWithUser($coachId) : null;
142        $userModel = model(UserModel::class);
143        $coachModel = model(CoachModel::class);
144        $roleId = (int) (model(RoleModel::class)->where('slug', 'coach')->first()['id'] ?? 0);
145        $now = date('Y-m-d H:i:s');
146
147        $db = $this->db();
148        $db->transStart();
149
150        if ($existing === null) {
151            $account = new \CodeIgniter\Shield\Entities\User([
152                'username' => $payload['username'],
153                'email' => $payload['email'],
154                'password' => $payload['password'],
155                'active' => (int) $payload['is_active'],
156            ]);
157
158            $userModel->save($account);
159
160            $createdUser = $userModel->findByCredentials(['email' => $payload['email']]);
161            $createdUser?->addGroup('coach');
162
163            if ($createdUser !== null && $roleId > 0) {
164                model(UserRoleModel::class)->insert([
165                    'user_id' => (int) $createdUser->id,
166                    'role_id' => $roleId,
167                    'created_at' => $now,
168                ]);
169            }
170
171            $coachModel->insert([
172                'user_id' => (int) $createdUser?->id,
173                'full_name' => $payload['full_name'],
174                'display_role' => $payload['display_role'],
175                'phone' => $payload['phone'] !== '' ? $payload['phone'] : null,
176                'email' => $payload['email'],
177                'avatar_path' => $payload['avatar_path'] !== '' ? $payload['avatar_path'] : null,
178                'is_active' => (int) $payload['is_active'],
179                'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
180                'created_at' => $now,
181                'updated_at' => $now,
182            ]);
183
184            $coachId = (int) $coachModel->getInsertID();
185        } else {
186            $account = $userModel->withGroups()->find($existing['user_id']);
187            if ($account !== null) {
188                $account->username = $payload['username'];
189                $account->email = $payload['email'];
190                $account->active = (int) $payload['is_active'];
191                if ($payload['password'] !== '') {
192                    $account->password = $payload['password'];
193                }
194                $userModel->save($account);
195            }
196
197            $coachModel->update($coachId, [
198                'full_name' => $payload['full_name'],
199                'display_role' => $payload['display_role'],
200                'phone' => $payload['phone'] !== '' ? $payload['phone'] : null,
201                'email' => $payload['email'],
202                'avatar_path' => $payload['avatar_path'] !== '' ? $payload['avatar_path'] : null,
203                'is_active' => (int) $payload['is_active'],
204                'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
205                'updated_at' => $now,
206            ]);
207        }
208
209        $this->syncAvailability((int) $coachId, $payload['availability'], $now);
210
211        $db->transComplete();
212
213        if (! $db->transStatus()) {
214            return ['success' => false, 'errors' => ['general' => 'Nu am putut salva trainerul.']];
215        }
216
217        return ['success' => true, 'coach_id' => (int) $coachId];
218    }
219
220    /**
221     * @return array<string, mixed>|null
222     */
223    public function getProfileData(int $coachId, User $user): ?array
224    {
225        $coach = $this->getCoachWithUser($coachId);
226        if ($coach === null) {
227            return null;
228        }
229
230        if ($this->authorizationService->userHasRole((int) $user->id, 'coach') && ! $this->authorizationService->userHasRole((int) $user->id, 'admin')) {
231            $currentTrainerId = $this->authorizationService->getCoachIdForUser((int) $user->id);
232            if ($currentTrainerId !== $coachId) {
233                return null;
234            }
235        }
236
237        $children = $this->db()
238            ->table('children')
239            ->select('id, full_name, status, birth_date')
240            ->where('primary_coach_id', $coachId)
241            ->orderBy('full_name', 'ASC')
242            ->get()
243            ->getResultArray();
244
245        $recentEvaluations = $this->db()
246            ->table('evaluations e')
247            ->select('e.evaluation_date, e.final_score, e.final_status, children.full_name as child_name, types.name as type_name, types.slug as type_slug')
248            ->join('children', 'children.id = e.child_id')
249            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
250            ->where('e.evaluator_coach_id', $coachId)
251            ->orderBy('e.evaluation_date', 'DESC')
252            ->orderBy('e.id', 'DESC')
253            ->limit(8)
254            ->get()
255            ->getResultArray();
256
257        $quickChecks = array_values(array_filter($recentEvaluations, static fn (array $row): bool => $row['type_slug'] === 'quick-check'));
258
259        return [
260            'pageTitle' => $coach['full_name'],
261            'pageDescription' => 'Profil operational cu copii asignati, evaluari recente si disponibilitate.',
262            'coach' => $coach,
263            'children' => $children,
264            'recentEvaluations' => $recentEvaluations,
265            'recentQuickChecks' => $quickChecks,
266            'availability' => $this->availabilityRows($coachId),
267            'canManage' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
268        ];
269    }
270
271    /**
272     * @param array<string, mixed> $input
273     * @return array<string, mixed>
274     */
275    private function buildFilters(array $input, int $userId): array
276    {
277        $role = $this->authorizationService->getPrimaryRole($userId);
278        $lockedTrainerId = $role === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
279
280        return [
281            'current_role' => $role,
282            'coach_locked' => $lockedTrainerId !== null,
283            'selected_status' => (string) ($input['status'] ?? 'all'),
284            'query' => trim((string) ($input['q'] ?? '')),
285            'locked_coach_id' => $lockedTrainerId,
286            'status_options' => [
287                'all' => 'Toate statusurile',
288                'active' => 'Activ',
289                'inactive' => 'Inactiv',
290            ],
291        ];
292    }
293
294    /**
295     * @param array<string, mixed> $filters
296     * @return list<array<string, mixed>>
297     */
298    private function getCoachRows(array $filters): array
299    {
300        $builder = $this->db()
301            ->table('coaches c')
302            ->select('c.*, users.username')
303            ->join('users', 'users.id = c.user_id', 'left')
304            ->orderBy('c.full_name', 'ASC');
305
306        if ($filters['locked_coach_id'] !== null) {
307            $builder->where('c.id', $filters['locked_coach_id']);
308        }
309
310        if ($filters['selected_status'] === 'active') {
311            $builder->where('c.is_active', 1);
312        } elseif ($filters['selected_status'] === 'inactive') {
313            $builder->where('c.is_active', 0);
314        }
315
316        if ($filters['query'] !== '') {
317            $builder
318                ->groupStart()
319                ->like('c.full_name', $filters['query'])
320                ->orLike('c.email', $filters['query'])
321                ->orLike('c.display_role', $filters['query'])
322                ->groupEnd();
323        }
324
325        return $builder->get()->getResultArray();
326    }
327
328    /**
329     * @param list<int> $coachIds
330     * @return array<string, array<int, mixed>>
331     */
332    private function buildCoachStats(array $coachIds): array
333    {
334        if ($coachIds === []) {
335            return ['children' => [], 'evaluations' => [], 'scores' => [], 'availability' => []];
336        }
337
338        $children = [];
339        foreach ($this->db()->table('children')->select('primary_coach_id, COUNT(*) as total')->where('status', 'active')->whereIn('primary_coach_id', $coachIds)->groupBy('primary_coach_id')->get()->getResultArray() as $row) {
340            $children[(int) $row['primary_coach_id']] = (int) $row['total'];
341        }
342
343        $evaluations = [];
344        foreach ($this->db()->table('evaluations')->select('evaluator_coach_id, COUNT(*) as total')->whereIn('evaluator_coach_id', $coachIds)->groupBy('evaluator_coach_id')->get()->getResultArray() as $row) {
345            $evaluations[(int) $row['evaluator_coach_id']] = (int) $row['total'];
346        }
347
348        $scores = [];
349        foreach ($this->db()->table('children c')->select('c.primary_coach_id, ROUND(AVG(e.final_score), 2) as avg_score')->join('evaluations e', 'e.child_id = c.id', 'left')->whereIn('c.primary_coach_id', $coachIds)->where('c.status', 'active')->groupBy('c.primary_coach_id')->get()->getResultArray() as $row) {
350            $scores[(int) $row['primary_coach_id']] = $row['avg_score'] !== null ? (float) $row['avg_score'] : null;
351        }
352
353        $availability = [];
354        foreach ($this->availabilityRowsForMany($coachIds) as $coachId => $rows) {
355            $availability[$coachId] = implode(' â€¢ ', array_map(
356                static fn (array $row): string => day_of_week_label((int) $row['day_of_week']) . ' ' . substr((string) $row['start_time'], 0, 5) . '-' . substr((string) $row['end_time'], 0, 5),
357                $rows,
358            ));
359        }
360
361        return compact('children', 'evaluations', 'scores', 'availability');
362    }
363
364    /**
365     * @param array<string, mixed> $input
366     * @return array<string, mixed>
367     */
368    private function normalizePayload(array $input): array
369    {
370        return [
371            'full_name' => trim((string) ($input['full_name'] ?? '')),
372            'display_role' => trim((string) ($input['display_role'] ?? 'Trainer')),
373            'phone' => trim((string) ($input['phone'] ?? '')),
374            'email' => trim((string) ($input['email'] ?? '')),
375            'avatar_path' => trim((string) ($input['avatar_path'] ?? '')),
376            'is_active' => (string) ((int) ($input['is_active'] ?? 1)),
377            'notes' => trim((string) ($input['notes'] ?? '')),
378            'username' => trim((string) ($input['username'] ?? '')),
379            'password' => (string) ($input['password'] ?? ''),
380            'password_confirmation' => (string) ($input['password_confirmation'] ?? ''),
381            'availability' => $this->normalizeAvailabilityInput((array) ($input['availability'] ?? [])),
382        ];
383    }
384
385    /**
386     * @return array<string, string>
387     */
388    private function validateCredentials(array $payload, ?int $coachId): array
389    {
390        $errors = [];
391        $existing = $coachId !== null ? $this->getCoachWithUser($coachId) : null;
392        $ignoreUserId = $existing['user_id'] ?? null;
393
394        if ($coachId === null && trim($payload['password']) === '') {
395            $errors['password'] = 'Parola este obligatorie pentru contul de trainer.';
396        }
397
398        if ($payload['password'] !== '' && $payload['password'] !== $payload['password_confirmation']) {
399            $errors['password_confirmation'] = 'Confirmarea parolei nu corespunde.';
400        }
401
402        $usernameTaken = $this->db()
403            ->table('users')
404            ->select('id')
405            ->where('LOWER(username)', strtolower($payload['username']))
406            ->get()
407            ->getRowArray();
408
409        if ($usernameTaken !== null && (int) $usernameTaken['id'] !== (int) $ignoreUserId) {
410            $errors['username'] = 'Username-ul este deja folosit.';
411        }
412
413        $emailTaken = $this->db()
414            ->table('auth_identities')
415            ->select('user_id')
416            ->where('type', 'email_password')
417            ->where('LOWER(secret)', strtolower($payload['email']))
418            ->get()
419            ->getRowArray();
420
421        if ($emailTaken !== null && (int) $emailTaken['user_id'] !== (int) $ignoreUserId) {
422            $errors['email'] = 'Exista deja un cont cu acest email.';
423        }
424
425        return $errors;
426    }
427
428    /**
429     * @param array<string, mixed> $availability
430     * @return array<int, array<string, string|bool>>
431     */
432    private function normalizeAvailabilityInput(array $availability): array
433    {
434        $normalized = $this->defaultAvailability();
435
436        foreach ($availability as $day => $row) {
437            $day = (int) $day;
438            if (! isset($normalized[$day]) || ! is_array($row)) {
439                continue;
440            }
441
442            $normalized[$day] = [
443                'enabled' => isset($row['enabled']) && (string) $row['enabled'] === '1',
444                'start' => trim((string) ($row['start'] ?? $normalized[$day]['start'])),
445                'end' => trim((string) ($row['end'] ?? $normalized[$day]['end'])),
446            ];
447        }
448
449        return $normalized;
450    }
451
452    /**
453     * @return array<int, array<string, string|bool>>
454     */
455    private function defaultAvailability(): array
456    {
457        $days = [];
458
459        for ($day = 1; $day <= 7; $day++) {
460            $days[$day] = [
461                'enabled' => false,
462                'start' => '16:00',
463                'end' => '19:00',
464            ];
465        }
466
467        return $days;
468    }
469
470    /**
471     * @return array<int, array<string, string|bool>>
472     */
473    private function availabilityMap(int $coachId): array
474    {
475        $map = $this->defaultAvailability();
476
477        foreach ($this->availabilityRows($coachId) as $row) {
478            $day = (int) $row['day_of_week'];
479            $map[$day] = [
480                'enabled' => true,
481                'start' => substr((string) $row['start_time'], 0, 5),
482                'end' => substr((string) $row['end_time'], 0, 5),
483            ];
484        }
485
486        return $map;
487    }
488
489    private function syncAvailability(int $coachId, array $availability, string $now): void
490    {
491        $this->db()->table('coach_availability')->where('coach_id', $coachId)->delete();
492
493        $rows = [];
494        foreach ($availability as $day => $slot) {
495            if (! ($slot['enabled'] ?? false)) {
496                continue;
497            }
498
499            $rows[] = [
500                'coach_id' => $coachId,
501                'day_of_week' => (int) $day,
502                'start_time' => ($slot['start'] ?? '16:00') . ':00',
503                'end_time' => ($slot['end'] ?? '19:00') . ':00',
504                'is_available' => 1,
505                'created_at' => $now,
506            ];
507        }
508
509        if ($rows !== []) {
510            model(CoachAvailabilityModel::class)->insertBatch($rows);
511        }
512    }
513
514    /**
515     * @return array<string, mixed>|null
516     */
517    private function getCoachWithUser(int $coachId): ?array
518    {
519        return $this->db()
520            ->table('coaches c')
521            ->select('c.*, users.username')
522            ->join('users', 'users.id = c.user_id', 'left')
523            ->where('c.id', $coachId)
524            ->get()
525            ->getRowArray();
526    }
527
528    /**
529     * @return list<array<string, mixed>>
530     */
531    private function availabilityRows(int $coachId): array
532    {
533        return $this->db()
534            ->table('coach_availability')
535            ->where('coach_id', $coachId)
536            ->orderBy('day_of_week', 'ASC')
537            ->get()
538            ->getResultArray();
539    }
540
541    /**
542     * @param list<int> $coachIds
543     * @return array<int, list<array<string, mixed>>>
544     */
545    private function availabilityRowsForMany(array $coachIds): array
546    {
547        $grouped = [];
548        if ($coachIds === []) {
549            return $grouped;
550        }
551
552        $rows = $this->db()
553            ->table('coach_availability')
554            ->whereIn('coach_id', $coachIds)
555            ->orderBy('day_of_week', 'ASC')
556            ->get()
557            ->getResultArray();
558
559        foreach ($rows as $row) {
560            $grouped[(int) $row['coach_id']][] = $row;
561        }
562
563        return $grouped;
564    }
565
566    private function db()
567    {
568        return db_connect();
569    }
570}