Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 283
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
TaskAdapter
0.00% covered (danger)
0.00%
0 / 283
0.00% covered (danger)
0.00%
0 / 12
4692
0.00% covered (danger)
0.00%
0 / 1
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
20
 syncDates
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 activate
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 deactivate
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 getStatus
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
182
 validateConfig
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
342
 getDefaultConfig
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getOrCreateEventTaskGroup
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 buildTaskComment
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 deleteTaskRelatedRecords
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\EventManagement\Adapters;
4
5use PDO;
6use DateTime;
7use BuyerKiosk\EventManagement\Models\Event;
8use BuyerKiosk\EventManagement\Models\EventIntegration;
9
10/**
11 * TaskAdapter - Integration adapter for Task system
12 *
13 * Creates and manages tasks linked to events. Handles:
14 * - Task creation with eventId FK for traceability
15 * - Auto-creation of task groups for event organization ("Event: {EventName}")
16 * - Relative date calculations for task due dates
17 * - Task activation/deactivation based on event lifecycle
18 *
19 * Target tables:
20 * - tasks: Individual task records with eventId FK
21 * - taskGroups: Task grouping (auto-created per event if needed)
22 *
23 * @package BuyerKiosk\EventManagement\Adapters
24 */
25class TaskAdapter extends AbstractAdapter
26{
27    /**
28     * Task group name prefix for event-created groups
29     */
30    private const EVENT_GROUP_PREFIX = 'Event: ';
31
32    /**
33     * Priority mapping from config strings to database values
34     */
35    private const PRIORITY_MAP = [
36        'high' => 1,
37        'normal' => 2,
38        'low' => 3,
39    ];
40
41    /**
42     * Valid phase values for task organization
43     */
44    private const VALID_PHASES = ['prep', 'active', 'cleanup'];
45
46    /**
47     * Valid assignTo values
48     */
49    private const VALID_ASSIGN_TO = ['manager', 'staff', 'all'];
50
51    /**
52     * Get the integration type this adapter handles
53     *
54     * @return string
55     */
56    public function getType(): string
57    {
58        return EventIntegration::TYPE_TASK;
59    }
60
61    /**
62     * Create a task linked to the event
63     *
64     * Steps:
65     * 1. Get or create the event task group
66     * 2. Calculate actual due date from relativeDays
67     * 3. Insert task record with eventId
68     * 4. Return task ID
69     *
70     * @param Event $event The unified event
71     * @param array $config Task configuration
72     * @return int The task ID
73     * @throws IntegrationException On failure
74     */
75    public function create(Event $event, array $config): int
76    {
77        // Validate config first
78        $errors = $this->validateConfig($config);
79        if (!empty($errors)) {
80            throw IntegrationException::invalidConfig($this->getType(), $errors);
81        }
82
83        $this->beginTransaction();
84
85        try {
86            // Get or create task group for this event
87            $groupId = $this->getOrCreateEventTaskGroup($event, $config);
88
89            // Calculate due date from relative days
90            $relativeDays = $this->getConfigValue($config, 'relativeDays', 0);
91            $dueDate = $this->calculateDateFromRelative($event, $relativeDays);
92
93            // Map priority string to integer
94            $priorityStr = $this->getConfigValue($config, 'priority', 'normal');
95            $priority = self::PRIORITY_MAP[$priorityStr] ?? 2;
96
97            // Build task comment from phase info
98            $phase = $this->getConfigValue($config, 'phase', 'active');
99            $comment = $this->buildTaskComment($event, $phase, $config);
100
101            // Insert task record
102            $sql = "INSERT INTO tasks
103                    (taskName, comment, taskGroup, recurOn, priority, sortOrder, startDate, endDate, timeOfDay, eventId)
104                    VALUES
105                    (:taskName, :comment, :taskGroup, :recurOn, :priority, :sortOrder, :startDate, :endDate, :timeOfDay, :eventId)";
106
107            $stmt = $this->db->prepare($sql);
108
109            // Task appears only during event period
110            $startDate = $this->formatDateOnly($dueDate);
111            $endDate = $this->formatDateOnly($event->endDate);
112
113            // If due date is after event end, extend end date
114            if ($dueDate > $event->endDate) {
115                $endDate = $this->formatDateOnly($dueDate);
116            }
117
118            $stmt->execute([
119                ':taskName' => $config['taskName'],
120                ':comment' => $comment,
121                ':taskGroup' => $groupId,
122                ':recurOn' => '', // Event tasks don't recur - they're date-bound
123                ':priority' => $priority,
124                ':sortOrder' => 0,
125                ':startDate' => $startDate,
126                ':endDate' => $endDate,
127                ':timeOfDay' => null,
128                ':eventId' => $event->id,
129            ]);
130
131            $taskId = (int) $this->db->lastInsertId();
132
133            $this->commit();
134
135            return $taskId;
136
137        } catch (\PDOException $e) {
138            $this->rollback();
139            $this->logError('Failed to create task', [
140                'eventId' => $event->id,
141                'config' => $config,
142                'error' => $e->getMessage(),
143            ]);
144            throw IntegrationException::createFailed(
145                $this->getType(),
146                'Database error: ' . $e->getMessage(),
147                ['eventId' => $event->id],
148                $e
149            );
150        }
151    }
152
153    /**
154     * Update task dates when event dates change
155     *
156     * Recalculates the task due date based on new event dates
157     * and the stored relativeDays configuration.
158     *
159     * @param Event $event The updated event with new dates
160     * @param EventIntegration $integration The integration record
161     * @throws IntegrationException On failure
162     */
163    public function syncDates(Event $event, EventIntegration $integration): void
164    {
165        $taskId = $integration->foreignId;
166
167        // Verify task exists
168        if (!$this->recordExists('tasks', $taskId)) {
169            throw IntegrationException::targetNotFound($this->getType(), $taskId);
170        }
171
172        try {
173            // Get relative days from integration config
174            $relativeDays = $integration->getConfigValue('relativeDays', 0);
175            $dueDate = $this->calculateDateFromRelative($event, $relativeDays);
176
177            // Calculate new date range
178            $startDate = $this->formatDateOnly($dueDate);
179            $endDate = $this->formatDateOnly($event->endDate);
180
181            // If due date is after event end, extend end date
182            if ($dueDate > $event->endDate) {
183                $endDate = $this->formatDateOnly($dueDate);
184            }
185
186            // Update task dates
187            $sql = "UPDATE tasks SET startDate = :startDate, endDate = :endDate WHERE id = :taskId";
188            $stmt = $this->db->prepare($sql);
189            $stmt->execute([
190                ':startDate' => $startDate,
191                ':endDate' => $endDate,
192                ':taskId' => $taskId,
193            ]);
194
195        } catch (\PDOException $e) {
196            $this->logError('Failed to sync task dates', [
197                'taskId' => $taskId,
198                'eventId' => $event->id,
199                'error' => $e->getMessage(),
200            ]);
201            throw IntegrationException::syncDatesFailed(
202                $this->getType(),
203                $taskId,
204                'Database error: ' . $e->getMessage(),
205                $e
206            );
207        }
208    }
209
210    /**
211     * Activate the task (make it visible)
212     *
213     * For tasks, activation means the task should appear in task lists.
214     * We set the startDate to today if the event is now active and
215     * the task was scheduled for earlier.
216     *
217     * @param Event $event The event being activated
218     * @param EventIntegration $integration The integration to activate
219     * @throws IntegrationException On failure
220     */
221    public function activate(Event $event, EventIntegration $integration): void
222    {
223        $taskId = $integration->foreignId;
224
225        if (!$this->recordExists('tasks', $taskId)) {
226            throw IntegrationException::targetNotFound($this->getType(), $taskId);
227        }
228
229        try {
230            // Get current task data
231            $stmt = $this->db->prepare("SELECT startDate, endDate FROM tasks WHERE id = :taskId");
232            $stmt->execute([':taskId' => $taskId]);
233            $task = $stmt->fetch(PDO::FETCH_ASSOC);
234
235            $today = new DateTime();
236            $todayStr = $this->formatDateOnly($today);
237
238            // If startDate is in the future, that's fine - task will appear when due
239            // If startDate is in the past, ensure it's visible now
240            $startDate = $this->parseDate($task['startDate']);
241
242            if ($startDate !== null && $startDate < $today) {
243                // Extend the visibility window to include today
244                $sql = "UPDATE tasks SET startDate = :startDate WHERE id = :taskId";
245                $stmt = $this->db->prepare($sql);
246                $stmt->execute([
247                    ':startDate' => $todayStr,
248                    ':taskId' => $taskId,
249                ]);
250            }
251
252        } catch (\PDOException $e) {
253            $this->logError('Failed to activate task', [
254                'taskId' => $taskId,
255                'eventId' => $event->id,
256                'error' => $e->getMessage(),
257            ]);
258            throw IntegrationException::activateFailed(
259                $this->getType(),
260                $taskId,
261                'Database error: ' . $e->getMessage(),
262                $e
263            );
264        }
265    }
266
267    /**
268     * Deactivate the task (hide it from task lists)
269     *
270     * Sets the endDate to yesterday so the task no longer appears.
271     * Task data is preserved for historical reference.
272     *
273     * @param Event $event The event being deactivated
274     * @param EventIntegration $integration The integration to deactivate
275     * @throws IntegrationException On failure
276     */
277    public function deactivate(Event $event, EventIntegration $integration): void
278    {
279        $taskId = $integration->foreignId;
280
281        if (!$this->recordExists('tasks', $taskId)) {
282            throw IntegrationException::targetNotFound($this->getType(), $taskId);
283        }
284
285        try {
286            // Set end date to yesterday to hide the task
287            $yesterday = (new DateTime())->modify('-1 day');
288            $yesterdayStr = $this->formatDateOnly($yesterday);
289
290            $sql = "UPDATE tasks SET endDate = :endDate WHERE id = :taskId";
291            $stmt = $this->db->prepare($sql);
292            $stmt->execute([
293                ':endDate' => $yesterdayStr,
294                ':taskId' => $taskId,
295            ]);
296
297        } catch (\PDOException $e) {
298            $this->logError('Failed to deactivate task', [
299                'taskId' => $taskId,
300                'eventId' => $event->id,
301                'error' => $e->getMessage(),
302            ]);
303            throw IntegrationException::deactivateFailed(
304                $this->getType(),
305                $taskId,
306                'Database error: ' . $e->getMessage(),
307                $e
308            );
309        }
310    }
311
312    /**
313     * Delete the task when event is deleted
314     *
315     * Behavior depends on preserveOnDelete config:
316     * - If true: SET NULL on eventId (preserve task but remove event link)
317     * - If false: DELETE the task record
318     *
319     * @param EventIntegration $integration The integration to delete
320     * @throws IntegrationException On failure
321     */
322    public function delete(EventIntegration $integration): void
323    {
324        $taskId = $integration->foreignId;
325
326        if (!$this->recordExists('tasks', $taskId)) {
327            // Task already deleted, nothing to do
328            return;
329        }
330
331        try {
332            $preserveOnDelete = $integration->getConfigValue('preserveOnDelete', false);
333
334            if ($preserveOnDelete) {
335                // Clear the eventId but keep the task
336                $this->clearEventIdOnRecord('tasks', $taskId);
337            } else {
338                // Delete the task entirely
339                // First delete related records
340                $this->deleteTaskRelatedRecords($taskId);
341
342                // Then delete the task
343                $sql = "DELETE FROM tasks WHERE id = :taskId";
344                $stmt = $this->db->prepare($sql);
345                $stmt->execute([':taskId' => $taskId]);
346            }
347
348        } catch (\PDOException $e) {
349            $this->logError('Failed to delete task', [
350                'taskId' => $taskId,
351                'error' => $e->getMessage(),
352            ]);
353            throw IntegrationException::deleteFailed(
354                $this->getType(),
355                $taskId,
356                'Database error: ' . $e->getMessage(),
357                $e
358            );
359        }
360    }
361
362    /**
363     * Get task completion status
364     *
365     * Returns:
366     * - status: pending, active (has completions today), completed (all done)
367     * - details: completion count, last completion info
368     *
369     * @param EventIntegration $integration The integration to check
370     * @return array Status details
371     */
372    public function getStatus(EventIntegration $integration): array
373    {
374        $taskId = $integration->foreignId;
375
376        if (!$this->recordExists('tasks', $taskId)) {
377            return $this->buildStatusResponse('not_found', [
378                'message' => 'Task record not found',
379            ]);
380        }
381
382        try {
383            // Get task basic info
384            $stmt = $this->db->prepare("SELECT taskName, startDate, endDate FROM tasks WHERE id = :taskId");
385            $stmt->execute([':taskId' => $taskId]);
386            $task = $stmt->fetch(PDO::FETCH_ASSOC);
387
388            // Check if we have completion tracking tables
389            $today = date('Y-m-d');
390            $completionStatus = 'pending';
391            $completionDetails = [];
392
393            // Check for workbook_task_completions table
394            try {
395                $stmt = $this->db->prepare("
396                    SELECT status, completedAt, completedBy
397                    FROM workbook_task_completions
398                    WHERE taskId = :taskId AND date = :date
399                    ORDER BY completedAt DESC
400                    LIMIT 1
401                ");
402                $stmt->execute([':taskId' => $taskId, ':date' => $today]);
403                $completion = $stmt->fetch(PDO::FETCH_ASSOC);
404
405                if ($completion) {
406                    switch ((int) $completion['status']) {
407                        case 0:
408                            $completionStatus = 'pending';
409                            break;
410                        case 1:
411                            $completionStatus = 'active';
412                            break;
413                        case 2:
414                            $completionStatus = 'completed';
415                            break;
416                    }
417                    $completionDetails = [
418                        'lastCompletedAt' => $completion['completedAt'],
419                        'lastCompletedBy' => $completion['completedBy'],
420                    ];
421                }
422            } catch (\PDOException $e) {
423                // Table may not exist, ignore
424            }
425
426            // Determine overall status based on task dates
427            $startDate = $this->parseDate($task['startDate']);
428            $endDate = $this->parseDate($task['endDate']);
429            $now = new DateTime();
430
431            $taskStatus = 'pending';
432            if ($startDate !== null && $now < $startDate) {
433                $taskStatus = 'pending';
434            } elseif ($endDate !== null && $now > $endDate) {
435                $taskStatus = 'completed';
436            } else {
437                $taskStatus = $completionStatus === 'completed' ? 'completed' : 'active';
438            }
439
440            return $this->buildStatusResponse($taskStatus, array_merge([
441                'taskName' => $task['taskName'],
442                'startDate' => $task['startDate'],
443                'endDate' => $task['endDate'],
444                'todayCompletion' => $completionStatus,
445            ], $completionDetails));
446
447        } catch (\PDOException $e) {
448            $this->logError('Failed to get task status', [
449                'taskId' => $taskId,
450                'error' => $e->getMessage(),
451            ]);
452            throw IntegrationException::getStatusFailed(
453                $this->getType(),
454                $taskId,
455                'Database error: ' . $e->getMessage(),
456                $e
457            );
458        }
459    }
460
461    /**
462     * Validate configuration before creating integration
463     *
464     * @param array $config Configuration to validate
465     * @return array Array of validation errors (empty if valid)
466     */
467    public function validateConfig(array $config): array
468    {
469        $errors = [];
470
471        // Required: taskName
472        $errors = array_merge($errors, $this->validateRequiredKeys($config, ['taskName']));
473
474        // taskName must be non-empty string
475        if (isset($config['taskName']) && strlen(trim($config['taskName'])) === 0) {
476            $errors[] = 'taskName cannot be empty';
477        }
478
479        // taskName max length
480        if (isset($config['taskName']) && strlen($config['taskName']) > 255) {
481            $errors[] = 'taskName cannot exceed 255 characters';
482        }
483
484        // Validate phase if provided
485        if (isset($config['phase']) && !in_array($config['phase'], self::VALID_PHASES, true)) {
486            $errors[] = 'Invalid phase. Must be one of: ' . implode(', ', self::VALID_PHASES);
487        }
488
489        // Validate relativeDays if provided
490        if (isset($config['relativeDays'])) {
491            if (!is_int($config['relativeDays']) && !is_numeric($config['relativeDays'])) {
492                $errors[] = 'relativeDays must be an integer';
493            } elseif ($config['relativeDays'] < -365 || $config['relativeDays'] > 365) {
494                $errors[] = 'relativeDays must be between -365 and 365';
495            }
496        }
497
498        // Validate assignTo if provided
499        if (isset($config['assignTo']) && !in_array($config['assignTo'], self::VALID_ASSIGN_TO, true)) {
500            $errors[] = 'Invalid assignTo. Must be one of: ' . implode(', ', self::VALID_ASSIGN_TO);
501        }
502
503        // Validate priority if provided
504        if (isset($config['priority']) && !array_key_exists($config['priority'], self::PRIORITY_MAP)) {
505            $errors[] = 'Invalid priority. Must be one of: ' . implode(', ', array_keys(self::PRIORITY_MAP));
506        }
507
508        // Validate createGroup if provided
509        if (isset($config['createGroup']) && !is_bool($config['createGroup'])) {
510            $errors[] = 'createGroup must be a boolean';
511        }
512
513        return $errors;
514    }
515
516    /**
517     * Get the default configuration for task integration
518     *
519     * @return array Default configuration values
520     */
521    public function getDefaultConfig(): array
522    {
523        return [
524            'taskName' => '',
525            'phase' => 'active',
526            'relativeDays' => 0,
527            'assignTo' => 'all',
528            'priority' => 'normal',
529            'createGroup' => true,
530            'preserveOnDelete' => false,
531        ];
532    }
533
534    /**
535     * Get or create a task group for the event
536     *
537     * If createGroup config is true, creates a group named "Event: {EventName}"
538     * Otherwise uses an existing group or falls back to a default.
539     *
540     * @param Event $event The event
541     * @param array $config Integration config
542     * @return int Task group ID
543     * @throws \PDOException On database error
544     */
545    private function getOrCreateEventTaskGroup(Event $event, array $config): int
546    {
547        $createGroup = $this->getConfigValue($config, 'createGroup', true);
548        $groupName = self::EVENT_GROUP_PREFIX . $event->name;
549
550        if ($createGroup) {
551            // Check if event task group already exists
552            $stmt = $this->db->prepare("SELECT id FROM taskGroups WHERE groupName = :groupName");
553            $stmt->execute([':groupName' => $groupName]);
554            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
555
556            if ($existing) {
557                return (int) $existing['id'];
558            }
559
560            // Create new task group
561            $stmt = $this->db->prepare("INSERT INTO taskGroups (groupName) VALUES (:groupName)");
562            $stmt->execute([':groupName' => $groupName]);
563            return (int) $this->db->lastInsertId();
564        }
565
566        // Use existing group specified in config, or fall back to first available
567        if (isset($config['taskGroupId'])) {
568            $stmt = $this->db->prepare("SELECT id FROM taskGroups WHERE id = :id");
569            $stmt->execute([':id' => $config['taskGroupId']]);
570            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
571            if ($existing) {
572                return (int) $existing['id'];
573            }
574        }
575
576        // Get or create a default "Event Tasks" group
577        $defaultGroupName = 'Event Tasks';
578        $stmt = $this->db->prepare("SELECT id FROM taskGroups WHERE groupName = :groupName");
579        $stmt->execute([':groupName' => $defaultGroupName]);
580        $existing = $stmt->fetch(PDO::FETCH_ASSOC);
581
582        if ($existing) {
583            return (int) $existing['id'];
584        }
585
586        $stmt = $this->db->prepare("INSERT INTO taskGroups (groupName) VALUES (:groupName)");
587        $stmt->execute([':groupName' => $defaultGroupName]);
588        return (int) $this->db->lastInsertId();
589    }
590
591    /**
592     * Build the task comment/description field
593     *
594     * @param Event $event The event
595     * @param string $phase Task phase
596     * @param array $config Task config
597     * @return string Task comment
598     */
599    private function buildTaskComment(Event $event, string $phase, array $config): string
600    {
601        $parts = [];
602
603        // Add phase context
604        $phaseLabels = [
605            'prep' => 'Preparation task',
606            'active' => 'Active event task',
607            'cleanup' => 'Cleanup task',
608        ];
609        $parts[] = $phaseLabels[$phase] ?? 'Event task';
610
611        // Add event reference
612        $parts[] = "for {$event->name}";
613
614        // Add date info if there's a relative offset
615        $relativeDays = $this->getConfigValue($config, 'relativeDays', 0);
616        if ($relativeDays < 0) {
617            $days = abs($relativeDays);
618            $parts[] = "({$days} day" . ($days > 1 ? 's' : '') . " before event)";
619        } elseif ($relativeDays > 0) {
620            $parts[] = "({$relativeDays} day" . ($relativeDays > 1 ? 's' : '') . " after event start)";
621        }
622
623        return implode(' ', $parts);
624    }
625
626    /**
627     * Delete related records for a task before deleting the task
628     *
629     * @param int $taskId Task ID
630     */
631    private function deleteTaskRelatedRecords(int $taskId): void
632    {
633        // Delete completions if table exists
634        try {
635            $stmt = $this->db->prepare("DELETE FROM workbook_task_completions WHERE taskId = :taskId");
636            $stmt->execute([':taskId' => $taskId]);
637        } catch (\PDOException $e) {
638            // Table may not exist
639        }
640
641        // Delete comments if table exists
642        try {
643            $stmt = $this->db->prepare("DELETE FROM workbook_task_comments WHERE taskId = :taskId");
644            $stmt->execute([':taskId' => $taskId]);
645        } catch (\PDOException $e) {
646            // Table may not exist
647        }
648
649        // Delete assignments if table exists
650        try {
651            $stmt = $this->db->prepare("DELETE FROM workbook_task_assignments WHERE taskId = :taskId");
652            $stmt->execute([':taskId' => $taskId]);
653        } catch (\PDOException $e) {
654            // Table may not exist
655        }
656    }
657}