Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.33% covered (success)
97.33%
73 / 75
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatController
97.33% covered (success)
97.33%
73 / 75
66.67% covered (warning)
66.67%
4 / 6
17
0.00% covered (danger)
0.00%
0 / 1
 getConversations
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getMessages
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 send
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 markAsRead
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 getUnreadCount
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 searchUsers
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Chat API Controller for private user-to-user messaging.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-19
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use Exception;
23use JsonException;
24use phpMyFAQ\Chat;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Session\Token;
28use phpMyFAQ\Translation;
29use Symfony\Component\HttpFoundation\JsonResponse;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\HttpFoundation\Response;
32use Symfony\Component\Routing\Attribute\Route;
33
34final class ChatController extends AbstractController
35{
36    /**
37     * Gets the list of all conversations for the current user.
38     *
39     * @throws Exception
40     */
41    #[Route(path: 'chat/conversations', name: 'api.private.chat.conversations', methods: ['GET'])]
42    public function getConversations(Request $request): JsonResponse
43    {
44        $this->userIsAuthenticated();
45
46        $chat = new Chat($this->configuration);
47        $conversations = $chat->getConversationList($this->currentUser->getUserId());
48
49        return $this->json([
50            'success' => true,
51            'conversations' => $conversations,
52        ], Response::HTTP_OK);
53    }
54
55    /**
56     * Gets messages with a specific user.
57     *
58     * @throws Exception
59     */
60    #[Route(path: 'chat/messages/{userId}', name: 'api.private.chat.messages', methods: ['GET'])]
61    public function getMessages(Request $request): JsonResponse
62    {
63        $this->userIsAuthenticated();
64
65        $partnerId = Filter::filterVar($request->attributes->get('userId'), FILTER_VALIDATE_INT);
66        $limit = Filter::filterVar($request->query->get('limit', 50), FILTER_VALIDATE_INT);
67        if (!is_int($limit)) {
68            $limit = 50;
69        }
70
71        $offset = Filter::filterVar($request->query->get('offset', 0), FILTER_VALIDATE_INT);
72        if (!is_int($offset)) {
73            $offset = 0;
74        }
75
76        if (!$partnerId) {
77            return $this->json(['error' => 'Invalid user ID'], Response::HTTP_BAD_REQUEST);
78        }
79
80        $chat = new Chat($this->configuration);
81
82        // Mark messages as read when viewing conversation
83        $chat->markConversationAsRead($this->currentUser->getUserId(), $partnerId);
84
85        $messages = $chat->getConversation($this->currentUser->getUserId(), $partnerId, $limit, $offset);
86
87        return $this->json([
88            'success' => true,
89            'messages' => $chat->messagesToArray($messages),
90        ], Response::HTTP_OK);
91    }
92
93    /**
94     * Sends a new message.
95     *
96     * @throws JsonException
97     * @throws Exception
98     */
99    #[Route(path: 'chat/send', name: 'api.private.chat.send', methods: ['POST'])]
100    public function send(Request $request): JsonResponse
101    {
102        $this->userIsAuthenticated();
103
104        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
105        $recipientId = Filter::filterVar($data->recipientId ?? 0, FILTER_VALIDATE_INT);
106        $message = trim((string) Filter::filterVar($data->message ?? '', FILTER_SANITIZE_SPECIAL_CHARS));
107        $csrfToken = trim((string) ($data->csrfToken ?? ''));
108
109        if (!$this->verifySessionCsrfToken('send-chat-message', $csrfToken)) {
110            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_UNAUTHORIZED);
111        }
112
113        if (!$recipientId || trim($message) === '') {
114            return $this->json(['error' => 'Invalid recipient or empty message'], Response::HTTP_BAD_REQUEST);
115        }
116
117        $chat = new Chat($this->configuration);
118        $chatMessage = $chat->sendMessage($this->currentUser->getUserId(), $recipientId, $message);
119
120        if ($chatMessage === null) {
121            return $this->json(['error' => Translation::get(key: 'msgError')], Response::HTTP_BAD_REQUEST);
122        }
123
124        return $this->json([
125            'success' => true,
126            'message' => $chat->messageToArray($chatMessage),
127            'csrfToken' => Token::getInstance($this->session)->getTokenString('send-chat-message'),
128        ], Response::HTTP_CREATED);
129    }
130
131    /**
132     * Marks a message as read.
133     *
134     * @throws JsonException
135     * @throws Exception
136     */
137    #[Route(path: 'chat/read/{messageId}', name: 'api.private.chat.read', methods: ['POST'])]
138    public function markAsRead(Request $request): JsonResponse
139    {
140        $this->userIsAuthenticated();
141
142        $messageId = Filter::filterVar($request->attributes->get('messageId'), FILTER_VALIDATE_INT);
143
144        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
145        $csrfToken = trim((string) ($data->csrfToken ?? ''));
146
147        if (!$this->verifySessionCsrfToken('mark-chat-read', $csrfToken)) {
148            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_UNAUTHORIZED);
149        }
150
151        if (!$messageId) {
152            return $this->json(['error' => 'Invalid message ID'], Response::HTTP_BAD_REQUEST);
153        }
154
155        $chat = new Chat($this->configuration);
156        $result = $chat->markAsRead($messageId, $this->currentUser->getUserId());
157
158        if (!$result) {
159            return $this->json(['error' => Translation::get(key: 'msgError')], Response::HTTP_BAD_REQUEST);
160        }
161
162        return $this->json(['success' => true], Response::HTTP_OK);
163    }
164
165    /**
166     * Gets the unread message count for the current user.
167     *
168     * @throws Exception
169     */
170    #[Route(path: 'chat/unread-count', name: 'api.private.chat.unread-count', methods: ['GET'])]
171    public function getUnreadCount(Request $request): JsonResponse
172    {
173        $this->userIsAuthenticated();
174
175        $chat = new Chat($this->configuration);
176        $count = $chat->getUnreadCount($this->currentUser->getUserId());
177
178        return $this->json([
179            'success' => true,
180            'count' => $count,
181        ], Response::HTTP_OK);
182    }
183
184    /**
185     * Searches for users to start a new conversation.
186     *
187     * @throws Exception
188     */
189    #[Route(path: 'chat/users', name: 'api.private.chat.users', methods: ['GET'])]
190    public function searchUsers(Request $request): JsonResponse
191    {
192        $this->userIsAuthenticated();
193
194        $query = trim($request->query->get('q', ''));
195
196        if (mb_strlen($query) < 2) {
197            return $this->json([
198                'success' => true,
199                'users' => [],
200            ], Response::HTTP_OK);
201        }
202
203        $chat = new Chat($this->configuration);
204        $users = $chat->searchUsers($query, $this->currentUser->getUserId());
205
206        return $this->json([
207            'success' => true,
208            'users' => $users,
209        ], Response::HTTP_OK);
210    }
211}