Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateService
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 14
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 list
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 get
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getIntegrations
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 create
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 update
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
72
 delete
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 applyToEvent
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
156
 createIntegrations
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 hydrateTemplateFromData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 mergeTemplateData
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 parseDateTime
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 camelToSnake
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatValueForDb
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\EventManagement\Services;
4
5use PDO;
6use PDOException;
7use InvalidArgumentException;
8use BuyerKiosk\EventManagement\Models\Event;
9use BuyerKiosk\EventManagement\Models\EventTemplate;
10use BuyerKiosk\EventManagement\Models\EventIntegration;
11
12/**
13 * TemplateService - Manages event templates in the central database
14 *
15 * Templates allow stores to create events from predefined configurations.
16 * Templates are stored in the central database (kiosk_buykiosk) and can be:
17 * - Global: Available to all stores
18 * - Franchise: Available to stores in a franchise group
19 * - Store: Custom templates for specific stores
20 *
21 * @package BuyerKiosk\EventManagement\Services
22 */
23class TemplateService
24{
25    private PDO $centralDb;
26
27    /**
28     * Constructor
29     *
30     * @param PDO $centralDb Central database connection (kiosk_buykiosk)
31     */
32    public function __construct(PDO $centralDb)
33    {
34        $this->centralDb = $centralDb;
35    }
36
37    // =========================================================================
38    // LIST & GET OPERATIONS
39    // =========================================================================
40
41    /**
42     * List available templates for a store
43     *
44     * @param string|null $storeType Filter by store type (ou, pa, etc.)
45     * @param string|null $scope Filter by scope (global, franchise, store)
46     * @return EventTemplate[] Array of EventTemplate objects
47     */
48    public function list(?string $storeType = null, ?string $scope = null): array
49    {
50        $sql = "SELECT * FROM eventTemplates WHERE isActive = 1";
51        $params = [];
52
53        if ($storeType !== null) {
54            // Include templates that match the store type OR have no store type restriction
55            $sql .= " AND (storeType = :storeType OR storeType IS NULL)";
56            $params[':storeType'] = $storeType;
57        }
58
59        if ($scope !== null) {
60            $sql .= " AND scope = :scope";
61            $params[':scope'] = $scope;
62        }
63
64        $sql .= " ORDER BY scope, eventType, name";
65
66        $stmt = $this->centralDb->prepare($sql);
67        $stmt->execute($params);
68
69        $templates = [];
70        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
71            $templates[] = EventTemplate::fromRow($row);
72        }
73
74        return $templates;
75    }
76
77    /**
78     * Get template by ID with integrations
79     *
80     * @param int $templateId Template ID
81     * @return EventTemplate|null Template object or null if not found
82     */
83    public function get(int $templateId): ?EventTemplate
84    {
85        $sql = "SELECT * FROM eventTemplates WHERE id = :id LIMIT 1";
86        $stmt = $this->centralDb->prepare($sql);
87        $stmt->execute([':id' => $templateId]);
88        $row = $stmt->fetch(PDO::FETCH_ASSOC);
89
90        if (!$row) {
91            return null;
92        }
93
94        $template = EventTemplate::fromRow($row);
95
96        // Load integrations
97        $template->integrations = $this->getIntegrations($templateId);
98
99        return $template;
100    }
101
102    /**
103     * Get template integrations
104     *
105     * @param int $templateId Template ID
106     * @return array Array of integration configuration arrays
107     */
108    public function getIntegrations(int $templateId): array
109    {
110        $sql = "SELECT * FROM eventTemplate_Integrations WHERE templateId = :templateId ORDER BY integrationType, id";
111        $stmt = $this->centralDb->prepare($sql);
112        $stmt->execute([':templateId' => $templateId]);
113
114        $integrations = [];
115        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
116            $integrations[] = [
117                'id' => (int) $row['id'],
118                'templateId' => (int) $row['templateId'],
119                'integrationType' => $row['integrationType'],
120                'config' => $row['config'] ? json_decode($row['config'], true) : null,
121                'relativeDays' => isset($row['relativeDays']) ? (int) $row['relativeDays'] : null,
122            ];
123        }
124
125        return $integrations;
126    }
127
128    // =========================================================================
129    // CREATE, UPDATE, DELETE OPERATIONS
130    // =========================================================================
131
132    /**
133     * Create store-specific template
134     *
135     * @param array $data Template data
136     * @return EventTemplate Created template
137     * @throws InvalidArgumentException On validation failure
138     */
139    public function create(array $data): EventTemplate
140    {
141        $template = $this->hydrateTemplateFromData($data);
142        $errors = $template->validate();
143
144        if (!empty($errors)) {
145            throw new InvalidArgumentException(implode('; ', $errors));
146        }
147
148        $sql = "INSERT INTO eventTemplates (
149            name, description, eventType, scope, storeType,
150            defaultBuildUpDays, defaultWindDownDays,
151            color, icon, isActive, created_at, updated_at
152        ) VALUES (
153            :name, :description, :eventType, :scope, :storeType,
154            :defaultBuildUpDays, :defaultWindDownDays,
155            :color, :icon, :isActive, NOW(), NOW()
156        )";
157
158        $stmt = $this->centralDb->prepare($sql);
159        $stmt->execute([
160            ':name' => $template->name,
161            ':description' => $template->description,
162            ':eventType' => $template->eventType,
163            ':scope' => $template->scope,
164            ':storeType' => $template->storeType,
165            ':defaultBuildUpDays' => $template->defaultBuildUpDays,
166            ':defaultWindDownDays' => $template->defaultWindDownDays,
167            ':color' => $template->color,
168            ':icon' => $template->icon,
169            ':isActive' => $template->isActive ? 1 : 0,
170        ]);
171
172        $templateId = (int) $this->centralDb->lastInsertId();
173
174        // Create integrations if provided
175        if (!empty($data['integrations'])) {
176            $this->createIntegrations($templateId, $data['integrations']);
177        }
178
179        return $this->get($templateId);
180    }
181
182    /**
183     * Update template
184     *
185     * @param int $templateId Template ID
186     * @param array $data Fields to update
187     * @return EventTemplate Updated template
188     * @throws InvalidArgumentException On validation failure
189     */
190    public function update(int $templateId, array $data): EventTemplate
191    {
192        $existingTemplate = $this->get($templateId);
193        if (!$existingTemplate) {
194            throw new InvalidArgumentException('Template not found');
195        }
196
197        // Merge existing data with updates
198        $mergedData = $this->mergeTemplateData($existingTemplate, $data);
199        $template = $this->hydrateTemplateFromData($mergedData);
200
201        $errors = $template->validate();
202        if (!empty($errors)) {
203            throw new InvalidArgumentException(implode('; ', $errors));
204        }
205
206        // Build dynamic UPDATE query
207        $allowedFields = [
208            'name', 'description', 'eventType', 'scope', 'storeType',
209            'defaultBuildUpDays', 'defaultWindDownDays',
210            'color', 'icon', 'isActive',
211        ];
212
213        $setClauses = [];
214        $params = [':id' => $templateId];
215
216        foreach ($allowedFields as $field) {
217            if (array_key_exists($field, $data)) {
218                // DB uses camelCase column names, so no conversion needed
219                $value = $this->formatValueForDb($field, $data[$field]);
220                $setClauses[] = "`{$field}` = :{$field}";
221                $params[":{$field}"] = $value;
222            }
223        }
224
225        if (empty($setClauses)) {
226            return $existingTemplate;
227        }
228
229        $setClauses[] = "updated_at = NOW()";
230        $sql = "UPDATE eventTemplates SET " . implode(', ', $setClauses) . " WHERE id = :id";
231
232        $stmt = $this->centralDb->prepare($sql);
233        $stmt->execute($params);
234
235        // Update integrations if provided
236        if (array_key_exists('integrations', $data)) {
237            // Delete existing integrations and recreate
238            $sql = "DELETE FROM eventTemplate_Integrations WHERE templateId = :templateId";
239            $stmt = $this->centralDb->prepare($sql);
240            $stmt->execute([':templateId' => $templateId]);
241
242            if (!empty($data['integrations'])) {
243                $this->createIntegrations($templateId, $data['integrations']);
244            }
245        }
246
247        return $this->get($templateId);
248    }
249
250    /**
251     * Delete template
252     *
253     * @param int $templateId Template ID
254     * @return bool True if deleted
255     * @throws InvalidArgumentException If template not found
256     */
257    public function delete(int $templateId): bool
258    {
259        $template = $this->get($templateId);
260        if (!$template) {
261            throw new InvalidArgumentException('Template not found');
262        }
263
264        $this->centralDb->beginTransaction();
265
266        try {
267            // Delete integrations first
268            $sql = "DELETE FROM eventTemplate_Integrations WHERE templateId = :templateId";
269            $stmt = $this->centralDb->prepare($sql);
270            $stmt->execute([':templateId' => $templateId]);
271
272            // Delete the template
273            $sql = "DELETE FROM eventTemplates WHERE id = :id";
274            $stmt = $this->centralDb->prepare($sql);
275            $stmt->execute([':id' => $templateId]);
276
277            $this->centralDb->commit();
278
279            return $stmt->rowCount() > 0;
280        } catch (\Exception $e) {
281            $this->centralDb->rollBack();
282            throw $e;
283        }
284    }
285
286    // =========================================================================
287    // TEMPLATE APPLICATION
288    // =========================================================================
289
290    /**
291     * Apply template to create a new event
292     *
293     * Creates an Event object with default values from the template.
294     * The event is NOT saved to the database - this returns a fully configured
295     * Event object that can be further customized before saving.
296     *
297     * @param int $templateId Template ID
298     * @param array $eventData Override template defaults
299     * @return Event Fully configured event (not saved)
300     * @throws InvalidArgumentException If template not found
301     */
302    public function applyToEvent(int $templateId, array $eventData): Event
303    {
304        $template = $this->get($templateId);
305        if (!$template) {
306            throw new InvalidArgumentException('Template not found');
307        }
308
309        // Use the template's createEvent method as a base
310        $year = (int) ($eventData['year'] ?? date('Y'));
311        $event = $template->createEvent($year);
312
313        // Override with any provided event data
314        if (isset($eventData['name'])) {
315            $event->name = $eventData['name'];
316        }
317
318        if (isset($eventData['description'])) {
319            $event->description = $eventData['description'];
320        }
321
322        if (isset($eventData['startDate'])) {
323            $event->startDate = $this->parseDateTime($eventData['startDate']);
324        }
325
326        if (isset($eventData['endDate'])) {
327            $event->endDate = $this->parseDateTime($eventData['endDate']);
328        }
329
330        if (isset($eventData['buildUpDays'])) {
331            $event->buildUpDays = (int) $eventData['buildUpDays'];
332        }
333
334        if (isset($eventData['windDownDays'])) {
335            $event->windDownDays = (int) $eventData['windDownDays'];
336        }
337
338        if (isset($eventData['color'])) {
339            $event->color = $eventData['color'];
340        }
341
342        if (isset($eventData['icon'])) {
343            $event->icon = $eventData['icon'];
344        }
345
346        if (isset($eventData['isRecurring'])) {
347            $event->isRecurring = (bool) $eventData['isRecurring'];
348        }
349
350        if (isset($eventData['createdBy'])) {
351            $event->createdBy = (int) $eventData['createdBy'];
352        }
353
354        // Recalculate phase based on dates
355        $event->phase = $event->calculatePhase();
356
357        return $event;
358    }
359
360    // =========================================================================
361    // PRIVATE HELPER METHODS
362    // =========================================================================
363
364    /**
365     * Create integrations for a template
366     *
367     * @param int $templateId Template ID
368     * @param array $integrations Array of integration data
369     */
370    private function createIntegrations(int $templateId, array $integrations): void
371    {
372        $sql = "INSERT INTO eventTemplate_Integrations (
373            templateId, integrationType, config, relativeDays, sortOrder
374        ) VALUES (
375            :templateId, :integrationType, :config, :relativeDays, :sortOrder
376        )";
377
378        $stmt = $this->centralDb->prepare($sql);
379
380        $sortOrder = 0;
381        foreach ($integrations as $integration) {
382            $stmt->execute([
383                ':templateId' => $templateId,
384                ':integrationType' => $integration['integrationType'] ?? $integration['integration_type'] ?? 'note',
385                ':config' => isset($integration['config']) && is_array($integration['config'])
386                    ? json_encode($integration['config'])
387                    : ($integration['config'] ?? null),
388                ':relativeDays' => $integration['relativeDays'] ?? $integration['relative_days'] ?? null,
389                ':sortOrder' => $integration['sortOrder'] ?? $sortOrder++,
390            ]);
391        }
392    }
393
394    /**
395     * Hydrate an EventTemplate object from input data array
396     *
397     * @param array $data Input data with camelCase keys
398     * @return EventTemplate Hydrated template object
399     */
400    private function hydrateTemplateFromData(array $data): EventTemplate
401    {
402        $template = new EventTemplate();
403
404        $template->name = $data['name'] ?? '';
405        $template->description = $data['description'] ?? null;
406        $template->eventType = $data['eventType'] ?? Event::TYPE_CUSTOM;
407        $template->scope = $data['scope'] ?? EventTemplate::SCOPE_GLOBAL;
408        $template->storeType = $data['storeType'] ?? null;
409        $template->defaultBuildUpDays = (int) ($data['defaultBuildUpDays'] ?? 14);
410        $template->defaultWindDownDays = (int) ($data['defaultWindDownDays'] ?? 7);
411        $template->color = $data['color'] ?? null;
412        $template->icon = $data['icon'] ?? null;
413        $template->isActive = (bool) ($data['isActive'] ?? true);
414
415        return $template;
416    }
417
418    /**
419     * Merge existing template data with update data
420     *
421     * @param EventTemplate $existing Existing template
422     * @param array $updates Update data
423     * @return array Merged data array
424     */
425    private function mergeTemplateData(EventTemplate $existing, array $updates): array
426    {
427        $existingArray = [
428            'name' => $existing->name,
429            'description' => $existing->description,
430            'eventType' => $existing->eventType,
431            'scope' => $existing->scope,
432            'storeType' => $existing->storeType,
433            'defaultBuildUpDays' => $existing->defaultBuildUpDays,
434            'defaultWindDownDays' => $existing->defaultWindDownDays,
435            'color' => $existing->color,
436            'icon' => $existing->icon,
437            'isActive' => $existing->isActive,
438        ];
439
440        return array_merge($existingArray, $updates);
441    }
442
443    /**
444     * Parse a datetime string into a DateTime object
445     *
446     * @param string|null $value Datetime string or null
447     * @return \DateTime|null Parsed DateTime or null
448     */
449    private function parseDateTime(?string $value): ?\DateTime
450    {
451        if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') {
452            return null;
453        }
454
455        try {
456            return new \DateTime($value);
457        } catch (\Exception $e) {
458            return null;
459        }
460    }
461
462    /**
463     * Convert camelCase to snake_case
464     *
465     * @param string $input camelCase string
466     * @return string snake_case string
467     */
468    private function camelToSnake(string $input): string
469    {
470        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $input));
471    }
472
473    /**
474     * Format a value for database storage
475     *
476     * @param string $field Field name
477     * @param mixed $value Value to format
478     * @return mixed Formatted value
479     */
480    private function formatValueForDb(string $field, $value)
481    {
482        // Handle boolean fields
483        $boolFields = ['isActive'];
484        if (in_array($field, $boolFields)) {
485            return $value ? 1 : 0;
486        }
487
488        return $value;
489    }
490}