Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 377
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotesApiController
0.00% covered (danger)
0.00%
0 / 377
0.00% covered (danger)
0.00%
0 / 9
5256
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
 getNotes
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
42
 createNote
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
210
 updateNote
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
342
 deleteNote
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 addReaction
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 removeReaction
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
42
 addComment
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
90
 getComments
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Workbook\Controllers;
4
5use BuyerKiosk\Workbook\NoteManager;
6use BuyerKiosk\Workbook\Note;
7use BuyerKiosk\Workbook\NoteReaction;
8use BuyerKiosk\Workbook\NoteComment;
9use BuyerKiosk\Workbook\WorkbookAbly;
10use Exception;
11
12/**
13 * Notes System API Controller
14 *
15 * Handles REST API endpoints for the Workbook Notes System including
16 * note management, reactions, and comments.
17 *
18 * This controller uses the unified users table (kiosk_users.users) with store assignments
19 * (kiosk_users.userStoreAssignments) instead of per-store employee tables.
20 *
21 * @package BuyerKiosk\Workbook
22 */
23class NotesApiController
24{
25    /**
26     * @var \Slim\Slim Slim application instance
27     */
28    private $app;
29
30    /**
31     * @var \Store Store object
32     */
33    private $store;
34
35    /**
36     * @var \PDO Store database connection
37     */
38    private $db;
39
40    /**
41     * @var \PDO Central database connection for unified users
42     */
43    private $centralDb;
44
45    /**
46     * @var string Store identifier
47     */
48    private $typeNum;
49
50    /**
51     * Constructor
52     *
53     * @param \Slim\Slim $app Slim application instance
54     * @param \Store $store Store object
55     */
56    public function __construct($app, \Store $store)
57    {
58        $this->app = $app;
59        $this->store = $store;
60        $this->db = dbConnectByName($store->getDbName());
61        $this->centralDb = \dbConnectByName('kiosk_users');
62        $this->typeNum = $store->getTypeNum();
63    }
64
65    /**
66     * GET /api/:typeNum/workbook/notes/
67     * Get paginated notes for infinite scroll feed
68     *
69     * Query params:
70     *   - limit: int (default: 5) - number of notes to return
71     *   - offset: int (default: 0) - pagination offset
72     *   - includeManagerOnly: bool (default: false)
73     *   - employeeId: int (for reaction status)
74     */
75    public function getNotes($typeNum)
76    {
77        try {
78            $limit = $this->app->request->get('limit');
79            $limit = $limit ? (int) $limit : 5;
80            $limit = max(1, min($limit, 50)); // Clamp between 1 and 50
81
82            $offset = $this->app->request->get('offset');
83            $offset = $offset ? (int) $offset : 0;
84            $offset = max(0, $offset);
85
86            $includeManagerOnly = $this->app->request->get('includeManagerOnly') === 'true';
87            $employeeId = $this->app->request->get('employeeId');
88            $employeeId = $employeeId ? (int) $employeeId : null;
89
90            $manager = new NoteManager($this->store);
91            $result = $manager->getNotes($limit, $offset, $includeManagerOnly, $employeeId);
92
93            // Get reaction details (counts + names) for all notes in batch
94            $noteIds = array_map(function($note) {
95                return $note->id;
96            }, $result['notes']);
97            $reactionDetailsByNote = $manager->getReactionDetailsBatch($noteIds);
98
99            $noteArray = array_map(function($note) use ($reactionDetailsByNote) {
100                $arr = $note->toArray();
101                // Add computed authorName
102                $arr['authorName'] = trim(($arr['authorFirstName'] ?? '') . ' ' . ($arr['authorLastName'] ?? ''));
103                if (empty($arr['authorName'])) {
104                    $arr['authorName'] = 'Unknown';
105                }
106                // Add reaction details (counts and names for tooltips)
107                $details = $reactionDetailsByNote[$note->id] ?? [
108                    'like' => ['count' => 0, 'names' => []],
109                    'heart' => ['count' => 0, 'names' => []]
110                ];
111                $arr['reactionCounts'] = [
112                    'like' => $details['like']['count'],
113                    'heart' => $details['heart']['count']
114                ];
115                $arr['reactionNames'] = [
116                    'like' => $details['like']['names'],
117                    'heart' => $details['heart']['names']
118                ];
119                return $arr;
120            }, $result['notes']);
121
122            $this->app->response->headers->set('Content-Type', 'application/json');
123            $this->app->response->setBody(json_encode([
124                'success' => true,
125                'data' => $noteArray,
126                'hasMore' => $result['hasMore'],
127                'offset' => $offset,
128                'limit' => $limit
129            ]));
130        } catch (Exception $e) {
131            error_log("NotesApiController::getNotes error: " . $e->getMessage());
132            $this->app->response->headers->set('Content-Type', 'application/json');
133            $this->app->halt(500, json_encode([
134                'error' => 'Failed to retrieve notes'
135            ]));
136        }
137    }
138
139    /**
140     * POST /api/:typeNum/workbook/notes/
141     * Create a new note
142     *
143     * Request body: {
144     *   "authorEmployeeId": int,
145     *   "title": string,
146     *   "content": string,
147     *   "contentHtml": string,
148     *   "startDate": "Y-m-d",
149     *   "endDate": "Y-m-d",
150     *   "isManagerOnly": bool,
151     *   "isPinned": bool
152     * }
153     *
154     * Requires workbook_create_notes permission
155     *
156     * @param string $typeNum Store identifier
157     */
158    public function createNote($typeNum)
159    {
160        // Check permission
161        if (!$this->app->user->checkAccess('uri_store_settings')) {
162            $this->app->response->headers->set('Content-Type', 'application/json');
163            $this->app->halt(403, json_encode([
164                'error' => 'Access denied. Requires admin permission'
165            ]));
166            return;
167        }
168
169        try {
170            $data = json_decode($this->app->request->getBody(), true);
171
172            // Validate required fields
173            if (empty($data['authorEmployeeId']) || empty($data['content']) || empty($data['startDate'])) {
174                $this->app->response->headers->set('Content-Type', 'application/json');
175                $this->app->halt(400, json_encode([
176                    'error' => 'authorEmployeeId, content, and startDate are required'
177                ]));
178                return;
179            }
180
181            // Verify employee exists in unified users table
182            $stmt = $this->centralDb->prepare("
183                SELECT u.id
184                FROM users u
185                INNER JOIN userStoreAssignments usa ON u.id = usa.userId
186                WHERE u.id = :employeeId
187                  AND usa.typeNum = :typeNum
188                  AND usa.isActive = 1
189                  AND u.enabled = 1
190            ");
191            $stmt->execute([
192                ':employeeId' => (int)$data['authorEmployeeId'],
193                ':typeNum' => $this->typeNum
194            ]);
195            if (!$stmt->fetch()) {
196                $this->app->response->headers->set('Content-Type', 'application/json');
197                $this->app->halt(404, json_encode([
198                    'error' => 'Employee not found or inactive'
199                ]));
200                return;
201            }
202
203            // Validate date format
204            $startDate = \DateTime::createFromFormat('Y-m-d', $data['startDate']);
205            if (!$startDate || $startDate->format('Y-m-d') !== $data['startDate']) {
206                $this->app->response->headers->set('Content-Type', 'application/json');
207                $this->app->halt(400, json_encode([
208                    'error' => 'Invalid startDate format. Use Y-m-d'
209                ]));
210                return;
211            }
212
213            if (!empty($data['endDate'])) {
214                $endDate = \DateTime::createFromFormat('Y-m-d', $data['endDate']);
215                if (!$endDate || $endDate->format('Y-m-d') !== $data['endDate']) {
216                    $this->app->response->headers->set('Content-Type', 'application/json');
217                    $this->app->halt(400, json_encode([
218                        'error' => 'Invalid endDate format. Use Y-m-d'
219                    ]));
220                    return;
221                }
222            }
223
224            $manager = new NoteManager($this->store);
225            $noteId = $manager->create($data);
226
227            if ($noteId) {
228                // Publish to Ably for real-time updates
229                try {
230                    $ably = new WorkbookAbly($typeNum);
231                    $ably->noteCreated($noteId, [
232                        'authorEmployeeId' => $data['authorEmployeeId'],
233                        'title' => $data['title'] ?? null,
234                        'content' => $data['content'],
235                        'startDate' => $data['startDate'],
236                        'endDate' => $data['endDate'] ?? null,
237                        'isManagerOnly' => $data['isManagerOnly'] ?? false,
238                        'isPinned' => $data['isPinned'] ?? false
239                    ]);
240                } catch (Exception $e) {
241                    error_log("NotesApiController::createNote Ably error: " . $e->getMessage());
242                }
243
244                $this->app->response->setStatus(201);
245                $this->app->response->headers->set('Content-Type', 'application/json');
246                $this->app->response->setBody(json_encode([
247                    'success' => true,
248                    'noteId' => $noteId
249                ]));
250            } else {
251                $this->app->response->headers->set('Content-Type', 'application/json');
252                $this->app->halt(500, json_encode([
253                    'error' => 'Failed to create note'
254                ]));
255            }
256        } catch (Exception $e) {
257            error_log("NotesApiController::createNote error: " . $e->getMessage());
258            $this->app->response->headers->set('Content-Type', 'application/json');
259            $this->app->halt(500, json_encode([
260                'error' => 'Failed to create note'
261            ]));
262        }
263    }
264
265    /**
266     * PUT /api/:typeNum/workbook/notes/:noteId/
267     * Update an existing note
268     *
269     * Request body: {
270     *   "title": string,
271     *   "content": string,
272     *   "contentHtml": string,
273     *   "startDate": "Y-m-d",
274     *   "endDate": "Y-m-d",
275     *   "isManagerOnly": bool,
276     *   "isPinned": bool
277     * }
278     *
279     * Requires workbook_create_notes permission
280     *
281     * @param string $typeNum Store identifier
282     * @param int $noteId Note ID
283     */
284    public function updateNote($typeNum, $noteId)
285    {
286        // Check permission
287        if (!$this->app->user->checkAccess('uri_store_settings')) {
288            $this->app->response->headers->set('Content-Type', 'application/json');
289            $this->app->halt(403, json_encode([
290                'error' => 'Access denied. Requires admin permission'
291            ]));
292            return;
293        }
294
295        try {
296            $data = json_decode($this->app->request->getBody(), true);
297
298            // Get existing note
299            $manager = new NoteManager($this->store);
300            $note = $manager->getById($noteId);
301
302            if (!$note) {
303                $this->app->response->headers->set('Content-Type', 'application/json');
304                $this->app->halt(404, json_encode([
305                    'error' => 'Note not found'
306                ]));
307                return;
308            }
309
310            // Update fields if provided
311            if (isset($data['title'])) {
312                $note->title = $data['title'];
313            }
314            if (isset($data['content'])) {
315                $note->content = $data['content'];
316            }
317            if (isset($data['contentHtml'])) {
318                $note->contentHtml = $data['contentHtml'];
319            }
320            if (isset($data['startDate'])) {
321                $startDate = \DateTime::createFromFormat('Y-m-d', $data['startDate']);
322                if (!$startDate || $startDate->format('Y-m-d') !== $data['startDate']) {
323                    $this->app->response->headers->set('Content-Type', 'application/json');
324                    $this->app->halt(400, json_encode([
325                        'error' => 'Invalid startDate format. Use Y-m-d'
326                    ]));
327                    return;
328                }
329                $note->startDate = $data['startDate'];
330            }
331            if (isset($data['endDate'])) {
332                if ($data['endDate'] === null) {
333                    $note->endDate = null;
334                } else {
335                    $endDate = \DateTime::createFromFormat('Y-m-d', $data['endDate']);
336                    if (!$endDate || $endDate->format('Y-m-d') !== $data['endDate']) {
337                        $this->app->response->headers->set('Content-Type', 'application/json');
338                        $this->app->halt(400, json_encode([
339                            'error' => 'Invalid endDate format. Use Y-m-d'
340                        ]));
341                        return;
342                    }
343                    $note->endDate = $data['endDate'];
344                }
345            }
346            if (isset($data['isManagerOnly'])) {
347                $note->isManagerOnly = (bool) $data['isManagerOnly'];
348            }
349            if (isset($data['isPinned'])) {
350                $note->isPinned = (bool) $data['isPinned'];
351            }
352
353            if ($note->save($this->db)) {
354                // Publish to Ably for real-time updates
355                try {
356                    $ably = new WorkbookAbly($typeNum);
357                    $ably->noteUpdated($noteId, $note->toArray());
358                } catch (Exception $e) {
359                    error_log("NotesApiController::updateNote Ably error: " . $e->getMessage());
360                }
361
362                $this->app->response->headers->set('Content-Type', 'application/json');
363                $this->app->response->setBody(json_encode([
364                    'success' => true
365                ]));
366            } else {
367                $this->app->response->headers->set('Content-Type', 'application/json');
368                $this->app->halt(500, json_encode([
369                    'error' => 'Failed to update note'
370                ]));
371            }
372        } catch (Exception $e) {
373            error_log("NotesApiController::updateNote error: " . $e->getMessage());
374            $this->app->response->headers->set('Content-Type', 'application/json');
375            $this->app->halt(500, json_encode([
376                'error' => 'Failed to update note'
377            ]));
378        }
379    }
380
381    /**
382     * DELETE /api/:typeNum/workbook/notes/:noteId/
383     * Delete a note
384     *
385     * Requires workbook_create_notes permission
386     *
387     * @param string $typeNum Store identifier
388     * @param int $noteId Note ID
389     */
390    public function deleteNote($typeNum, $noteId)
391    {
392        // Check permission
393        if (!$this->app->user->checkAccess('uri_store_settings')) {
394            $this->app->response->headers->set('Content-Type', 'application/json');
395            $this->app->halt(403, json_encode([
396                'error' => 'Access denied. Requires admin permission'
397            ]));
398            return;
399        }
400
401        try {
402            // Get existing note
403            $manager = new NoteManager($this->store);
404            $note = $manager->getById($noteId);
405
406            if (!$note) {
407                $this->app->response->headers->set('Content-Type', 'application/json');
408                $this->app->halt(404, json_encode([
409                    'error' => 'Note not found'
410                ]));
411                return;
412            }
413
414            if ($note->delete($this->db)) {
415                // Publish to Ably for real-time updates
416                try {
417                    $ably = new WorkbookAbly($typeNum);
418                    $ably->noteDeleted($noteId);
419                } catch (Exception $e) {
420                    error_log("NotesApiController::deleteNote Ably error: " . $e->getMessage());
421                }
422
423                $this->app->response->headers->set('Content-Type', 'application/json');
424                $this->app->response->setBody(json_encode([
425                    'success' => true
426                ]));
427            } else {
428                $this->app->response->headers->set('Content-Type', 'application/json');
429                $this->app->halt(500, json_encode([
430                    'error' => 'Failed to delete note'
431                ]));
432            }
433        } catch (Exception $e) {
434            error_log("NotesApiController::deleteNote error: " . $e->getMessage());
435            $this->app->response->headers->set('Content-Type', 'application/json');
436            $this->app->halt(500, json_encode([
437                'error' => 'Failed to delete note'
438            ]));
439        }
440    }
441
442    /**
443     * POST /api/:typeNum/workbook/notes/:noteId/react/
444     * Add a reaction to a note
445     *
446     * Request body: {
447     *   "employeeId": int (optional - uses 0 for anonymous if not provided),
448     *   "reaction": string (default: "like")
449     * }
450     *
451     * @param string $typeNum Store identifier
452     * @param int $noteId Note ID
453     */
454    public function addReaction($typeNum, $noteId)
455    {
456        try {
457            $data = json_decode($this->app->request->getBody(), true);
458
459            // employeeId is optional - use 0 for anonymous reactions from shared workspace
460            $employeeId = !empty($data['employeeId']) ? (int) $data['employeeId'] : 0;
461
462            // Verify note exists
463            $manager = new NoteManager($this->store);
464            $note = $manager->getById($noteId);
465
466            if (!$note) {
467                $this->app->response->headers->set('Content-Type', 'application/json');
468                $this->app->halt(404, json_encode([
469                    'error' => 'Note not found'
470                ]));
471                return;
472            }
473
474            // Only verify employee if one was provided (via unified users table)
475            if ($employeeId > 0) {
476                $stmt = $this->centralDb->prepare("
477                    SELECT u.id
478                    FROM users u
479                    INNER JOIN userStoreAssignments usa ON u.id = usa.userId
480                    WHERE u.id = :employeeId
481                      AND usa.typeNum = :typeNum
482                      AND usa.isActive = 1
483                      AND u.enabled = 1
484                ");
485                $stmt->execute([
486                    ':employeeId' => $employeeId,
487                    ':typeNum' => $this->typeNum
488                ]);
489                if (!$stmt->fetch()) {
490                    $this->app->response->headers->set('Content-Type', 'application/json');
491                    $this->app->halt(404, json_encode([
492                        'error' => 'Employee not found or inactive'
493                    ]));
494                    return;
495                }
496            }
497
498            // Support both 'reaction' and 'reactionType' for flexibility
499            $reactionType = $data['reaction'] ?? $data['reactionType'] ?? 'like';
500
501            if (NoteReaction::add($this->db, $noteId, $employeeId, $reactionType)) {
502                // Publish to Ably for real-time updates
503                try {
504                    $ably = new WorkbookAbly($typeNum);
505                    $ably->noteReaction($noteId, $employeeId, $reactionType, true);
506                } catch (Exception $e) {
507                    error_log("NotesApiController::addReaction Ably error: " . $e->getMessage());
508                }
509
510                $this->app->response->headers->set('Content-Type', 'application/json');
511                $this->app->response->setBody(json_encode([
512                    'success' => true,
513                    'reactionCount' => NoteReaction::getCount($this->db, $noteId)
514                ]));
515            } else {
516                $this->app->response->headers->set('Content-Type', 'application/json');
517                $this->app->halt(500, json_encode([
518                    'error' => 'Failed to add reaction'
519                ]));
520            }
521        } catch (Exception $e) {
522            error_log("NotesApiController::addReaction error: " . $e->getMessage());
523            $this->app->response->headers->set('Content-Type', 'application/json');
524            $this->app->halt(500, json_encode([
525                'error' => 'Failed to add reaction'
526            ]));
527        }
528    }
529
530    /**
531     * DELETE /api/:typeNum/workbook/notes/:noteId/react/
532     * Remove a reaction from a note
533     *
534     * Request body: {
535     *   "employeeId": int (optional - uses 0 for anonymous if not provided)
536     * }
537     *
538     * @param string $typeNum Store identifier
539     * @param int $noteId Note ID
540     */
541    public function removeReaction($typeNum, $noteId)
542    {
543        try {
544            $data = json_decode($this->app->request->getBody(), true);
545
546            // employeeId is optional - use 0 for anonymous reactions
547            $employeeId = !empty($data['employeeId']) ? (int) $data['employeeId'] : 0;
548
549            // Verify note exists
550            $manager = new NoteManager($this->store);
551            $note = $manager->getById($noteId);
552
553            if (!$note) {
554                $this->app->response->headers->set('Content-Type', 'application/json');
555                $this->app->halt(404, json_encode([
556                    'error' => 'Note not found'
557                ]));
558                return;
559            }
560
561            if (NoteReaction::remove($this->db, $noteId, $employeeId)) {
562                // Publish to Ably for real-time updates
563                try {
564                    $ably = new WorkbookAbly($typeNum);
565                    $ably->noteReaction($noteId, $employeeId, 'like', false);
566                } catch (Exception $e) {
567                    error_log("NotesApiController::removeReaction Ably error: " . $e->getMessage());
568                }
569
570                $this->app->response->headers->set('Content-Type', 'application/json');
571                $this->app->response->setBody(json_encode([
572                    'success' => true,
573                    'reactionCount' => NoteReaction::getCount($this->db, $noteId)
574                ]));
575            } else {
576                $this->app->response->headers->set('Content-Type', 'application/json');
577                $this->app->halt(500, json_encode([
578                    'error' => 'Failed to remove reaction'
579                ]));
580            }
581        } catch (Exception $e) {
582            error_log("NotesApiController::removeReaction error: " . $e->getMessage());
583            $this->app->response->headers->set('Content-Type', 'application/json');
584            $this->app->halt(500, json_encode([
585                'error' => 'Failed to remove reaction'
586            ]));
587        }
588    }
589
590    /**
591     * POST /api/:typeNum/workbook/notes/:noteId/comment/
592     * Add a comment to a note
593     *
594     * Request body: {
595     *   "employeeId": int (optional - uses 0 for anonymous if not provided),
596     *   "comment": string
597     * }
598     *
599     * @param string $typeNum Store identifier
600     * @param int $noteId Note ID
601     */
602    public function addComment($typeNum, $noteId)
603    {
604        try {
605            $data = json_decode($this->app->request->getBody(), true);
606
607            if (empty($data['comment'])) {
608                $this->app->response->headers->set('Content-Type', 'application/json');
609                $this->app->halt(400, json_encode([
610                    'error' => 'comment is required'
611                ]));
612                return;
613            }
614
615            if (strlen($data['comment']) > 2000) {
616                $this->app->response->headers->set('Content-Type', 'application/json');
617                $this->app->halt(400, json_encode([
618                    'error' => 'Comment exceeds maximum length of 2000 characters'
619                ]));
620                return;
621            }
622
623            // employeeId is optional - use 0 for anonymous comments
624            $employeeId = !empty($data['employeeId']) ? (int) $data['employeeId'] : 0;
625
626            // Verify note exists
627            $manager = new NoteManager($this->store);
628            $note = $manager->getById($noteId);
629
630            if (!$note) {
631                $this->app->response->headers->set('Content-Type', 'application/json');
632                $this->app->halt(404, json_encode([
633                    'error' => 'Note not found'
634                ]));
635                return;
636            }
637
638            // Only verify employee if one was provided (via unified users table)
639            if ($employeeId > 0) {
640                $stmt = $this->centralDb->prepare("
641                    SELECT u.id
642                    FROM users u
643                    INNER JOIN userStoreAssignments usa ON u.id = usa.userId
644                    WHERE u.id = :employeeId
645                      AND usa.typeNum = :typeNum
646                      AND usa.isActive = 1
647                      AND u.enabled = 1
648                ");
649                $stmt->execute([
650                    ':employeeId' => $employeeId,
651                    ':typeNum' => $this->typeNum
652                ]);
653                if (!$stmt->fetch()) {
654                    $this->app->response->headers->set('Content-Type', 'application/json');
655                    $this->app->halt(404, json_encode([
656                        'error' => 'Employee not found or inactive'
657                    ]));
658                    return;
659                }
660            }
661
662            $comment = new NoteComment();
663            $comment->noteId = $noteId;
664            $comment->employeeId = $employeeId;
665            $comment->comment = trim($data['comment']);
666
667            $commentId = $comment->save($this->db);
668
669            // Publish to Ably for real-time updates
670            try {
671                $ably = new WorkbookAbly($typeNum);
672                $ably->noteComment($noteId, $commentId, $employeeId, $comment->comment);
673            } catch (Exception $e) {
674                error_log("NotesApiController::addComment Ably error: " . $e->getMessage());
675            }
676
677            $this->app->response->setStatus(201);
678            $this->app->response->headers->set('Content-Type', 'application/json');
679            $this->app->response->setBody(json_encode([
680                'success' => true,
681                'commentId' => $commentId
682            ]));
683        } catch (Exception $e) {
684            error_log("NotesApiController::addComment error: " . $e->getMessage());
685            $this->app->response->headers->set('Content-Type', 'application/json');
686            $this->app->halt(500, json_encode([
687                'error' => 'Failed to add comment'
688            ]));
689        }
690    }
691
692    /**
693     * GET /api/:typeNum/workbook/notes/:noteId/comments/
694     * Get all comments for a note
695     *
696     * @param string $typeNum Store identifier
697     * @param int $noteId Note ID
698     */
699    public function getComments($typeNum, $noteId)
700    {
701        try {
702            // Verify note exists
703            $manager = new NoteManager($this->store);
704            $note = $manager->getById($noteId);
705
706            if (!$note) {
707                $this->app->response->headers->set('Content-Type', 'application/json');
708                $this->app->halt(404, json_encode([
709                    'error' => 'Note not found'
710                ]));
711                return;
712            }
713
714            $comments = NoteComment::getForNote($this->db, $noteId);
715
716            // Add authorName computed field to each comment
717            $comments = array_map(function($comment) {
718                $comment['authorName'] = trim(($comment['employeeFirstName'] ?? '') . ' ' . ($comment['employeeLastName'] ?? ''));
719                if (empty($comment['authorName'])) {
720                    $comment['authorName'] = 'Anonymous';
721                }
722                return $comment;
723            }, $comments);
724
725            $this->app->response->headers->set('Content-Type', 'application/json');
726            $this->app->response->setBody(json_encode([
727                'success' => true,
728                'data' => $comments
729            ]));
730        } catch (Exception $e) {
731            error_log("NotesApiController::getComments error: " . $e->getMessage());
732            $this->app->response->headers->set('Content-Type', 'application/json');
733            $this->app->halt(500, json_encode([
734                'error' => 'Failed to retrieve comments'
735            ]));
736        }
737    }
738}