Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 311
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
SmsAdapter
0.00% covered (danger)
0.00%
0 / 311
0.00% covered (danger)
0.00%
0 / 23
4032
0.00% covered (danger)
0.00%
0 / 1
 resolveSubType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 create
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 createBlast
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 createTrigger
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 syncDates
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 syncBlastDates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 syncTriggerDates
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 activate
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 deactivate
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getBlastStatus
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 getTriggerStatus
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 validateConfig
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 validateBlastConfig
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 validateTriggerConfig
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 getDefaultConfig
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 calculateScheduledDateTime
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 blastExistsForEvent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 triggerExistsForEvent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getBlastsForEvent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getTriggersForEvent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\EventManagement\Adapters;
4
5use PDO;
6use DateTime;
7use DateTimeZone;
8use BuyerKiosk\EventManagement\Models\Event;
9use BuyerKiosk\EventManagement\Models\EventIntegration;
10
11/**
12 * SmsAdapter - Adapter for SMS blast and trigger integrations
13 *
14 * Translates Event operations to seller_marketing_blasts and seller_marketing_triggers.
15 * Handles both scheduled blasts (one-time campaigns) and automated triggers (recurring).
16 *
17 * Integration Types:
18 * - SMS Blast: One-time scheduled message to a customer segment
19 * - SMS Trigger: Automated trigger active during an event period
20 *
21 * Data Flow:
22 * - event.id -> blast.eventId / trigger.eventId (marks as event-managed)
23 * - relativeDays + event.startDate -> blast.scheduled_at
24 * - event.startDate/endDate -> trigger start_date/expire_date (via config)
25 * - config.messageId -> blast/trigger.message_id
26 *
27 * @package BuyerKiosk\EventManagement\Adapters
28 */
29class SmsAdapter extends AbstractAdapter
30{
31    /**
32     * Integration type constants
33     */
34    private const BLAST_TYPE = 'blast';
35    private const TRIGGER_TYPE = 'trigger';
36
37    /**
38     * Table names
39     */
40    private const BLAST_TABLE = 'seller_marketing_blasts';
41    private const TRIGGER_TABLE = 'seller_marketing_triggers';
42
43    /**
44     * @var string Current integration subtype ('blast' or 'trigger')
45     */
46    private string $subType = self::BLAST_TYPE;
47
48    /**
49     * Set the sub-type based on configuration
50     *
51     * @param array $config Configuration containing 'type' key
52     * @return void
53     */
54    private function resolveSubType(array $config): void
55    {
56        $this->subType = $this->getConfigValue($config, 'type', self::BLAST_TYPE);
57    }
58
59    /**
60     * Get the integration type this adapter handles
61     *
62     * Returns different type based on configuration.
63     *
64     * @return string EventIntegration::TYPE_SMS_BLAST or TYPE_SMS_TRIGGER
65     */
66    public function getType(): string
67    {
68        return $this->subType === self::TRIGGER_TYPE
69            ? EventIntegration::TYPE_SMS_TRIGGER
70            : EventIntegration::TYPE_SMS_BLAST;
71    }
72
73    /**
74     * Create a linked SMS blast or trigger record
75     *
76     * For blasts: Creates a seller_marketing_blasts record with calculated scheduled_at
77     * For triggers: Creates a seller_marketing_triggers record with event period dates
78     *
79     * @param Event $event The unified event
80     * @param array $config Integration-specific configuration:
81     *   For blast:
82     *     - type: 'blast'
83     *     - messageId: int (required) Message template ID
84     *     - sendTime: string (optional) Time of day HH:MM, default '10:00'
85     *     - segment: string (optional) Customer segment criteria
86     *     - relativeDays: int (optional) Days from event start, default 0
87     *   For trigger:
88     *     - type: 'trigger'
89     *     - messageId: int (required) Message template ID
90     *     - triggerType: string (optional) Type of trigger, default 'days_since_event'
91     *     - startRelativeDays: int (optional) Days from event start for trigger start
92     *     - endRelativeDays: int (optional) Days from event end for trigger end
93     *     - config: array (optional) Additional trigger configuration
94     * @return int The foreign ID of the created record
95     * @throws IntegrationException On failure
96     */
97    public function create(Event $event, array $config): int
98    {
99        $this->resolveSubType($config);
100
101        // Validate configuration
102        $errors = $this->validateConfig($config);
103        if (!empty($errors)) {
104            throw IntegrationException::invalidConfig($this->getType(), $errors);
105        }
106
107        try {
108            $this->beginTransaction();
109
110            if ($this->subType === self::TRIGGER_TYPE) {
111                $foreignId = $this->createTrigger($event, $config);
112            } else {
113                $foreignId = $this->createBlast($event, $config);
114            }
115
116            $this->commit();
117
118            $this->logError("Created {$this->getType()} for event {$event->id}, foreignId: {$foreignId}", [
119                'eventId' => $event->id,
120                'config' => $config,
121            ]);
122
123            return $foreignId;
124
125        } catch (\PDOException $e) {
126            $this->rollback();
127            throw IntegrationException::createFailed(
128                $this->getType(),
129                'Database error: ' . $e->getMessage(),
130                ['config' => $config],
131                $e
132            );
133        } catch (\Exception $e) {
134            $this->rollback();
135            throw IntegrationException::createFailed(
136                $this->getType(),
137                $e->getMessage(),
138                ['config' => $config],
139                $e
140            );
141        }
142    }
143
144    /**
145     * Create an SMS blast record
146     *
147     * @param Event $event The event
148     * @param array $config Blast configuration
149     * @return int Created blast ID
150     */
151    private function createBlast(Event $event, array $config): int
152    {
153        $messageId = (int) $config['messageId'];
154        $sendTime = $config['sendTime'] ?? '10:00';
155        $segment = $config['segment'] ?? '';
156        $relativeDays = (int) ($config['relativeDays'] ?? 0);
157
158        // Calculate scheduled_at from event start + relativeDays + sendTime
159        $scheduledAt = $this->calculateScheduledDateTime($event, $relativeDays, $sendTime);
160
161        // Build criteria from segment if provided
162        $criteria = [];
163        if (!empty($segment)) {
164            $criteria['segment'] = $segment;
165        }
166
167        $sql = "INSERT INTO " . self::BLAST_TABLE . "
168                (name, message_id, criteria, scheduled_at, status, eventId, created_at)
169                VALUES
170                (:name, :message_id, :criteria, :scheduled_at, :status, :eventId, NOW())";
171
172        $stmt = $this->db->prepare($sql);
173        $stmt->execute([
174            ':name' => "Event: {$event->name} - SMS Blast",
175            ':message_id' => $messageId,
176            ':criteria' => json_encode($criteria),
177            ':scheduled_at' => $this->formatDateForDb($scheduledAt),
178            ':status' => 'pending',
179            ':eventId' => $event->id,
180        ]);
181
182        return (int) $this->db->lastInsertId();
183    }
184
185    /**
186     * Create an SMS trigger record
187     *
188     * @param Event $event The event
189     * @param array $config Trigger configuration
190     * @return int Created trigger ID
191     */
192    private function createTrigger(Event $event, array $config): int
193    {
194        $messageId = (int) $config['messageId'];
195        $triggerType = $config['triggerType'] ?? 'days_since_event';
196        $startRelativeDays = (int) ($config['startRelativeDays'] ?? 0);
197        $endRelativeDays = (int) ($config['endRelativeDays'] ?? 0);
198        $triggerConfig = $config['config'] ?? [];
199
200        // Calculate trigger start and expire dates
201        $startDate = $this->calculateDateFromRelative($event, $startRelativeDays);
202        $expireDate = $this->calculateDateFromEndRelative($event, $endRelativeDays);
203
204        // Merge provided config with event-specific settings
205        $triggerConfig['event_id'] = $event->id;
206        $triggerConfig['event_name'] = $event->name;
207
208        $sql = "INSERT INTO " . self::TRIGGER_TABLE . "
209                (name, type, message_id, status, config, expire_date, eventId, created_at)
210                VALUES
211                (:name, :type, :message_id, :status, :config, :expire_date, :eventId, NOW())";
212
213        $stmt = $this->db->prepare($sql);
214        $stmt->execute([
215            ':name' => "Event: {$event->name} - SMS Trigger",
216            ':type' => $triggerType,
217            ':message_id' => $messageId,
218            ':status' => 'inactive', // Start inactive, activate() will enable
219            ':config' => json_encode($triggerConfig),
220            ':expire_date' => $this->formatDateOnly($expireDate),
221            ':eventId' => $event->id,
222        ]);
223
224        return (int) $this->db->lastInsertId();
225    }
226
227    /**
228     * Update the linked record when event dates change
229     *
230     * For blasts: Recalculates scheduled_at based on new event dates
231     * For triggers: Updates expire_date based on new event end date
232     *
233     * @param Event $event The updated event with new dates
234     * @param EventIntegration $integration The integration record with foreignId
235     * @throws IntegrationException On failure
236     */
237    public function syncDates(Event $event, EventIntegration $integration): void
238    {
239        $foreignId = $integration->foreignId;
240        $config = $integration->config ?? [];
241
242        try {
243            if ($integration->integrationType === EventIntegration::TYPE_SMS_TRIGGER) {
244                $this->syncTriggerDates($event, $foreignId, $config);
245            } else {
246                $this->syncBlastDates($event, $foreignId, $config);
247            }
248
249            $this->logError("Synced dates for {$integration->integrationType}, foreignId: {$foreignId}", [
250                'eventId' => $event->id,
251            ]);
252
253        } catch (\PDOException $e) {
254            throw IntegrationException::syncDatesFailed(
255                $integration->integrationType,
256                $foreignId,
257                'Database error: ' . $e->getMessage(),
258                $e
259            );
260        }
261    }
262
263    /**
264     * Sync blast dates with event
265     *
266     * @param Event $event The event
267     * @param int $foreignId Blast ID
268     * @param array $config Integration config
269     */
270    private function syncBlastDates(Event $event, int $foreignId, array $config): void
271    {
272        $sendTime = $config['sendTime'] ?? '10:00';
273        $relativeDays = (int) ($config['relativeDays'] ?? 0);
274
275        $scheduledAt = $this->calculateScheduledDateTime($event, $relativeDays, $sendTime);
276
277        $sql = "UPDATE " . self::BLAST_TABLE . "
278                SET scheduled_at = :scheduled_at
279                WHERE id = :id AND eventId = :eventId";
280
281        $stmt = $this->db->prepare($sql);
282        $stmt->execute([
283            ':scheduled_at' => $this->formatDateForDb($scheduledAt),
284            ':id' => $foreignId,
285            ':eventId' => $event->id,
286        ]);
287    }
288
289    /**
290     * Sync trigger dates with event
291     *
292     * @param Event $event The event
293     * @param int $foreignId Trigger ID
294     * @param array $config Integration config
295     */
296    private function syncTriggerDates(Event $event, int $foreignId, array $config): void
297    {
298        $endRelativeDays = (int) ($config['endRelativeDays'] ?? 0);
299        $expireDate = $this->calculateDateFromEndRelative($event, $endRelativeDays);
300
301        $sql = "UPDATE " . self::TRIGGER_TABLE . "
302                SET expire_date = :expire_date
303                WHERE id = :id AND eventId = :eventId";
304
305        $stmt = $this->db->prepare($sql);
306        $stmt->execute([
307            ':expire_date' => $this->formatDateOnly($expireDate),
308            ':id' => $foreignId,
309            ':eventId' => $event->id,
310        ]);
311    }
312
313    /**
314     * Activate the SMS integration
315     *
316     * For blasts: Ensures status is 'pending' (ready to send at scheduled time)
317     * For triggers: Sets status to 'active'
318     *
319     * @param Event $event The event being activated
320     * @param EventIntegration $integration The integration to activate
321     * @throws IntegrationException On failure
322     */
323    public function activate(Event $event, EventIntegration $integration): void
324    {
325        $foreignId = $integration->foreignId;
326
327        try {
328            if ($integration->integrationType === EventIntegration::TYPE_SMS_TRIGGER) {
329                $sql = "UPDATE " . self::TRIGGER_TABLE . "
330                        SET status = 'active'
331                        WHERE id = :id AND eventId = :eventId";
332            } else {
333                // For blasts, ensure it's pending (not cancelled)
334                $sql = "UPDATE " . self::BLAST_TABLE . "
335                        SET status = 'pending'
336                        WHERE id = :id AND eventId = :eventId AND status = 'cancelled'";
337            }
338
339            $stmt = $this->db->prepare($sql);
340            $stmt->execute([
341                ':id' => $foreignId,
342                ':eventId' => $event->id,
343            ]);
344
345            $this->logError("Activated {$integration->integrationType}, foreignId: {$foreignId}", [
346                'eventId' => $event->id,
347            ]);
348
349        } catch (\PDOException $e) {
350            throw IntegrationException::activateFailed(
351                $integration->integrationType,
352                $foreignId,
353                'Database error: ' . $e->getMessage(),
354                $e
355            );
356        }
357    }
358
359    /**
360     * Deactivate/cancel the SMS integration
361     *
362     * For blasts: Sets status to 'cancelled'
363     * For triggers: Sets status to 'inactive'
364     *
365     * @param Event $event The event being deactivated
366     * @param EventIntegration $integration The integration to deactivate
367     * @throws IntegrationException On failure
368     */
369    public function deactivate(Event $event, EventIntegration $integration): void
370    {
371        $foreignId = $integration->foreignId;
372
373        try {
374            if ($integration->integrationType === EventIntegration::TYPE_SMS_TRIGGER) {
375                $sql = "UPDATE " . self::TRIGGER_TABLE . "
376                        SET status = 'inactive'
377                        WHERE id = :id AND eventId = :eventId";
378            } else {
379                // Only cancel if still pending
380                $sql = "UPDATE " . self::BLAST_TABLE . "
381                        SET status = 'cancelled'
382                        WHERE id = :id AND eventId = :eventId AND status IN ('pending')";
383            }
384
385            $stmt = $this->db->prepare($sql);
386            $stmt->execute([
387                ':id' => $foreignId,
388                ':eventId' => $event->id,
389            ]);
390
391            $this->logError("Deactivated {$integration->integrationType}, foreignId: {$foreignId}", [
392                'eventId' => $event->id,
393            ]);
394
395        } catch (\PDOException $e) {
396            throw IntegrationException::deactivateFailed(
397                $integration->integrationType,
398                $foreignId,
399                'Database error: ' . $e->getMessage(),
400                $e
401            );
402        }
403    }
404
405    /**
406     * Delete the linked SMS record
407     *
408     * Permanently removes the blast or trigger record.
409     *
410     * @param EventIntegration $integration The integration to delete
411     * @throws IntegrationException On failure
412     */
413    public function delete(EventIntegration $integration): void
414    {
415        $foreignId = $integration->foreignId;
416
417        try {
418            $table = $integration->integrationType === EventIntegration::TYPE_SMS_TRIGGER
419                ? self::TRIGGER_TABLE
420                : self::BLAST_TABLE;
421
422            $sql = "DELETE FROM {$table} WHERE id = :id";
423            $stmt = $this->db->prepare($sql);
424            $stmt->execute([':id' => $foreignId]);
425
426            $this->logError("Deleted {$integration->integrationType}, foreignId: {$foreignId}");
427
428        } catch (\PDOException $e) {
429            throw IntegrationException::deleteFailed(
430                $integration->integrationType,
431                $foreignId,
432                'Database error: ' . $e->getMessage(),
433                $e
434            );
435        }
436    }
437
438    /**
439     * Get status information from the SMS system
440     *
441     * Returns delivery metrics and current status.
442     *
443     * @param EventIntegration $integration The integration to check
444     * @return array Status details including:
445     *   - 'status': string (pending, active, completed, failed, cancelled)
446     *   - 'details': array with sent_count, failed_count, recipient_count, etc.
447     *   - 'lastUpdated': string timestamp
448     */
449    public function getStatus(EventIntegration $integration): array
450    {
451        $foreignId = $integration->foreignId;
452
453        try {
454            if ($integration->integrationType === EventIntegration::TYPE_SMS_TRIGGER) {
455                return $this->getTriggerStatus($foreignId);
456            } else {
457                return $this->getBlastStatus($foreignId);
458            }
459        } catch (\PDOException $e) {
460            throw IntegrationException::getStatusFailed(
461                $integration->integrationType,
462                $foreignId,
463                'Database error: ' . $e->getMessage(),
464                $e
465            );
466        }
467    }
468
469    /**
470     * Get blast status and metrics
471     *
472     * @param int $foreignId Blast ID
473     * @return array Status information
474     */
475    private function getBlastStatus(int $foreignId): array
476    {
477        $sql = "SELECT status, recipient_count, sent_count, failed_count,
478                       scheduled_at, started_at, completed_at, error_message
479                FROM " . self::BLAST_TABLE . " WHERE id = :id";
480
481        $stmt = $this->db->prepare($sql);
482        $stmt->execute([':id' => $foreignId]);
483        $row = $stmt->fetch(PDO::FETCH_ASSOC);
484
485        if (!$row) {
486            return $this->buildStatusResponse('not_found', ['error' => 'Blast not found']);
487        }
488
489        $status = $row['status'];
490        $details = [
491            'recipient_count' => (int) ($row['recipient_count'] ?? 0),
492            'sent_count' => (int) ($row['sent_count'] ?? 0),
493            'failed_count' => (int) ($row['failed_count'] ?? 0),
494            'scheduled_at' => $row['scheduled_at'],
495            'started_at' => $row['started_at'],
496            'completed_at' => $row['completed_at'],
497        ];
498
499        // Calculate delivery rate
500        if ($details['recipient_count'] > 0) {
501            $details['delivery_rate'] = round(
502                ($details['sent_count'] / $details['recipient_count']) * 100,
503                2
504            );
505        } else {
506            $details['delivery_rate'] = 0;
507        }
508
509        if (!empty($row['error_message'])) {
510            $details['error_message'] = $row['error_message'];
511        }
512
513        $lastUpdated = $row['completed_at'] ?? $row['started_at'] ?? $row['scheduled_at'];
514
515        return $this->buildStatusResponse(
516            $status,
517            $details,
518            $lastUpdated ? $this->parseDate($lastUpdated) : null
519        );
520    }
521
522    /**
523     * Get trigger status and metrics
524     *
525     * @param int $foreignId Trigger ID
526     * @return array Status information
527     */
528    private function getTriggerStatus(int $foreignId): array
529    {
530        $sql = "SELECT status, last_processed, expire_date, config
531                FROM " . self::TRIGGER_TABLE . " WHERE id = :id";
532
533        $stmt = $this->db->prepare($sql);
534        $stmt->execute([':id' => $foreignId]);
535        $row = $stmt->fetch(PDO::FETCH_ASSOC);
536
537        if (!$row) {
538            return $this->buildStatusResponse('not_found', ['error' => 'Trigger not found']);
539        }
540
541        $status = $row['status'];
542        $details = [
543            'last_processed' => $row['last_processed'],
544            'expire_date' => $row['expire_date'],
545        ];
546
547        // Get message count from customer log if available
548        $logSql = "SELECT COUNT(*) as message_count
549                   FROM seller_marketing_customer_log
550                   WHERE trigger_id = :trigger_id";
551
552        $logStmt = $this->db->prepare($logSql);
553        $logStmt->execute([':trigger_id' => $foreignId]);
554        $logRow = $logStmt->fetch(PDO::FETCH_ASSOC);
555
556        $details['messages_sent'] = (int) ($logRow['message_count'] ?? 0);
557
558        $lastUpdated = $row['last_processed'] ?? null;
559
560        return $this->buildStatusResponse(
561            $status,
562            $details,
563            $lastUpdated ? $this->parseDate($lastUpdated) : null
564        );
565    }
566
567    /**
568     * Validate configuration before creating integration
569     *
570     * @param array $config Configuration to validate
571     * @return array Array of validation errors (empty if valid)
572     */
573    public function validateConfig(array $config): array
574    {
575        $type = $config['type'] ?? self::BLAST_TYPE;
576        $this->resolveSubType($config);
577
578        // Common validation
579        $errors = $this->validateRequiredKeys($config, ['messageId']);
580
581        // Validate messageId is positive integer
582        if (isset($config['messageId'])) {
583            $messageId = $config['messageId'];
584            if (!is_numeric($messageId) || (int) $messageId <= 0) {
585                $errors[] = 'messageId must be a positive integer';
586            }
587        }
588
589        if ($type === self::BLAST_TYPE) {
590            $errors = array_merge($errors, $this->validateBlastConfig($config));
591        } else {
592            $errors = array_merge($errors, $this->validateTriggerConfig($config));
593        }
594
595        return $errors;
596    }
597
598    /**
599     * Validate blast-specific configuration
600     *
601     * @param array $config Configuration to validate
602     * @return array Validation errors
603     */
604    private function validateBlastConfig(array $config): array
605    {
606        $errors = [];
607
608        // Validate sendTime format if provided
609        if (isset($config['sendTime']) && !empty($config['sendTime'])) {
610            if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $config['sendTime'])) {
611                $errors[] = 'sendTime must be in HH:MM format (e.g., 10:00 or 14:30)';
612            }
613        }
614
615        // Validate relativeDays is reasonable
616        if (isset($config['relativeDays'])) {
617            $days = (int) $config['relativeDays'];
618            if ($days < -365 || $days > 365) {
619                $errors[] = 'relativeDays must be between -365 and 365';
620            }
621        }
622
623        return $errors;
624    }
625
626    /**
627     * Validate trigger-specific configuration
628     *
629     * @param array $config Configuration to validate
630     * @return array Validation errors
631     */
632    private function validateTriggerConfig(array $config): array
633    {
634        $errors = [];
635
636        // Validate triggerType if provided
637        $validTriggerTypes = [
638            'days_since_event',
639            'days_since_sold',
640            'birthday',
641            'expiring_points',
642            'custom',
643        ];
644
645        if (isset($config['triggerType'])) {
646            if (!in_array($config['triggerType'], $validTriggerTypes, true)) {
647                $errors[] = 'triggerType must be one of: ' . implode(', ', $validTriggerTypes);
648            }
649        }
650
651        // Validate relative days ranges
652        foreach (['startRelativeDays', 'endRelativeDays'] as $key) {
653            if (isset($config[$key])) {
654                $days = (int) $config[$key];
655                if ($days < -365 || $days > 365) {
656                    $errors[] = "{$key} must be between -365 and 365";
657                }
658            }
659        }
660
661        return $errors;
662    }
663
664    /**
665     * Get the default configuration for this integration type
666     *
667     * @return array Default configuration values
668     */
669    public function getDefaultConfig(): array
670    {
671        return [
672            'type' => self::BLAST_TYPE,
673            'messageId' => null,
674            'sendTime' => '10:00',
675            'segment' => '',
676            'relativeDays' => 0,
677            // Trigger-specific defaults
678            'triggerType' => 'days_since_event',
679            'startRelativeDays' => 0,
680            'endRelativeDays' => 0,
681            'config' => [],
682        ];
683    }
684
685    /**
686     * Calculate scheduled DateTime from event date, relative days, and send time
687     *
688     * @param Event $event The event
689     * @param int $relativeDays Days from event start
690     * @param string $sendTime Time of day in HH:MM format
691     * @return DateTime Calculated scheduled datetime
692     */
693    private function calculateScheduledDateTime(Event $event, int $relativeDays, string $sendTime): DateTime
694    {
695        $date = $this->calculateDateFromRelative($event, $relativeDays);
696
697        // Parse the send time
698        $timeParts = explode(':', $sendTime);
699        $hours = (int) ($timeParts[0] ?? 10);
700        $minutes = (int) ($timeParts[1] ?? 0);
701
702        // Set the time
703        $date->setTime($hours, $minutes, 0);
704
705        // Apply timezone if available
706        if ($this->timezone) {
707            try {
708                $tz = new DateTimeZone($this->timezone);
709                $date->setTimezone($tz);
710            } catch (\Exception $e) {
711                // Use default if timezone is invalid
712                $this->logError("Invalid timezone: {$this->timezone}, using default");
713            }
714        }
715
716        return $date;
717    }
718
719    /**
720     * Check if a blast record exists and belongs to an event
721     *
722     * @param int $blastId Blast ID
723     * @param int $eventId Event ID
724     * @return bool
725     */
726    public function blastExistsForEvent(int $blastId, int $eventId): bool
727    {
728        $sql = "SELECT 1 FROM " . self::BLAST_TABLE . "
729                WHERE id = :id AND eventId = :eventId LIMIT 1";
730        $stmt = $this->db->prepare($sql);
731        $stmt->execute([':id' => $blastId, ':eventId' => $eventId]);
732        return $stmt->fetch() !== false;
733    }
734
735    /**
736     * Check if a trigger record exists and belongs to an event
737     *
738     * @param int $triggerId Trigger ID
739     * @param int $eventId Event ID
740     * @return bool
741     */
742    public function triggerExistsForEvent(int $triggerId, int $eventId): bool
743    {
744        $sql = "SELECT 1 FROM " . self::TRIGGER_TABLE . "
745                WHERE id = :id AND eventId = :eventId LIMIT 1";
746        $stmt = $this->db->prepare($sql);
747        $stmt->execute([':id' => $triggerId, ':eventId' => $eventId]);
748        return $stmt->fetch() !== false;
749    }
750
751    /**
752     * Get all blasts linked to an event
753     *
754     * @param int $eventId Event ID
755     * @return array Array of blast records
756     */
757    public function getBlastsForEvent(int $eventId): array
758    {
759        $sql = "SELECT * FROM " . self::BLAST_TABLE . " WHERE eventId = :eventId";
760        $stmt = $this->db->prepare($sql);
761        $stmt->execute([':eventId' => $eventId]);
762        return $stmt->fetchAll(PDO::FETCH_ASSOC);
763    }
764
765    /**
766     * Get all triggers linked to an event
767     *
768     * @param int $eventId Event ID
769     * @return array Array of trigger records
770     */
771    public function getTriggersForEvent(int $eventId): array
772    {
773        $sql = "SELECT * FROM " . self::TRIGGER_TABLE . " WHERE eventId = :eventId";
774        $stmt = $this->db->prepare($sql);
775        $stmt->execute([':eventId' => $eventId]);
776        return $stmt->fetchAll(PDO::FETCH_ASSOC);
777    }
778}