Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 239
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
NoteAdapter
0.00% covered (danger)
0.00%
0 / 239
0.00% covered (danger)
0.00%
0 / 12
2862
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 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 syncDates
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 activate
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
20
 validateConfig
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
240
 getDefaultConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 calculateVisibilityStartDate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 calculateVisibilityEndDate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 calculateVisibilityStatus
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace BuyerKiosk\EventManagement\Adapters;
4
5use PDO;
6use PDOException;
7use DateTime;
8use BuyerKiosk\EventManagement\Models\Event;
9use BuyerKiosk\EventManagement\Models\EventIntegration;
10
11/**
12 * NoteAdapter - Integration adapter for workbook notes
13 *
14 * Creates and manages staff announcements/notes linked to events.
15 * Notes have visibility windows calculated relative to event dates.
16 *
17 * Key features:
18 * - Creates notes with eventId FK for traceability
19 * - Visibility dates calculated from event start/end + relative days
20 * - Manager-only and pinned flag support
21 * - Automatic date sync when event dates change
22 *
23 * Table: workbook_notes
24 * FK: workbook_notes.eventId -> events.id
25 *
26 * @package BuyerKiosk\EventManagement\Adapters
27 */
28class NoteAdapter extends AbstractAdapter
29{
30    /**
31     * @var string Author name for event-created notes
32     */
33    private const EVENT_AUTHOR_NAME = 'Event System';
34
35    /**
36     * Get the integration type this adapter handles
37     *
38     * @return string The integration type constant
39     */
40    public function getType(): string
41    {
42        return EventIntegration::TYPE_NOTE;
43    }
44
45    /**
46     * Create a linked note in workbook_notes
47     *
48     * Creates a note with:
49     * - eventId set for traceability
50     * - Visibility dates calculated from event dates + relative days
51     * - Manager-only and pinned flags from config
52     *
53     * @param Event $event The unified event
54     * @param array $config Integration-specific configuration
55     * @return int The note ID of the created record
56     * @throws IntegrationException On failure
57     */
58    public function create(Event $event, array $config): int
59    {
60        // Validate configuration
61        $errors = $this->validateConfig($config);
62        if (!empty($errors)) {
63            throw IntegrationException::invalidConfig($this->getType(), $errors);
64        }
65
66        // Merge with defaults
67        $config = array_merge($this->getDefaultConfig(), $config);
68
69        // Calculate visibility dates
70        $startDate = $this->calculateVisibilityStartDate($event, $config);
71        $endDate = $this->calculateVisibilityEndDate($event, $config);
72
73        try {
74            $this->beginTransaction();
75
76            // Insert the note
77            $stmt = $this->db->prepare("
78                INSERT INTO workbook_notes
79                (authorName, title, content, startDate, endDate, isManagerOnly, isPinned, eventId)
80                VALUES
81                (:authorName, :title, :content, :startDate, :endDate, :isManagerOnly, :isPinned, :eventId)
82            ");
83
84            $result = $stmt->execute([
85                ':authorName' => self::EVENT_AUTHOR_NAME,
86                ':title' => $config['title'],
87                ':content' => $config['content'],
88                ':startDate' => $this->formatDateOnly($startDate),
89                ':endDate' => $endDate ? $this->formatDateOnly($endDate) : null,
90                ':isManagerOnly' => $config['isManagerOnly'] ? 1 : 0,
91                ':isPinned' => $config['isPinned'] ? 1 : 0,
92                ':eventId' => $event->id,
93            ]);
94
95            if (!$result) {
96                throw new PDOException('Insert statement failed');
97            }
98
99            $noteId = (int) $this->db->lastInsertId();
100
101            $this->commit();
102
103            $this->logError("Created note #{$noteId} for event #{$event->id}", [
104                'title' => $config['title'],
105                'startDate' => $this->formatDateOnly($startDate),
106                'endDate' => $endDate ? $this->formatDateOnly($endDate) : 'indefinite',
107            ]);
108
109            return $noteId;
110
111        } catch (PDOException $e) {
112            $this->rollback();
113            $this->logError("Failed to create note for event #{$event->id}", [
114                'error' => $e->getMessage(),
115                'config' => $config,
116            ]);
117            throw IntegrationException::createFailed(
118                $this->getType(),
119                $e->getMessage(),
120                ['eventId' => $event->id],
121                $e
122            );
123        }
124    }
125
126    /**
127     * Update note visibility dates when event dates change
128     *
129     * Recalculates the startDate and endDate based on the new event dates
130     * and the relative days stored in the integration config.
131     *
132     * @param Event $event The updated event with new dates
133     * @param EventIntegration $integration The integration record with foreignId
134     * @throws IntegrationException On failure
135     */
136    public function syncDates(Event $event, EventIntegration $integration): void
137    {
138        $noteId = $integration->foreignId;
139
140        // Verify note exists
141        if (!$this->recordExists('workbook_notes', $noteId)) {
142            throw IntegrationException::targetNotFound($this->getType(), $noteId);
143        }
144
145        // Get config from integration
146        $config = $integration->config ?? $this->getDefaultConfig();
147        $config = array_merge($this->getDefaultConfig(), $config);
148
149        // Recalculate visibility dates
150        $startDate = $this->calculateVisibilityStartDate($event, $config);
151        $endDate = $this->calculateVisibilityEndDate($event, $config);
152
153        try {
154            $stmt = $this->db->prepare("
155                UPDATE workbook_notes
156                SET startDate = :startDate,
157                    endDate = :endDate
158                WHERE id = :id AND eventId = :eventId
159            ");
160
161            $result = $stmt->execute([
162                ':startDate' => $this->formatDateOnly($startDate),
163                ':endDate' => $endDate ? $this->formatDateOnly($endDate) : null,
164                ':id' => $noteId,
165                ':eventId' => $event->id,
166            ]);
167
168            if (!$result || $stmt->rowCount() === 0) {
169                // Note may have been manually modified or eventId changed
170                $this->logError("Note #{$noteId} not updated - may be manually managed", [
171                    'eventId' => $event->id,
172                ]);
173            }
174
175            $this->logError("Synced dates for note #{$noteId}", [
176                'eventId' => $event->id,
177                'newStartDate' => $this->formatDateOnly($startDate),
178                'newEndDate' => $endDate ? $this->formatDateOnly($endDate) : 'indefinite',
179            ]);
180
181        } catch (PDOException $e) {
182            $this->logError("Failed to sync dates for note #{$noteId}", [
183                'error' => $e->getMessage(),
184                'eventId' => $event->id,
185            ]);
186            throw IntegrationException::syncDatesFailed(
187                $this->getType(),
188                $noteId,
189                $e->getMessage(),
190                $e
191            );
192        }
193    }
194
195    /**
196     * Activate the note (make it visible now)
197     *
198     * Sets the startDate to today if it's in the future,
199     * making the note immediately visible.
200     *
201     * @param Event $event The event being activated
202     * @param EventIntegration $integration The integration to activate
203     * @throws IntegrationException On failure
204     */
205    public function activate(Event $event, EventIntegration $integration): void
206    {
207        $noteId = $integration->foreignId;
208
209        if (!$this->recordExists('workbook_notes', $noteId)) {
210            throw IntegrationException::targetNotFound($this->getType(), $noteId);
211        }
212
213        try {
214            $today = new DateTime();
215            $todayStr = $this->formatDateOnly($today);
216
217            // Set startDate to today if it's in the future
218            $stmt = $this->db->prepare("
219                UPDATE workbook_notes
220                SET startDate = CASE
221                    WHEN startDate > :today THEN :today
222                    ELSE startDate
223                END
224                WHERE id = :id AND eventId = :eventId
225            ");
226
227            $stmt->execute([
228                ':today' => $todayStr,
229                ':id' => $noteId,
230                ':eventId' => $event->id,
231            ]);
232
233            $this->logError("Activated note #{$noteId} for event #{$event->id}");
234
235        } catch (PDOException $e) {
236            $this->logError("Failed to activate note #{$noteId}", [
237                'error' => $e->getMessage(),
238            ]);
239            throw IntegrationException::activateFailed(
240                $this->getType(),
241                $noteId,
242                $e->getMessage(),
243                $e
244            );
245        }
246    }
247
248    /**
249     * Deactivate the note (hide it)
250     *
251     * Sets the endDate to yesterday, making the note no longer visible.
252     * Does not delete the note, preserving it for historical reference.
253     *
254     * @param Event $event The event being deactivated
255     * @param EventIntegration $integration The integration to deactivate
256     * @throws IntegrationException On failure
257     */
258    public function deactivate(Event $event, EventIntegration $integration): void
259    {
260        $noteId = $integration->foreignId;
261
262        if (!$this->recordExists('workbook_notes', $noteId)) {
263            throw IntegrationException::targetNotFound($this->getType(), $noteId);
264        }
265
266        try {
267            $yesterday = new DateTime('-1 day');
268            $yesterdayStr = $this->formatDateOnly($yesterday);
269
270            // Set endDate to yesterday to hide the note
271            $stmt = $this->db->prepare("
272                UPDATE workbook_notes
273                SET endDate = :yesterday
274                WHERE id = :id AND eventId = :eventId
275            ");
276
277            $stmt->execute([
278                ':yesterday' => $yesterdayStr,
279                ':id' => $noteId,
280                ':eventId' => $event->id,
281            ]);
282
283            $this->logError("Deactivated note #{$noteId} for event #{$event->id}");
284
285        } catch (PDOException $e) {
286            $this->logError("Failed to deactivate note #{$noteId}", [
287                'error' => $e->getMessage(),
288            ]);
289            throw IntegrationException::deactivateFailed(
290                $this->getType(),
291                $noteId,
292                $e->getMessage(),
293                $e
294            );
295        }
296    }
297
298    /**
299     * Delete the note and all related reactions/comments
300     *
301     * Performs cascade delete of:
302     * - Note reactions
303     * - Note comments
304     * - The note itself
305     *
306     * @param EventIntegration $integration The integration to delete
307     * @throws IntegrationException On failure
308     */
309    public function delete(EventIntegration $integration): void
310    {
311        $noteId = $integration->foreignId;
312
313        // Allow deletion even if note doesn't exist (idempotent)
314        if (!$this->recordExists('workbook_notes', $noteId)) {
315            $this->logError("Note #{$noteId} already deleted or not found");
316            return;
317        }
318
319        try {
320            $this->beginTransaction();
321
322            // Delete reactions
323            $stmt = $this->db->prepare("DELETE FROM workbook_note_reactions WHERE noteId = :noteId");
324            $stmt->execute([':noteId' => $noteId]);
325
326            // Delete comments
327            $stmt = $this->db->prepare("DELETE FROM workbook_note_comments WHERE noteId = :noteId");
328            $stmt->execute([':noteId' => $noteId]);
329
330            // Delete the note
331            $stmt = $this->db->prepare("DELETE FROM workbook_notes WHERE id = :id");
332            $stmt->execute([':id' => $noteId]);
333
334            $this->commit();
335
336            $this->logError("Deleted note #{$noteId} and related records");
337
338        } catch (PDOException $e) {
339            $this->rollback();
340            $this->logError("Failed to delete note #{$noteId}", [
341                'error' => $e->getMessage(),
342            ]);
343            throw IntegrationException::deleteFailed(
344                $this->getType(),
345                $noteId,
346                $e->getMessage(),
347                $e
348            );
349        }
350    }
351
352    /**
353     * Get status information for the note
354     *
355     * Returns visibility status based on current date relative to
356     * the note's startDate and endDate.
357     *
358     * @param EventIntegration $integration The integration to check
359     * @return array Status details
360     * @throws IntegrationException On failure
361     */
362    public function getStatus(EventIntegration $integration): array
363    {
364        $noteId = $integration->foreignId;
365
366        try {
367            $stmt = $this->db->prepare("
368                SELECT
369                    id, title, startDate, endDate, isManagerOnly, isPinned,
370                    createdAt, updatedAt, deletedAt,
371                    (SELECT COUNT(*) FROM workbook_note_reactions WHERE noteId = :noteId1) as reactionCount,
372                    (SELECT COUNT(*) FROM workbook_note_comments WHERE noteId = :noteId2) as commentCount
373                FROM workbook_notes
374                WHERE id = :id
375            ");
376
377            $stmt->execute([
378                ':id' => $noteId,
379                ':noteId1' => $noteId,
380                ':noteId2' => $noteId,
381            ]);
382
383            $note = $stmt->fetch(PDO::FETCH_ASSOC);
384
385            if (!$note) {
386                return $this->buildStatusResponse(
387                    EventIntegration::STATUS_FAILED,
388                    ['error' => 'Note not found'],
389                    new DateTime()
390                );
391            }
392
393            // Check if soft-deleted
394            if (!empty($note['deletedAt'])) {
395                return $this->buildStatusResponse(
396                    EventIntegration::STATUS_COMPLETED,
397                    [
398                        'noteId' => $noteId,
399                        'title' => $note['title'],
400                        'deleted' => true,
401                        'deletedAt' => $note['deletedAt'],
402                    ],
403                    $this->parseDate($note['updatedAt'])
404                );
405            }
406
407            // Determine visibility status
408            $today = new DateTime();
409            $startDate = $this->parseDate($note['startDate']);
410            $endDate = $this->parseDate($note['endDate']);
411
412            $visibilityStatus = $this->calculateVisibilityStatus($today, $startDate, $endDate);
413
414            return $this->buildStatusResponse(
415                $visibilityStatus,
416                [
417                    'noteId' => $noteId,
418                    'title' => $note['title'],
419                    'startDate' => $note['startDate'],
420                    'endDate' => $note['endDate'],
421                    'isManagerOnly' => (bool) $note['isManagerOnly'],
422                    'isPinned' => (bool) $note['isPinned'],
423                    'reactionCount' => (int) $note['reactionCount'],
424                    'commentCount' => (int) $note['commentCount'],
425                    'isVisible' => $visibilityStatus === EventIntegration::STATUS_ACTIVE,
426                ],
427                $this->parseDate($note['updatedAt']) ?? new DateTime()
428            );
429
430        } catch (PDOException $e) {
431            $this->logError("Failed to get status for note #{$noteId}", [
432                'error' => $e->getMessage(),
433            ]);
434            throw IntegrationException::getStatusFailed(
435                $this->getType(),
436                $noteId,
437                $e->getMessage(),
438                $e
439            );
440        }
441    }
442
443    /**
444     * Validate configuration before creating integration
445     *
446     * Required fields:
447     * - title: Non-empty string
448     * - content: Non-empty string
449     *
450     * Optional fields with validation:
451     * - startRelativeDays: Integer
452     * - endRelativeDays: Integer or null
453     * - isManagerOnly: Boolean
454     * - isPinned: Boolean
455     *
456     * @param array $config Configuration to validate
457     * @return array Array of validation errors (empty if valid)
458     */
459    public function validateConfig(array $config): array
460    {
461        $errors = [];
462
463        // Title is required
464        if (empty($config['title']) || !is_string($config['title'])) {
465            $errors[] = 'title is required and must be a non-empty string';
466        } elseif (strlen($config['title']) > 255) {
467            $errors[] = 'title cannot exceed 255 characters';
468        }
469
470        // Content is required
471        if (empty($config['content']) || !is_string($config['content'])) {
472            $errors[] = 'content is required and must be a non-empty string';
473        }
474
475        // startRelativeDays must be an integer if provided
476        if (isset($config['startRelativeDays']) && !is_int($config['startRelativeDays'])) {
477            $errors[] = 'startRelativeDays must be an integer';
478        }
479
480        // endRelativeDays must be an integer or null if provided
481        if (isset($config['endRelativeDays']) && $config['endRelativeDays'] !== null && !is_int($config['endRelativeDays'])) {
482            $errors[] = 'endRelativeDays must be an integer or null';
483        }
484
485        // isManagerOnly must be boolean if provided
486        if (isset($config['isManagerOnly']) && !is_bool($config['isManagerOnly'])) {
487            $errors[] = 'isManagerOnly must be a boolean';
488        }
489
490        // isPinned must be boolean if provided
491        if (isset($config['isPinned']) && !is_bool($config['isPinned'])) {
492            $errors[] = 'isPinned must be a boolean';
493        }
494
495        return $errors;
496    }
497
498    /**
499     * Get the default configuration for note integration
500     *
501     * Defaults:
502     * - startRelativeDays: 0 (note visible on event start)
503     * - endRelativeDays: null (note visible until event end + wind-down)
504     * - isManagerOnly: false (visible to all staff)
505     * - isPinned: false (not pinned)
506     *
507     * @return array Default configuration values
508     */
509    public function getDefaultConfig(): array
510    {
511        return [
512            'title' => '',
513            'content' => '',
514            'startRelativeDays' => 0,
515            'endRelativeDays' => null,
516            'isManagerOnly' => false,
517            'isPinned' => false,
518        ];
519    }
520
521    /**
522     * Calculate the visibility start date for the note
523     *
524     * @param Event $event The event
525     * @param array $config Configuration with startRelativeDays
526     * @return DateTime Calculated start date
527     */
528    private function calculateVisibilityStartDate(Event $event, array $config): DateTime
529    {
530        $relativeDays = $config['startRelativeDays'] ?? 0;
531        return $this->calculateDateFromRelative($event, $relativeDays);
532    }
533
534    /**
535     * Calculate the visibility end date for the note
536     *
537     * If endRelativeDays is null, the note visibility ends at event end + wind-down.
538     *
539     * @param Event $event The event
540     * @param array $config Configuration with endRelativeDays
541     * @return DateTime|null Calculated end date (null = indefinite)
542     */
543    private function calculateVisibilityEndDate(Event $event, array $config): ?DateTime
544    {
545        $relativeDays = $config['endRelativeDays'] ?? null;
546
547        if ($relativeDays === null) {
548            // Default to wind-down end date
549            return $this->getWindDownEndDate($event);
550        }
551
552        return $this->calculateDateFromEndRelative($event, $relativeDays);
553    }
554
555    /**
556     * Calculate the visibility status based on dates
557     *
558     * @param DateTime $today Current date
559     * @param DateTime|null $startDate Note start date
560     * @param DateTime|null $endDate Note end date
561     * @return string Status constant
562     */
563    private function calculateVisibilityStatus(DateTime $today, ?DateTime $startDate, ?DateTime $endDate): string
564    {
565        // No start date = assume pending
566        if ($startDate === null) {
567            return EventIntegration::STATUS_PENDING;
568        }
569
570        // Before start date = pending
571        if ($today < $startDate) {
572            return EventIntegration::STATUS_PENDING;
573        }
574
575        // After end date = completed
576        if ($endDate !== null && $today > $endDate) {
577            return EventIntegration::STATUS_COMPLETED;
578        }
579
580        // Within visibility window = active
581        return EventIntegration::STATUS_ACTIVE;
582    }
583}