Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 285
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatAdminController
0.00% covered (danger)
0.00%
0 / 285
0.00% covered (danger)
0.00%
0 / 11
2550
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 listTemplates
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 createTemplate
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
90
 updateTemplate
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
240
 deleteTemplate
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 getCustomerHistory
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
42
 getUsageReport
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
56
 findTemplateById
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 findTemplateByIdAndStore
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 jsonResponse
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 jsonError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Chat\Controllers;
4
5use BuyerKiosk\Chat\Models\ChatTemplate;
6use BuyerKiosk\Chat\Models\ChatThread;
7use BuyerKiosk\Chat\Models\ChatMessage;
8use BuyerKiosk\Chat\Services\ChatTemplateService;
9use BuyerKiosk\Chat\Services\ChatBillingService;
10use DateTime;
11use Exception;
12use PDO;
13
14/**
15 * ChatAdminController - Admin Chat Management Endpoints
16 *
17 * Handles administrative functions for Two-Way SMS Chat including:
18 * - Template management (CRUD operations)
19 * - Customer conversation history
20 * - Usage reporting for billing and analytics
21 *
22 * Endpoints:
23 * - GET    /admin/:typeNum/chat/templates              - List all templates
24 * - POST   /admin/:typeNum/chat/templates              - Create template
25 * - PUT    /admin/:typeNum/chat/templates/:templateId  - Update template
26 * - DELETE /admin/:typeNum/chat/templates/:templateId  - Delete template
27 * - GET    /admin/:typeNum/chat/customers/:customerId/history - Get customer history
28 * - GET    /admin/:typeNum/chat/usage                  - Get usage report
29 *
30 * Per SDD lines 612-690
31 *
32 * @package BuyerKiosk\Chat\Controllers
33 */
34class ChatAdminController
35{
36    /**
37     * @var \Slim\Slim Slim application instance
38     */
39    private $app;
40
41    /**
42     * @var \Store Store object
43     */
44    private \Store $store;
45
46    /**
47     * @var PDO Store database connection
48     */
49    private PDO $db;
50
51    /**
52     * @var PDO Central database connection
53     */
54    private PDO $centralDb;
55
56    /**
57     * Constructor
58     *
59     * @param \Slim\Slim $app Slim application instance
60     * @param \Store $store Store object
61     */
62    public function __construct($app, \Store $store)
63    {
64        global $db_name;
65
66        $this->app = $app;
67        $this->store = $store;
68        $this->db = dbConnectByName($store->getDbName());
69        $this->centralDb = dbConnectByName($db_name);
70    }
71
72    /**
73     * GET /admin/:typeNum/chat/templates
74     *
75     * List ALL templates for the store (including inactive).
76     * Returns templates with isSystem flag for each.
77     *
78     * Response: { success: true, templates: [...] }
79     *
80     * @param string $typeNum Store identifier
81     */
82    public function listTemplates(string $typeNum): void
83    {
84        try {
85            // Query ALL templates (including inactive) for admin view
86            $sql = "SELECT * FROM chat_templates
87                    WHERE typeNum = :typeNum OR typeNum = 'global'
88                    ORDER BY sort_order ASC, short_name ASC";
89
90            $stmt = $this->db->prepare($sql);
91            $stmt->execute([':typeNum' => $typeNum]);
92            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
93
94            $templates = array_map(function ($row) {
95                $template = ChatTemplate::fromRow($row);
96                return $template->toArray();
97            }, $rows);
98
99            $this->jsonResponse([
100                'success' => true,
101                'templates' => $templates,
102            ]);
103        } catch (Exception $e) {
104            error_log("ChatAdminController::listTemplates error: " . $e->getMessage());
105            $this->jsonError(500, 'Failed to retrieve templates');
106        }
107    }
108
109    /**
110     * POST /admin/:typeNum/chat/templates
111     *
112     * Create a new chat template.
113     *
114     * Request body: {
115     *   "shortName": string (required),
116     *   "content": string (required),
117     *   "category": string (required - transactional|initial_contact|follow_up),
118     *   "sortOrder": int (optional, default 0)
119     * }
120     *
121     * Auto-calculates segmentCount using ChatTemplateService.
122     * Sets isSystem: false, isActive: true.
123     *
124     * Response: { success: true, template: {...} }
125     *
126     * @param string $typeNum Store identifier
127     */
128    public function createTemplate(string $typeNum): void
129    {
130        try {
131            $data = json_decode($this->app->request->getBody(), true);
132
133            // Validate required fields
134            if (empty($data['shortName'])) {
135                $this->jsonError(400, 'shortName is required');
136                return;
137            }
138            if (empty($data['content'])) {
139                $this->jsonError(400, 'content is required');
140                return;
141            }
142            if (empty($data['category'])) {
143                $this->jsonError(400, 'category is required');
144                return;
145            }
146
147            $shortName = trim($data['shortName']);
148            $content = trim($data['content']);
149            $category = trim($data['category']);
150            $sortOrder = isset($data['sortOrder']) ? (int) $data['sortOrder'] : 0;
151
152            // Validate category
153            $validCategories = ChatTemplate::getValidCategories();
154            if (!in_array($category, $validCategories, true)) {
155                $this->jsonError(400, 'Invalid category. Must be one of: ' . implode(', ', $validCategories));
156                return;
157            }
158
159            // Calculate character count and segment count
160            $templateService = new ChatTemplateService();
161            $validation = $templateService->validateTemplate($content);
162
163            if (!$validation['valid']) {
164                $this->jsonError(400, implode('; ', $validation['errors']));
165                return;
166            }
167
168            $characterCount = $validation['charCount'];
169            $segmentCount = $validation['segments'];
170
171            // Employee ID tracking is optional - leave as null for now
172            // TODO: Implement user-to-employee lookup when user_employee_links table exists
173            $createdByEmployeeId = null;
174
175            // Insert template
176            $now = new DateTime();
177            $insertSql = "INSERT INTO chat_templates (
178                            typeNum, short_name, content, category,
179                            character_count, sms_segment_count, is_active, is_system,
180                            sort_order, created_by_employee_id, created_at, updated_at
181                          ) VALUES (
182                            :typeNum, :short_name, :content, :category,
183                            :character_count, :sms_segment_count, :is_active, :is_system,
184                            :sort_order, :created_by_employee_id, :created_at, :updated_at
185                          )";
186
187            $insertStmt = $this->db->prepare($insertSql);
188            $insertStmt->execute([
189                ':typeNum' => $typeNum,
190                ':short_name' => $shortName,
191                ':content' => $content,
192                ':category' => $category,
193                ':character_count' => $characterCount,
194                ':sms_segment_count' => $segmentCount,
195                ':is_active' => 1,
196                ':is_system' => 0,
197                ':sort_order' => $sortOrder,
198                ':created_by_employee_id' => $createdByEmployeeId,
199                ':created_at' => $now->format('Y-m-d H:i:s'),
200                ':updated_at' => $now->format('Y-m-d H:i:s'),
201            ]);
202
203            $templateId = (int) $this->db->lastInsertId();
204
205            // Retrieve the created template
206            $template = $this->findTemplateById($templateId);
207
208            $this->app->response->setStatus(201);
209            $this->jsonResponse([
210                'success' => true,
211                'template' => $template ? $template->toArray() : ['id' => $templateId],
212            ]);
213        } catch (Exception $e) {
214            error_log("ChatAdminController::createTemplate error: " . $e->getMessage());
215            $this->jsonError(500, 'Failed to create template');
216        }
217    }
218
219    /**
220     * PUT /admin/:typeNum/chat/templates/:templateId
221     *
222     * Update an existing template.
223     *
224     * Request body: {
225     *   "shortName": string (optional),
226     *   "content": string (optional),
227     *   "isActive": bool (optional)
228     * }
229     *
230     * Recalculates segmentCount if content changes.
231     * Cannot change isSystem flag.
232     *
233     * Response: { success: true, template: {...} }
234     *
235     * @param string $typeNum Store identifier
236     * @param int $templateId Template ID to update
237     */
238    public function updateTemplate(string $typeNum, int $templateId): void
239    {
240        try {
241            $data = json_decode($this->app->request->getBody(), true);
242
243            // Find existing template
244            $template = $this->findTemplateByIdAndStore($templateId, $typeNum);
245            if (!$template) {
246                $this->jsonError(404, 'Template not found');
247                return;
248            }
249
250            // Check if system template content is being modified
251            if ($template->isSystem()) {
252                // System templates can only toggle isActive, not change content
253                if (isset($data['content']) || isset($data['shortName'])) {
254                    $this->jsonError(403, 'System templates cannot be modified. Only activation status can be changed.');
255                    return;
256                }
257            }
258
259            // Build update fields
260            $updates = [];
261            $params = [':templateId' => $templateId];
262
263            // Update shortName if provided
264            if (isset($data['shortName'])) {
265                $shortName = trim($data['shortName']);
266                if (empty($shortName)) {
267                    $this->jsonError(400, 'shortName cannot be empty');
268                    return;
269                }
270                $updates[] = 'short_name = :short_name';
271                $params[':short_name'] = $shortName;
272            }
273
274            // Update content if provided
275            if (isset($data['content'])) {
276                $content = trim($data['content']);
277                if (empty($content)) {
278                    $this->jsonError(400, 'content cannot be empty');
279                    return;
280                }
281
282                // Validate and recalculate segment count
283                $templateService = new ChatTemplateService();
284                $validation = $templateService->validateTemplate($content);
285
286                if (!$validation['valid']) {
287                    $this->jsonError(400, implode('; ', $validation['errors']));
288                    return;
289                }
290
291                $updates[] = 'content = :content';
292                $updates[] = 'character_count = :character_count';
293                $updates[] = 'sms_segment_count = :sms_segment_count';
294                $params[':content'] = $content;
295                $params[':character_count'] = $validation['charCount'];
296                $params[':sms_segment_count'] = $validation['segments'];
297            }
298
299            // Update isActive if provided
300            if (isset($data['isActive'])) {
301                $updates[] = 'is_active = :is_active';
302                $params[':is_active'] = $data['isActive'] ? 1 : 0;
303            }
304
305            // If no updates, return current template
306            if (empty($updates)) {
307                $this->jsonResponse([
308                    'success' => true,
309                    'template' => $template->toArray(),
310                ]);
311                return;
312            }
313
314            // Add updated_at
315            $now = new DateTime();
316            $updates[] = 'updated_at = :updated_at';
317            $params[':updated_at'] = $now->format('Y-m-d H:i:s');
318
319            // Execute update
320            $updateSql = "UPDATE chat_templates SET " . implode(', ', $updates) . " WHERE id = :templateId";
321            $updateStmt = $this->db->prepare($updateSql);
322            $updateStmt->execute($params);
323
324            // Retrieve updated template
325            $updatedTemplate = $this->findTemplateById($templateId);
326
327            $this->jsonResponse([
328                'success' => true,
329                'template' => $updatedTemplate ? $updatedTemplate->toArray() : ['id' => $templateId],
330            ]);
331        } catch (Exception $e) {
332            error_log("ChatAdminController::updateTemplate error: " . $e->getMessage());
333            $this->jsonError(500, 'Failed to update template');
334        }
335    }
336
337    /**
338     * DELETE /admin/:typeNum/chat/templates/:templateId
339     *
340     * Delete a non-system template.
341     *
342     * BLOCKS deletion if isSystem = true.
343     *
344     * Response: { success: true } or { success: false, error: "..." }
345     *
346     * @param string $typeNum Store identifier
347     * @param int $templateId Template ID to delete
348     */
349    public function deleteTemplate(string $typeNum, int $templateId): void
350    {
351        try {
352            // Find existing template
353            $template = $this->findTemplateByIdAndStore($templateId, $typeNum);
354            if (!$template) {
355                $this->jsonError(404, 'Template not found');
356                return;
357            }
358
359            // Block deletion of system templates
360            if ($template->isSystem()) {
361                $this->jsonResponse([
362                    'success' => false,
363                    'error' => 'System templates cannot be deleted',
364                ]);
365                return;
366            }
367
368            // Delete template
369            $deleteSql = "DELETE FROM chat_templates WHERE id = :templateId";
370            $deleteStmt = $this->db->prepare($deleteSql);
371            $deleteStmt->execute([':templateId' => $templateId]);
372
373            $this->jsonResponse([
374                'success' => true,
375            ]);
376        } catch (Exception $e) {
377            error_log("ChatAdminController::deleteTemplate error: " . $e->getMessage());
378            $this->jsonError(500, 'Failed to delete template');
379        }
380    }
381
382    /**
383     * GET /admin/:typeNum/chat/customers/:customerId/history
384     *
385     * Get all chat threads and messages for a customer.
386     *
387     * Response: {
388     *   success: true,
389     *   threads: [{
390     *     ...thread data,
391     *     messages: [{...message with delivery status}]
392     *   }]
393     * }
394     *
395     * @param string $typeNum Store identifier
396     * @param int $customerId Customer ID
397     */
398    public function getCustomerHistory(string $typeNum, int $customerId): void
399    {
400        try {
401            // Validate customer exists
402            $customerStmt = $this->db->prepare(
403                "SELECT customerID, firstName, lastName, phone FROM customers WHERE customerID = :customerId"
404            );
405            $customerStmt->execute([':customerId' => $customerId]);
406            $customer = $customerStmt->fetch(PDO::FETCH_ASSOC);
407
408            if (!$customer) {
409                $this->jsonError(404, 'Customer not found');
410                return;
411            }
412
413            // Get all threads for customer
414            $threadsSql = "SELECT * FROM chat_threads
415                          WHERE typeNum = :typeNum AND customer_id = :customerId
416                          ORDER BY created_at DESC";
417            $threadsStmt = $this->db->prepare($threadsSql);
418            $threadsStmt->execute([
419                ':typeNum' => $typeNum,
420                ':customerId' => $customerId,
421            ]);
422            $threadRows = $threadsStmt->fetchAll(PDO::FETCH_ASSOC);
423
424            $threads = [];
425            foreach ($threadRows as $threadRow) {
426                $thread = ChatThread::fromRow($threadRow);
427                $threadData = $thread->toArray();
428
429                // Get messages for this thread
430                $messagesSql = "SELECT
431                                    m.*,
432                                    e.firstName as sender_first_name,
433                                    e.lastName as sender_last_name
434                                FROM chat_messages m
435                                LEFT JOIN employees e ON m.sender_id = e.employeeID AND m.sender_type = 'staff'
436                                WHERE m.thread_id = :threadId
437                                ORDER BY m.created_at ASC";
438                $messagesStmt = $this->db->prepare($messagesSql);
439                $messagesStmt->execute([':threadId' => $thread->getId()]);
440                $messageRows = $messagesStmt->fetchAll(PDO::FETCH_ASSOC);
441
442                $messages = array_map(function ($row) {
443                    $message = ChatMessage::fromRow($row);
444                    $arr = $message->toArray();
445
446                    // Add sender name for staff messages
447                    if ($message->isFromStaff() && !empty($row['sender_first_name'])) {
448                        $arr['senderName'] = trim($row['sender_first_name'] . ' ' . ($row['sender_last_name'] ?? ''));
449                    }
450
451                    return $arr;
452                }, $messageRows);
453
454                $threadData['messages'] = $messages;
455                $threads[] = $threadData;
456            }
457
458            $this->jsonResponse([
459                'success' => true,
460                'customer' => [
461                    'id' => (int) $customer['customerID'],
462                    'firstName' => $customer['firstName'],
463                    'lastName' => $customer['lastName'],
464                    'phone' => $customer['phone'],
465                    'fullName' => trim($customer['firstName'] . ' ' . $customer['lastName']),
466                ],
467                'threads' => $threads,
468            ]);
469        } catch (Exception $e) {
470            error_log("ChatAdminController::getCustomerHistory error: " . $e->getMessage());
471            $this->jsonError(500, 'Failed to retrieve customer history');
472        }
473    }
474
475    /**
476     * GET /admin/:typeNum/chat/usage
477     *
478     * Get SMS usage report for billing and analytics.
479     *
480     * Query params:
481     *   - startDate: string (YYYY-MM-DD, required)
482     *   - endDate: string (YYYY-MM-DD, required)
483     *
484     * Response: {
485     *   success: true,
486     *   summary: {
487     *     totalSent: int,
488     *     totalReceived: int,
489     *     transactionalCount: int,
490     *     interactiveCount: int,
491     *     deliveryRate: float
492     *   },
493     *   byDay: [{
494     *     date: string,
495     *     sent: int,
496     *     received: int,
497     *     transactional: int,
498     *     interactive: int
499     *   }]
500     * }
501     *
502     * @param string $typeNum Store identifier
503     */
504    public function getUsageReport(string $typeNum): void
505    {
506        try {
507            // Get date range from query params
508            $startDate = $this->app->request->get('startDate');
509            $endDate = $this->app->request->get('endDate');
510
511            if (empty($startDate) || empty($endDate)) {
512                $this->jsonError(400, 'startDate and endDate query parameters are required');
513                return;
514            }
515
516            // Validate date format
517            $startDateTime = DateTime::createFromFormat('Y-m-d', $startDate);
518            $endDateTime = DateTime::createFromFormat('Y-m-d', $endDate);
519
520            if (!$startDateTime || !$endDateTime) {
521                $this->jsonError(400, 'Invalid date format. Use YYYY-MM-DD');
522                return;
523            }
524
525            // Query usage summary from central DB
526            $summarySql = "SELECT
527                            SUM(CASE WHEN direction = 'outbound' THEN 1 ELSE 0 END) as total_sent,
528                            SUM(CASE WHEN direction = 'inbound' THEN 1 ELSE 0 END) as total_received,
529                            SUM(CASE WHEN category = 'transactional' THEN 1 ELSE 0 END) as transactional_count,
530                            SUM(CASE WHEN category = 'interactive' THEN 1 ELSE 0 END) as interactive_count,
531                            SUM(segment_count) as total_segments,
532                            COUNT(*) as total_messages
533                          FROM chat_sms_usage
534                          WHERE typeNum = :typeNum
535                            AND DATE(sent_at) >= :startDate
536                            AND DATE(sent_at) <= :endDate";
537
538            $summaryStmt = $this->centralDb->prepare($summarySql);
539            $summaryStmt->execute([
540                ':typeNum' => $typeNum,
541                ':startDate' => $startDate,
542                ':endDate' => $endDate,
543            ]);
544            $summaryRow = $summaryStmt->fetch(PDO::FETCH_ASSOC);
545
546            // Calculate delivery rate from store DB messages
547            $deliveryRateSql = "SELECT
548                                  COUNT(*) as total_outbound,
549                                  SUM(CASE WHEN delivery_status = 'delivered' THEN 1 ELSE 0 END) as delivered_count
550                                FROM chat_messages
551                                WHERE typeNum = :typeNum
552                                  AND direction = 'outbound'
553                                  AND DATE(created_at) >= :startDate
554                                  AND DATE(created_at) <= :endDate";
555
556            $deliveryStmt = $this->db->prepare($deliveryRateSql);
557            $deliveryStmt->execute([
558                ':typeNum' => $typeNum,
559                ':startDate' => $startDate,
560                ':endDate' => $endDate,
561            ]);
562            $deliveryRow = $deliveryStmt->fetch(PDO::FETCH_ASSOC);
563
564            $totalOutbound = (int) ($deliveryRow['total_outbound'] ?? 0);
565            $deliveredCount = (int) ($deliveryRow['delivered_count'] ?? 0);
566            $deliveryRate = $totalOutbound > 0 ? round(($deliveredCount / $totalOutbound) * 100, 2) : 0.0;
567
568            // Query by-day breakdown from central DB
569            $byDaySql = "SELECT
570                           DATE(sent_at) as date,
571                           SUM(CASE WHEN direction = 'outbound' THEN 1 ELSE 0 END) as sent,
572                           SUM(CASE WHEN direction = 'inbound' THEN 1 ELSE 0 END) as received,
573                           SUM(CASE WHEN category = 'transactional' THEN 1 ELSE 0 END) as transactional,
574                           SUM(CASE WHEN category = 'interactive' THEN 1 ELSE 0 END) as interactive
575                         FROM chat_sms_usage
576                         WHERE typeNum = :typeNum
577                           AND DATE(sent_at) >= :startDate
578                           AND DATE(sent_at) <= :endDate
579                         GROUP BY DATE(sent_at)
580                         ORDER BY DATE(sent_at) ASC";
581
582            $byDayStmt = $this->centralDb->prepare($byDaySql);
583            $byDayStmt->execute([
584                ':typeNum' => $typeNum,
585                ':startDate' => $startDate,
586                ':endDate' => $endDate,
587            ]);
588            $byDayRows = $byDayStmt->fetchAll(PDO::FETCH_ASSOC);
589
590            $byDay = array_map(function ($row) {
591                return [
592                    'date' => $row['date'],
593                    'sent' => (int) $row['sent'],
594                    'received' => (int) $row['received'],
595                    'transactional' => (int) $row['transactional'],
596                    'interactive' => (int) $row['interactive'],
597                ];
598            }, $byDayRows);
599
600            $this->jsonResponse([
601                'success' => true,
602                'summary' => [
603                    'totalSent' => (int) ($summaryRow['total_sent'] ?? 0),
604                    'totalReceived' => (int) ($summaryRow['total_received'] ?? 0),
605                    'transactionalCount' => (int) ($summaryRow['transactional_count'] ?? 0),
606                    'interactiveCount' => (int) ($summaryRow['interactive_count'] ?? 0),
607                    'totalSegments' => (int) ($summaryRow['total_segments'] ?? 0),
608                    'deliveryRate' => $deliveryRate,
609                ],
610                'byDay' => $byDay,
611            ]);
612        } catch (Exception $e) {
613            error_log("ChatAdminController::getUsageReport error: " . $e->getMessage());
614            $this->jsonError(500, 'Failed to retrieve usage report');
615        }
616    }
617
618    // =========================================================================
619    // Helper Methods
620    // =========================================================================
621
622    /**
623     * Find a template by ID only (for retrieving after insert)
624     *
625     * @param int $templateId Template ID
626     * @return ChatTemplate|null
627     */
628    private function findTemplateById(int $templateId): ?ChatTemplate
629    {
630        $stmt = $this->db->prepare("SELECT * FROM chat_templates WHERE id = :id");
631        $stmt->execute([':id' => $templateId]);
632        $row = $stmt->fetch(PDO::FETCH_ASSOC);
633
634        if (!$row) {
635            return null;
636        }
637
638        return ChatTemplate::fromRow($row);
639    }
640
641    /**
642     * Find a template by ID and verify it belongs to the store (or is global)
643     *
644     * @param int $templateId Template ID
645     * @param string $typeNum Store identifier
646     * @return ChatTemplate|null
647     */
648    private function findTemplateByIdAndStore(int $templateId, string $typeNum): ?ChatTemplate
649    {
650        $stmt = $this->db->prepare(
651            "SELECT * FROM chat_templates
652             WHERE id = :id AND (typeNum = :typeNum OR typeNum = 'global')"
653        );
654        $stmt->execute([':id' => $templateId, ':typeNum' => $typeNum]);
655        $row = $stmt->fetch(PDO::FETCH_ASSOC);
656
657        if (!$row) {
658            return null;
659        }
660
661        return ChatTemplate::fromRow($row);
662    }
663
664    /**
665     * Send a JSON success response
666     *
667     * @param array $data Response data
668     */
669    private function jsonResponse(array $data): void
670    {
671        $this->app->response->headers->set('Content-Type', 'application/json');
672        $this->app->response->setBody(json_encode($data));
673    }
674
675    /**
676     * Send a JSON error response and halt
677     *
678     * @param int $statusCode HTTP status code
679     * @param string $message Error message
680     */
681    private function jsonError(int $statusCode, string $message): void
682    {
683        $this->app->response->headers->set('Content-Type', 'application/json');
684        $this->app->halt($statusCode, json_encode([
685            'success' => false,
686            'error' => $message,
687        ]));
688    }
689}