Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.22% covered (success)
95.22%
239 / 251
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UniversalCalendarService
95.60% covered (success)
95.60%
239 / 250
54.55% covered (warning)
54.55%
6 / 11
69
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
 listCalendarEvents
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
5
 listCalendarUsers
98.31% covered (success)
98.31%
58 / 59
0.00% covered (danger)
0.00%
0 / 1
9
 listRelatedCalendarEvents
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
14.03
 listRelatedWorkTimeRecords
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
12
 resolveCalendarEffectiveUserIds
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 parseCalendarUserIds
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 applyCalendarOwnerScope
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 formatCalendarEvent
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
11
 buildWorkTimeSummaryResult
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 markParentCalendarEventPostponed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Persistence\UniversalPersistenceManager;
12use App\Core\Engine\Application\Query\Clause\UniversalFilterClauseBuilder;
13use App\Core\Engine\Application\Security\PermissionGuard;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
16use App\Core\Grid\GridRequest;
17use PDO;
18
19/**
20 * Handles calendar event listing, user scoping, related calendar records, and work time summaries.
21 */
22final readonly class UniversalCalendarService
23{
24    public function __construct(
25        private MetadataRepositoryInterface $metadata,
26        private PermissionGuard $guard,
27        private UniversalPersistenceManager $persistence
28    ) {
29    }
30
31    /**
32     * Fetches calendar events for a module within a given date range.
33     *
34     * @param string            $moduleName Module machine name.
35     * @param string            $start      Start ISO datetime.
36     * @param string            $end        End ISO datetime.
37     * @param PermissionContext $context    Security context.
38     * @param int|null          $filterId   Optional filter record ID.
39     * @param string|null       $userId     Optional target user ID or 'all'.
40     * @return array<int, array<string, mixed>> EventCalendar formatted event records.
41     */
42    public function listCalendarEvents(
43        string            $moduleName,
44        string            $start,
45        string            $end,
46        PermissionContext $context,
47        ?int              $filterId = null,
48        ?string           $userId = null,
49    ): array {
50        $module = $this->metadata->findModule($moduleName);
51        $this->guard->assertReadAccess($module, $context);
52
53        $tableName  = $module->tableName;
54        $tableAlias = $module->tableAlias !== '' ? $module->tableAlias : 'cal';
55        if ($tableName === '') {
56            return [];
57        }
58
59        $applyOwner   = $this->guard->shouldApplyOwnerScope($module, $context);
60        $pdo          = $this->persistence->getPdo();
61        $filterWhere  = '';
62        $filterParams = [];
63
64        if ($filterId !== null) {
65            $fields = $this->metadata->findFields($module->id);
66            $filter = $this->metadata->findFilter($module->id, $filterId);
67            $filterBuilder = new UniversalFilterClauseBuilder();
68            [$filterWhere, $filterParams] = $filterBuilder->buildWhere(
69                $module,
70                $fields,
71                $filter,
72                new GridRequest(),
73                $context,
74                $applyOwner
75            );
76        }
77
78        $params = array_merge($filterParams, [
79            ':start' => $start,
80            ':end'   => $end,
81        ]);
82
83        $where = "(`{$tableAlias}`.`start_date` <= :end) AND (`{$tableAlias}`.`end_date` >= :start)";
84        if ($filterWhere !== '') {
85            $where .= " AND ({$filterWhere})";
86        }
87
88        $effectiveUserIds = $this->resolveCalendarEffectiveUserIds($applyOwner, $context, $userId);
89        $this->applyCalendarOwnerScope($tableAlias, $effectiveUserIds, $where, $params);
90
91        $sql = "SELECT `{$tableAlias}`.`id`, `{$tableAlias}`.`subject`, `{$tableAlias}`.`start_date`,
92                       `{$tableAlias}`.`end_date`, `{$tableAlias}`.`is_all_day`, `{$tableAlias}`.`location`,
93                       `{$tableAlias}`.`meeting_url`, `{$tableAlias}`.`event_type`, `{$tableAlias}`.`status`,
94                       `{$tableAlias}`.`color`, `{$tableAlias}`.`company_id`, `{$tableAlias}`.`contact_id`,
95                       `{$tableAlias}`.`priority`, `{$tableAlias}`.`description`, `{$tableAlias}`.`owner`
96                FROM   `{$tableName}` `{$tableAlias}`
97                WHERE  {$where}
98                ORDER BY `{$tableAlias}`.`start_date` ASC
99                LIMIT  1000";
100
101        $stmt = $pdo->prepare($sql);
102        $stmt->execute($params);
103        /** @var list<array<string, mixed>> $records */
104        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
105
106        return array_map([$this, 'formatCalendarEvent'], $records);
107    }
108
109    /**
110     * Returns a list of users having records in the module within the specified filter scope.
111     *
112     * @param string            $moduleName Module machine name.
113     * @param PermissionContext $context    Security context.
114     * @param int|null          $filterId   Optional active filter ID.
115     * @return array{
116     *     current_user_id: int,
117     *     active_filter_id: int|null,
118     *     active_filter_name: string,
119     *     users: array<int, array<string, mixed>>
120     * }
121     */
122    public function listCalendarUsers(
123        string            $moduleName,
124        PermissionContext $context,
125        ?int              $filterId = null,
126    ): array {
127        $module = $this->metadata->findModule($moduleName);
128        $this->guard->assertReadAccess($module, $context);
129
130        $tableName  = $module->tableName;
131        $tableAlias = $module->tableAlias !== '' ? $module->tableAlias : 'cal';
132        if ($tableName === '') {
133            return [
134                'current_user_id'    => $context->actorUserId,
135                'active_filter_id'   => $filterId,
136                'active_filter_name' => '',
137                'users'              => [],
138            ];
139        }
140
141        $applyOwner  = $this->guard->shouldApplyOwnerScope($module, $context);
142        $filterWhere = '';
143        $filterName  = '';
144        $params      = [];
145
146        if ($filterId !== null) {
147            $fields = $this->metadata->findFields($module->id);
148            $filter = $this->metadata->findFilter($module->id, $filterId);
149            $filterName = $filter->label !== '' ? $filter->label : $filter->name;
150            $filterBuilder = new UniversalFilterClauseBuilder();
151            [$filterWhere, $params] = $filterBuilder->buildWhere(
152                $module,
153                $fields,
154                $filter,
155                new GridRequest(),
156                $context,
157                $applyOwner
158            );
159        }
160
161        $joinCondition = "`{$tableAlias}`.`owner` = `u`.`id` AND `{$tableAlias}`.`special_access` = 1";
162        if ($filterWhere !== '') {
163            $joinCondition .= " AND ({$filterWhere})";
164        }
165
166        $userTable = str_starts_with($tableName, 'c_') ? 'c_mod_users_records' : 'a_mod_users_records';
167        $sql = "SELECT `u`.`id`, `u`.`username`, `u`.`email`, COUNT(`{$tableAlias}`.`id`) AS `events_count`
168                FROM `{$userTable}` `u`
169                LEFT JOIN `{$tableName}` `{$tableAlias}` ON {$joinCondition}
170                WHERE `u`.`status` = 'active' AND `u`.`special_access` = 1
171                GROUP BY `u`.`id`, `u`.`username`, `u`.`email`
172                ORDER BY `u`.`username` ASC";
173
174        $pdo  = $this->persistence->getPdo();
175        $stmt = $pdo->prepare($sql);
176        $stmt->execute($params);
177        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
178
179        $users = [];
180        foreach ($rows as $row) {
181            $userId    = (int) $row['id'];
182            $isCurrent = ($userId === $context->actorUserId);
183            $username  = (string) $row['username'];
184            $users[]   = [
185                'id'           => $userId,
186                'username'     => $username,
187                'label'        => $username . ($isCurrent ? ' (Ty)' : ''),
188                'email'        => (string) $row['email'],
189                'events_count' => (int) $row['events_count'],
190                'is_current'   => $isCurrent,
191            ];
192        }
193
194        return [
195            'current_user_id'    => $context->actorUserId,
196            'active_filter_id'   => $filterId,
197            'active_filter_name' => $filterName,
198            'users'              => $users,
199        ];
200    }
201
202    /**
203     * Lists calendar events related to a specific record in any module.
204     *
205     * @param string $moduleName Parent module machine name.
206     * @param int    $id         Parent record ID.
207     * @return list<array<string, mixed>> List of matching calendar rows.
208     */
209    public function listRelatedCalendarEvents(string $moduleName, int $id): array
210    {
211        $pdo = $this->persistence->getPdo();
212        $ref = $moduleName . ':' . $id;
213
214        $whereClause = match ($moduleName) {
215            'companies'      => '(`cal`.`company_id` = :record_id OR `cal`.`related_party_ref` = :ref)',
216            'partners'       => '(`cal`.`partner_id` = :record_id OR `cal`.`related_party_ref` = :ref)',
217            'contacts'       => '(`cal`.`contact_id` = :record_id OR `cal`.`related_party_ref` = :ref)',
218            'projects'       => '(`cal`.`project_id` = :record_id OR `cal`.`process_ref` = :ref)',
219            'contracts'      => '(`cal`.`contract_id` = :record_id OR `cal`.`process_ref` = :ref)',
220            'project_stages' => '(`cal`.`stage_id` = :record_id OR `cal`.`subprocess_ref` = :ref)',
221            'project_tasks'  => '(`cal`.`task_id` = :record_id OR `cal`.`subprocess_ref` = :ref)',
222            'tickets'        => '(`cal`.`ticket_id` = :record_id OR `cal`.`subprocess_ref` = :ref)',
223            default          => '(`cal`.`related_party_ref` = :ref OR `cal`.`process_ref` = :ref '
224                . 'OR `cal`.`subprocess_ref` = :ref)',
225        };
226
227        $sql = "SELECT `cal`.`id`, `cal`.`subject`, `cal`.`start_date`, `cal`.`end_date`, "
228            . "`cal`.`is_all_day`, `cal`.`status`, `cal`.`event_type`, `cal`.`priority`, "
229            . "`cal`.`color`, `cal`.`location`, `cal`.`meeting_url`, `u`.`username` AS `owner_name` "
230            . "FROM `c_mod_calendar_records` `cal` "
231            . "LEFT JOIN `c_mod_users_records` `u` ON `u`.`id` = `cal`.`owner` "
232            . "WHERE {$whereClause} "
233            . "ORDER BY `cal`.`start_date` DESC LIMIT 100";
234
235        $params = [':ref' => $ref];
236        if (str_contains($whereClause, ':record_id')) {
237            $params[':record_id'] = $id;
238        }
239
240        try {
241            $stmt = $pdo->prepare($sql);
242            $stmt->execute($params);
243            /** @var list<array<string, mixed>> */
244            return $stmt->fetchAll(PDO::FETCH_ASSOC);
245        } catch (\Throwable) {
246            return [];
247        }
248    }
249
250    /**
251     * Lists work time entries related to a specific record with summary metrics.
252     *
253     * @param string $moduleName Parent module machine name.
254     * @param int    $id         Parent record ID.
255     * @return array{
256     *     summary: array{total_hours: float, total_minutes: int, billable_hours: float, count: int},
257     *     items: list<array<string, mixed>>
258     * }
259     */
260    public function listRelatedWorkTimeRecords(string $moduleName, int $id): array
261    {
262        $pdo = $this->persistence->getPdo();
263        $ref = $moduleName . ':' . $id;
264
265        $whereClause = match ($moduleName) {
266            'companies'      => '(`wt`.`company_id` = :record_id OR `wt`.`related_party_ref` = :ref)',
267            'partners'       => '(`wt`.`partner_id` = :record_id OR `wt`.`related_party_ref` = :ref)',
268            'contacts'       => '(`wt`.`contact_id` = :record_id OR `wt`.`related_party_ref` = :ref)',
269            'projects'       => '(`wt`.`project_id` = :record_id OR `wt`.`process_ref` = :ref)',
270            'contracts'      => '(`wt`.`contract_id` = :record_id OR `wt`.`process_ref` = :ref)',
271            'project_stages' => '(`wt`.`stage_id` = :record_id OR `wt`.`subprocess_ref` = :ref)',
272            'project_tasks'  => '(`wt`.`task_id` = :record_id OR `wt`.`subprocess_ref` = :ref)',
273            'tickets'        => '(`wt`.`ticket_id` = :record_id OR `wt`.`subprocess_ref` = :ref)',
274            default          => '(`wt`.`related_party_ref` = :ref OR `wt`.`process_ref` = :ref '
275                . 'OR `wt`.`subprocess_ref` = :ref)',
276        };
277
278        $sql = "SELECT `wt`.`id`, `wt`.`subject`, `wt`.`start_date`, `wt`.`end_date`, "
279            . "`wt`.`duration_hours`, `wt`.`duration_minutes`, `wt`.`work_time_type`, "
280            . "`wt`.`status`, `wt`.`is_billable`, `wt`.`hourly_rate`, `wt`.`total_amount`, "
281            . "`wt`.`description`, `u`.`username` AS `owner_name` "
282            . "FROM `c_mod_work_time_records` `wt` "
283            . "LEFT JOIN `c_mod_users_records` `u` ON `u`.`id` = `wt`.`owner` "
284            . "WHERE {$whereClause} "
285            . "ORDER BY `wt`.`start_date` DESC LIMIT 100";
286
287        $wtParams = [':ref' => $ref];
288        if (str_contains($whereClause, ':record_id')) {
289            $wtParams[':record_id'] = $id;
290        }
291
292        try {
293            $stmt = $pdo->prepare($sql);
294            $stmt->execute($wtParams);
295            /** @var list<array<string, mixed>> $rows */
296            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
297
298            return $this->buildWorkTimeSummaryResult($rows);
299        } catch (\Throwable) {
300            return [
301                'summary' => ['total_hours' => 0.0, 'total_minutes' => 0, 'billable_hours' => 0.0, 'count' => 0],
302                'items' => [],
303            ];
304        }
305    }
306
307    /**
308     * Resolves effective user IDs for calendar event scoping.
309     *
310     * @param bool              $applyOwner Whether owner scope is enforced.
311     * @param PermissionContext $context    Security context.
312     * @param string|null       $userId     Requested user ID or 'all'.
313     * @return list<int>
314     */
315    private function resolveCalendarEffectiveUserIds(
316        bool $applyOwner,
317        PermissionContext $context,
318        ?string $userId
319    ): array {
320        if ($applyOwner) {
321            return [(int) $context->actorUserId];
322        }
323
324        $rawUserId = ($userId === null || $userId === '')
325            ? (string) $context->actorUserId
326            : $userId;
327
328        return $this->parseCalendarUserIds($rawUserId);
329    }
330
331    /**
332     * @return list<int>
333     */
334    private function parseCalendarUserIds(string $rawUserId): array
335    {
336        if ($rawUserId === 'all') {
337            return [];
338        }
339
340        if ($rawUserId === 'none' || $rawUserId === '0') {
341            return [0];
342        }
343
344        $parts = explode(',', $rawUserId);
345        $effectiveUserIds = [];
346        foreach ($parts as $p) {
347            $trimmed = trim($p);
348            if (is_numeric($trimmed) && (int) $trimmed > 0) {
349                $effectiveUserIds[] = (int) $trimmed;
350            }
351        }
352
353        return $effectiveUserIds;
354    }
355
356    /**
357     * Appends calendar owner scoping to the SQL where clause.
358     *
359     * @param string               $tableAlias       Table alias.
360     * @param list<int>            $effectiveUserIds User IDs to scope by.
361     * @param string               $where            Target WHERE SQL clause.
362     * @param array<string, mixed> $params           Target query parameters.
363     */
364    private function applyCalendarOwnerScope(
365        string $tableAlias,
366        array $effectiveUserIds,
367        string &$where,
368        array &$params
369    ): void {
370        if (count($effectiveUserIds) === 1) {
371            $where .= " AND (`{$tableAlias}`.`owner` = :calendar_user_id)";
372            $params[':calendar_user_id'] = $effectiveUserIds[0];
373            return;
374        }
375
376        if (count($effectiveUserIds) > 1) {
377            $inPlaceholders = [];
378            foreach ($effectiveUserIds as $idx => $uid) {
379                $ph = ':cal_user_' . $idx;
380                $inPlaceholders[] = $ph;
381                $params[$ph] = $uid;
382            }
383            $where .= " AND (`{$tableAlias}`.`owner` IN (" . implode(', ', $inPlaceholders) . "))";
384        }
385    }
386
387    /**
388     * Formats database record into an EventCalendar structure.
389     *
390     * @param array<string, mixed> $rec Raw database row.
391     * @return array<string, mixed>
392     */
393    private function formatCalendarEvent(array $rec): array
394    {
395        $startDate = str_replace(' ', 'T', (string) $rec['start_date']);
396        $endDate   = str_replace(' ', 'T', (string) $rec['end_date']);
397        $isAllDay  = (int) ($rec['is_all_day'] ?? 0) === 1;
398
399        $color = (string) ($rec['color'] ?? 'primary');
400        $bg = match ($color) {
401            'success', 'green'  => '#2fb344',
402            'warning', 'yellow' => '#f59f00',
403            'danger', 'red'     => '#d63939',
404            'info', 'azure'     => '#4299e1',
405            'purple'            => '#ae3ec9',
406            'secondary'         => '#6c757d',
407            default             => '#206bc4',
408        };
409
410        return [
411            'id'              => (string) $rec['id'],
412            'title'           => (string) ($rec['subject'] ?? 'Wydarzenie'),
413            'start'           => $startDate,
414            'end'             => $endDate,
415            'allDay'          => $isAllDay,
416            'backgroundColor' => $bg,
417            'borderColor'     => $bg,
418            'extendedProps'   => [
419                'recordId'    => (int) $rec['id'],
420                'eventType'   => (string) ($rec['event_type'] ?? 'meeting'),
421                'status'      => (string) ($rec['status'] ?? 'confirmed'),
422                'priority'    => (string) ($rec['priority'] ?? 'normal'),
423                'location'    => (string) ($rec['location'] ?? ''),
424                'meetingUrl'  => (string) ($rec['meeting_url'] ?? ''),
425                'companyId'   => isset($rec['company_id']) ? (int) $rec['company_id'] : null,
426                'contactId'   => isset($rec['contact_id']) ? (int) $rec['contact_id'] : null,
427                'owner'       => isset($rec['owner']) ? (int) $rec['owner'] : null,
428                'description' => (string) ($rec['description'] ?? ''),
429            ],
430        ];
431    }
432
433    /**
434     * Builds summary metrics from work time record rows.
435     *
436     * @param list<array<string, mixed>> $rows Record rows.
437     * @return array{
438     *     summary: array{total_hours: float, total_minutes: int, billable_hours: float, count: int},
439     *     items: list<array<string, mixed>>
440     * }
441     */
442    private function buildWorkTimeSummaryResult(array $rows): array
443    {
444        $totalHours = 0.0;
445        $totalMins = 0;
446        $billableHours = 0.0;
447
448        foreach ($rows as $row) {
449            $hrs = (float) ($row['duration_hours'] ?? 0.0);
450            $totalHours += $hrs;
451            $totalMins += (int) ($row['duration_minutes'] ?? 0);
452            if (!empty($row['is_billable'])) {
453                $billableHours += $hrs;
454            }
455        }
456
457        return [
458            'summary' => [
459                'total_hours'    => round($totalHours, 2),
460                'total_minutes'  => $totalMins,
461                'billable_hours' => round($billableHours, 2),
462                'count'          => count($rows),
463            ],
464            'items' => $rows,
465        ];
466    }
467
468    /**
469     * Marks source calendar event as postponed when a rescheduled child event is created.
470     *
471     * @param int $parentId Parent record primary key.
472     */
473    public function markParentCalendarEventPostponed(int $parentId): void
474    {
475        $sql = 'UPDATE `c_mod_calendar_records` SET `status` = :status WHERE `id` = :id';
476        $stmt = $this->persistence->getPdo()->prepare($sql);
477        $stmt->execute([':status' => 'postponed', ':id' => $parentId]);
478    }
479}
480