Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
136 / 140
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardCalendarActionService
97.12% covered (success)
97.12%
135 / 139
72.73% covered (warning)
72.73%
8 / 11
35
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
 getEventDetails
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 fetchEventRow
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 parseEventAttendees
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
5.39
 buildEventDetailsSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 completeEvent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cancelEvent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postponeEvent
98.96% covered (success)
98.96%
95 / 96
0.00% covered (danger)
0.00%
0 / 1
14
 updateEventStatus
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 resolveActivePdo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 tableExists
100.00% covered (success)
100.00%
1 / 1
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\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\Modules\Dashboard\Application\Service\Query\CalendarEventQueryBuilder;
14use PDO;
15use Throwable;
16
17/**
18 * Dashboard Calendar Action Service.
19 *
20 * Dispatches quick actions on calendar events (complete, cancel, postpone)
21 * and retrieves event summary records for the interactive modal.
22 *
23 * @package App\Modules\Dashboard\Application\Service
24 */
25final readonly class DashboardCalendarActionService implements DashboardCalendarActionServiceInterface
26{
27    private CalendarEventQueryBuilder $queryBuilder;
28
29    /**
30     * DashboardCalendarActionService constructor.
31     *
32     * @param PDO                                   $pdo                    Primary database PDO connection.
33     * @param InstanceContextManagerInterface|null  $instanceContextManager Optional instance context manager.
34     * @param PDO|null                              $clientPdo              Optional client database connection.
35     * @param string                                $tablePrefix            Database table prefix.
36     * @param CalendarEventQueryBuilder|null        $queryBuilder           Optional calendar query builder.
37     */
38    public function __construct(
39        private PDO $pdo,
40        private ?InstanceContextManagerInterface $instanceContextManager = null,
41        private ?PDO $clientPdo = null,
42        private string $tablePrefix = 'a_',
43        ?CalendarEventQueryBuilder $queryBuilder = null,
44    ) {
45        $this->queryBuilder = $queryBuilder ?? new CalendarEventQueryBuilder($this->tablePrefix);
46    }
47
48    /**
49     * Retrieves full event details for the summary modal.
50     *
51     * @param int               $eventId Record identifier in calendar table.
52     * @param PermissionContext $context Security permission context.
53     * @return array<string, mixed>|null Event record or null if not found.
54     */
55    public function getEventDetails(int $eventId, PermissionContext $context): ?array
56    {
57        $pdo = $this->resolveActivePdo();
58        $calTable = $this->tablePrefix . 'mod_calendar_records';
59
60        if (!$this->tableExists($pdo, $calTable)) {
61            return null;
62        }
63
64        $sql = $this->buildEventDetailsSql($pdo, $calTable);
65
66        return $this->fetchEventRow($pdo, $sql, $eventId);
67    }
68
69    private function fetchEventRow(PDO $pdo, string $sql, int $eventId): ?array
70    {
71        try {
72            $stmt = $pdo->prepare($sql);
73            $stmt->bindValue(':id', $eventId, PDO::PARAM_INT);
74            $stmt->execute();
75            $row = $stmt->fetch(PDO::FETCH_ASSOC);
76
77            if ($row === false) {
78                return null;
79            }
80
81            $row['process_name'] = trim((string) ($row['process_name'] ?? ''));
82            $row['subprocess_name'] = trim((string) ($row['subprocess_name'] ?? ''));
83            $row['attendees_parsed'] = $this->parseEventAttendees($row['attendees'] ?? null);
84
85            return $row;
86        } catch (Throwable) {
87            return null;
88        }
89    }
90
91    private function parseEventAttendees(mixed $attendees): ?array
92    {
93        if (is_string($attendees) && $attendees !== '') {
94            $decoded = json_decode($attendees, true);
95            return is_array($decoded) ? $decoded : null;
96        }
97        return is_array($attendees) ? $attendees : null;
98    }
99
100    private function buildEventDetailsSql(PDO $pdo, string $calTable): string
101    {
102        return $this->queryBuilder->buildEventDetailsSql($pdo, $calTable);
103    }
104
105    /**
106     * Marks a calendar event as completed.
107     *
108     * @param int               $eventId Record identifier.
109     * @param PermissionContext $context Security permission context.
110     * @return bool True on success.
111     */
112    public function completeEvent(int $eventId, PermissionContext $context): bool
113    {
114        return $this->updateEventStatus($eventId, 'completed');
115    }
116
117    /**
118     * Marks a calendar event as cancelled.
119     *
120     * @param int               $eventId Record identifier.
121     * @param PermissionContext $context Security permission context.
122     * @return bool True on success.
123     */
124    public function cancelEvent(int $eventId, PermissionContext $context): bool
125    {
126        return $this->updateEventStatus($eventId, 'cancelled');
127    }
128
129    /**
130     * Reschedules / postpones a calendar event to a new date and time.
131     *
132     * @param int               $eventId      Record identifier.
133     * @param string            $newStartDate New start datetime (Y-m-d H:i:s).
134     * @param string|null       $newEndDate   Optional new end datetime.
135     * @param PermissionContext $context      Security permission context.
136     * @return bool True on success.
137     */
138    public function postponeEvent(
139        int               $eventId,
140        string            $newStartDate,
141        ?string           $newEndDate,
142        PermissionContext $context
143    ): bool {
144        $pdo = $this->resolveActivePdo();
145        $calTable = $this->tablePrefix . 'mod_calendar_records';
146
147        try {
148            $selectSql = "SELECT `sequence`, `subject`, `is_all_day`, `location`, `meeting_url`, `event_type`,
149                                 `c_isopaque`, `color`, `company_id`, `contact_id`, `related_party_ref`,
150                                 `partner_id`, `process_ref`, `project_id`, `contract_id`, `subprocess_ref`,
151                                 `task_id`, `stage_id`, `ticket_id`, `priority`, `description`,
152                                 `reminder_minutes`, `reminders`, `attendees`, `owner`, `co_owners`
153                          FROM   `{$calTable}`
154                          WHERE  `id` = :id AND `special_access` = 1
155                          LIMIT  1";
156            $stmt = $pdo->prepare($selectSql);
157            $stmt->bindValue(':id', $eventId, PDO::PARAM_INT);
158            $stmt->execute();
159            $original = $stmt->fetch(PDO::FETCH_ASSOC);
160
161            if (!$original) {
162                return false;
163            }
164
165            // 1. Mark existing source event as postponed
166            $updateSql = "UPDATE `{$calTable}`
167                          SET    `status`     = 'postponed',
168                                 `updated_at` = NOW(6)
169                          WHERE  `id` = :id";
170            $upStmt = $pdo->prepare($updateSql);
171            $upStmt->bindValue(':id', $eventId, PDO::PARAM_INT);
172            $upStmt->execute();
173
174            // 2. Insert rescheduled child event planned for new date
175            $calculatedEnd = $newEndDate ?? $newStartDate;
176            $newUid = bin2hex(random_bytes(16)) . '@ammonly.com';
177            $actorId = $context->actorUserId > 0 ? $context->actorUserId : 1;
178
179            $insertSql = "INSERT INTO `{$calTable}` (
180                `c_uid`, `sequence`, `subject`, `start_date`, `end_date`,
181                `is_all_day`, `location`, `meeting_url`, `event_type`, `status`,
182                `c_isopaque`, `color`, `parent_id`, `company_id`, `contact_id`,
183                `related_party_ref`, `partner_id`, `process_ref`, `project_id`,
184                `contract_id`, `subprocess_ref`, `task_id`, `stage_id`, `ticket_id`,
185                `priority`, `description`, `reminder_minutes`, `reminders`, `attendees`,
186                `special_access`, `created_by`, `owner`, `co_owners`,
187                `created_at`, `updated_at`
188            ) VALUES (
189                :c_uid, :sequence, :subject, :start_date, :end_date,
190                :is_all_day, :location, :meeting_url, :event_type, 'planned',
191                :c_isopaque, :color, :parent_id, :company_id, :contact_id,
192                :related_party_ref, :partner_id, :process_ref, :project_id,
193                :contract_id, :subprocess_ref, :task_id, :stage_id, :ticket_id,
194                :priority, :description, :reminder_minutes, :reminders, :attendees,
195                1, :created_by, :owner, :co_owners,
196                NOW(6), NOW(6)
197            )";
198
199            $insStmt = $pdo->prepare($insertSql);
200            $insStmt->bindValue(':c_uid', $newUid, PDO::PARAM_STR);
201            $insStmt->bindValue(':sequence', ((int) ($original['sequence'] ?? 0)) + 1, PDO::PARAM_INT);
202            $insStmt->bindValue(':subject', (string) ($original['subject'] ?? ''), PDO::PARAM_STR);
203            $insStmt->bindValue(':start_date', $newStartDate, PDO::PARAM_STR);
204            $insStmt->bindValue(':end_date', $calculatedEnd, PDO::PARAM_STR);
205            $insStmt->bindValue(':is_all_day', (int) ($original['is_all_day'] ?? 0), PDO::PARAM_INT);
206            $insStmt->bindValue(':location', $original['location'] ?? null, PDO::PARAM_STR);
207            $insStmt->bindValue(':meeting_url', $original['meeting_url'] ?? null, PDO::PARAM_STR);
208            $insStmt->bindValue(':event_type', (string) ($original['event_type'] ?? 'meeting'), PDO::PARAM_STR);
209            $insStmt->bindValue(':c_isopaque', (int) ($original['c_isopaque'] ?? 1), PDO::PARAM_INT);
210            $insStmt->bindValue(':color', (string) ($original['color'] ?? 'primary'), PDO::PARAM_STR);
211            $insStmt->bindValue(':parent_id', $eventId, PDO::PARAM_INT);
212            $insStmt->bindValue(
213                ':company_id',
214                !empty($original['company_id']) ? (int) $original['company_id'] : null,
215                PDO::PARAM_INT
216            );
217            $insStmt->bindValue(
218                ':contact_id',
219                !empty($original['contact_id']) ? (int) $original['contact_id'] : null,
220                PDO::PARAM_INT
221            );
222            $insStmt->bindValue(':related_party_ref', $original['related_party_ref'] ?? null, PDO::PARAM_STR);
223            $insStmt->bindValue(
224                ':partner_id',
225                !empty($original['partner_id']) ? (int) $original['partner_id'] : null,
226                PDO::PARAM_INT
227            );
228            $insStmt->bindValue(':process_ref', $original['process_ref'] ?? null, PDO::PARAM_STR);
229            $insStmt->bindValue(
230                ':project_id',
231                !empty($original['project_id']) ? (int) $original['project_id'] : null,
232                PDO::PARAM_INT
233            );
234            $insStmt->bindValue(
235                ':contract_id',
236                !empty($original['contract_id']) ? (int) $original['contract_id'] : null,
237                PDO::PARAM_INT
238            );
239            $insStmt->bindValue(':subprocess_ref', $original['subprocess_ref'] ?? null, PDO::PARAM_STR);
240            $insStmt->bindValue(
241                ':task_id',
242                !empty($original['task_id']) ? (int) $original['task_id'] : null,
243                PDO::PARAM_INT
244            );
245            $insStmt->bindValue(
246                ':stage_id',
247                !empty($original['stage_id']) ? (int) $original['stage_id'] : null,
248                PDO::PARAM_INT
249            );
250            $insStmt->bindValue(
251                ':ticket_id',
252                !empty($original['ticket_id']) ? (int) $original['ticket_id'] : null,
253                PDO::PARAM_INT
254            );
255            $insStmt->bindValue(':priority', (string) ($original['priority'] ?? 'normal'), PDO::PARAM_STR);
256            $insStmt->bindValue(':description', $original['description'] ?? null, PDO::PARAM_STR);
257            $insStmt->bindValue(
258                ':reminder_minutes',
259                isset($original['reminder_minutes']) ? (int) $original['reminder_minutes'] : null,
260                PDO::PARAM_INT
261            );
262            $insStmt->bindValue(':reminders', $original['reminders'] ?? null, PDO::PARAM_STR);
263            $insStmt->bindValue(':attendees', $original['attendees'] ?? null, PDO::PARAM_STR);
264            $insStmt->bindValue(':created_by', $actorId, PDO::PARAM_INT);
265            $insStmt->bindValue(
266                ':owner',
267                !empty($original['owner']) ? (int) $original['owner'] : $actorId,
268                PDO::PARAM_INT
269            );
270            $insStmt->bindValue(':co_owners', $original['co_owners'] ?? null, PDO::PARAM_STR);
271
272            return $insStmt->execute();
273        } catch (Throwable) {
274            return false;
275        }
276    }
277
278    /**
279     * Updates status of a calendar event.
280     *
281     * @param int    $eventId Target event ID.
282     * @param string $status  New status string.
283     * @return bool True on success.
284     */
285    private function updateEventStatus(int $eventId, string $status): bool
286    {
287        $pdo = $this->resolveActivePdo();
288        $calTable = $this->tablePrefix . 'mod_calendar_records';
289
290        $sql = "UPDATE `{$calTable}`
291                SET    `status` = :status,
292                       `updated_at` = NOW(6)
293                WHERE  `id` = :id
294                  AND  `special_access` = 1";
295
296        try {
297            $stmt = $pdo->prepare($sql);
298            $stmt->bindValue(':status', $status, PDO::PARAM_STR);
299            $stmt->bindValue(':id', $eventId, PDO::PARAM_INT);
300            $stmt->execute();
301
302            return $stmt->rowCount() > 0;
303        } catch (Throwable) {
304            return false;
305        }
306    }
307
308    /**
309     * Resolves active PDO connection considering remote client instance context.
310     *
311     * @return PDO Active PDO handle.
312     */
313    private function resolveActivePdo(): PDO
314    {
315        if (
316            $this->instanceContextManager !== null
317            && $this->instanceContextManager->isRemote()
318            && $this->clientPdo !== null
319        ) {
320            return $this->clientPdo;
321        }
322
323        return $this->pdo;
324    }
325
326    /**
327     * Checks whether given database table exists.
328     *
329     * @param PDO    $pdo       Active PDO handle.
330     * @param string $tableName Target table name.
331     * @return bool True if table exists.
332     */
333    private function tableExists(PDO $pdo, string $tableName): bool
334    {
335        return $this->queryBuilder->tableExists($pdo, $tableName);
336    }
337}