Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.67% covered (success)
98.67%
222 / 225
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Chat
98.67% covered (success)
98.67%
222 / 225
78.57% covered (warning)
78.57%
11 / 14
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendMessage
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 getConversation
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
3.00
 getConversationList
98.44% covered (success)
98.44%
63 / 64
0.00% covered (danger)
0.00%
0 / 1
11
 markAsRead
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 markConversationAsRead
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getUnreadCount
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getNewMessages
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 searchUsers
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 messageToArray
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 messagesToArray
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 getBatchUserInfo
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 getUserInfo
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 mapRowToEntity
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Chat class provides methods 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;
21
22use DateMalformedStringException;
23use DateTimeImmutable;
24use Exception;
25use phpMyFAQ\Entity\ChatMessage;
26
27readonly class Chat
28{
29    public function __construct(
30        private Configuration $configuration,
31    ) {
32    }
33
34    /**
35     * Sends a new message from one user to another.
36     *
37     * @throws Exception
38     */
39    public function sendMessage(int $senderId, int $recipientId, string $message): ?ChatMessage
40    {
41        if (trim($message) === '') {
42            return null;
43        }
44
45        $table = Database::getTablePrefix() . 'faqchat_messages';
46        $nextId = $this->configuration->getDb()->nextId($table, 'id');
47
48        $query = sprintf(
49            "INSERT INTO %s (id, sender_id, recipient_id, message, is_read, created_at)
50             VALUES (%d, %d, %d, '%s', 0, %s)",
51            $table,
52            $nextId,
53            $senderId,
54            $recipientId,
55            $this->configuration->getDb()->escape($message),
56            $this->configuration->getDb()->now(),
57        );
58
59        $result = $this->configuration->getDb()->query($query);
60
61        if (!$result) {
62            return null;
63        }
64
65        $chatMessage = new ChatMessage();
66        $chatMessage
67            ->setId($nextId)
68            ->setSenderId($senderId)
69            ->setRecipientId($recipientId)
70            ->setMessage($message)
71            ->setIsRead(false)
72            ->setCreatedAt(new DateTimeImmutable());
73
74        return $chatMessage;
75    }
76
77    /**
78     * Retrieves the conversation between two users.
79     *
80     * @return ChatMessage[]
81     */
82    public function getConversation(int $userId1, int $userId2, int $limit = 50, int $offset = 0): array
83    {
84        $query = sprintf(
85            'SELECT id, sender_id, recipient_id, message, is_read, created_at
86             FROM %sfaqchat_messages
87             WHERE (sender_id = %d AND recipient_id = %d)
88                OR (sender_id = %d AND recipient_id = %d)
89             ORDER BY created_at ASC
90             LIMIT %d OFFSET %d',
91            Database::getTablePrefix(),
92            $userId1,
93            $userId2,
94            $userId2,
95            $userId1,
96            $limit,
97            $offset,
98        );
99
100        $result = $this->configuration->getDb()->query($query);
101        $messages = [];
102
103        while (true) {
104            $row = $this->configuration->getDb()->fetchObject($result);
105            if (!$row instanceof \stdClass) {
106                break;
107            }
108
109            $messages[] = $this->mapRowToEntity($row);
110        }
111
112        return $messages;
113    }
114
115    /**
116     * Gets the list of all users with whom the given user has conversations.
117     * Optimized to use correlated subqueries instead of N+1 queries.
118     *
119     * @return array<int, array{userId: int, displayName: string, lastMessage: string, lastMessageTime: string, unreadCount: int}>
120     */
121    public function getConversationList(int $userId): array
122    {
123        $prefix = Database::getTablePrefix();
124
125        // Get all unique conversation partners first
126        $partnersQuery = sprintf(
127            'SELECT DISTINCT
128                CASE WHEN sender_id = %d THEN recipient_id ELSE sender_id END as partner_id
129             FROM %sfaqchat_messages
130             WHERE sender_id = %d OR recipient_id = %d',
131            $userId,
132            $prefix,
133            $userId,
134            $userId,
135        );
136
137        $partnersResult = $this->configuration->getDb()->query($partnersQuery);
138        if (!$partnersResult) {
139            return [];
140        }
141
142        $partnerIds = [];
143        while (true) {
144            $row = $this->configuration->getDb()->fetchObject($partnersResult);
145            if (!$row instanceof \stdClass) {
146                break;
147            }
148
149            $partnerIds[] = (int) $row->partner_id;
150        }
151
152        if ($partnerIds === []) {
153            return [];
154        }
155
156        // Build a single query to get user info for all partners
157        $partnerIdList = implode(',', $partnerIds);
158        $userInfoQuery = sprintf(
159            'SELECT user_id, display_name FROM %sfaquserdata WHERE user_id IN (%s)',
160            $prefix,
161            $partnerIdList,
162        );
163        $userInfoResult = $this->configuration->getDb()->query($userInfoQuery);
164        $userInfo = [];
165        while (true) {
166            $row = $this->configuration->getDb()->fetchObject($userInfoResult);
167            if (!$row instanceof \stdClass) {
168                break;
169            }
170
171            $userInfo[(int) $row->user_id] = (string) ($row->display_name ?? 'Unknown User');
172        }
173
174        // Build conversations array with optimized queries per partner
175        $conversations = [];
176        foreach ($partnerIds as $partnerId) {
177            // Get last message
178            $lastMsgQuery = sprintf(
179                'SELECT message, created_at FROM %sfaqchat_messages
180                 WHERE (sender_id = %d AND recipient_id = %d)
181                    OR (sender_id = %d AND recipient_id = %d)
182                 ORDER BY created_at DESC LIMIT 1',
183                $prefix,
184                $userId,
185                $partnerId,
186                $partnerId,
187                $userId,
188            );
189            $lastMsgResult = $this->configuration->getDb()->query($lastMsgQuery);
190            $lastMsg = $this->configuration->getDb()->fetchObject($lastMsgResult);
191
192            // Get unread count
193            $unreadQuery = sprintf(
194                'SELECT COUNT(*) as cnt FROM %sfaqchat_messages
195                 WHERE sender_id = %d AND recipient_id = %d AND is_read = 0',
196                $prefix,
197                $partnerId,
198                $userId,
199            );
200            $unreadResult = $this->configuration->getDb()->query($unreadQuery);
201            $unreadRow = $this->configuration->getDb()->fetchObject($unreadResult);
202
203            $conversations[] = [
204                'userId' => $partnerId,
205                'displayName' => $userInfo[$partnerId] ?? 'Unknown User',
206                'lastMessage' => $lastMsg instanceof \stdClass ? (string) ($lastMsg->message ?? '') : '',
207                'lastMessageTime' => $lastMsg instanceof \stdClass ? (string) ($lastMsg->created_at ?? '') : '',
208                'unreadCount' => $unreadRow instanceof \stdClass ? (int) ($unreadRow->cnt ?? 0) : 0,
209            ];
210        }
211
212        // Sort by last message time descending
213        usort($conversations, static fn($a, $b) => strcmp($b['lastMessageTime'], $a['lastMessageTime']));
214
215        return $conversations;
216    }
217
218    /**
219     * Marks a specific message as read.
220     */
221    public function markAsRead(int $messageId, int $userId): bool
222    {
223        $query = sprintf(
224            'UPDATE %sfaqchat_messages SET is_read = 1 WHERE id = %d AND recipient_id = %d',
225            Database::getTablePrefix(),
226            $messageId,
227            $userId,
228        );
229
230        $result = $this->configuration->getDb()->query($query);
231
232        return $result !== false && $result !== null;
233    }
234
235    /**
236     * Marks all messages in a conversation as read.
237     */
238    public function markConversationAsRead(int $userId, int $partnerId): bool
239    {
240        $query = sprintf(
241            'UPDATE %sfaqchat_messages SET is_read = 1 WHERE sender_id = %d AND recipient_id = %d AND is_read = 0',
242            Database::getTablePrefix(),
243            $partnerId,
244            $userId,
245        );
246
247        $result = $this->configuration->getDb()->query($query);
248
249        return $result !== false && $result !== null;
250    }
251
252    /**
253     * Gets the total count of unread messages for a user.
254     */
255    public function getUnreadCount(int $userId): int
256    {
257        $query = sprintf(
258            'SELECT COUNT(*) as count FROM %sfaqchat_messages WHERE recipient_id = %d AND is_read = 0',
259            Database::getTablePrefix(),
260            $userId,
261        );
262
263        $result = $this->configuration->getDb()->query($query);
264        $row = $this->configuration->getDb()->fetchObject($result);
265
266        return $row instanceof \stdClass ? (int) ($row->count ?? 0) : 0;
267    }
268
269    /**
270     * Gets new messages since a given message ID (for SSE polling).
271     *
272     * @return ChatMessage[]
273     */
274    public function getNewMessages(int $userId, int $lastMessageId): array
275    {
276        $query = sprintf('SELECT id, sender_id, recipient_id, message, is_read, created_at
277             FROM %sfaqchat_messages
278             WHERE recipient_id = %d AND id > %d
279             ORDER BY created_at ASC', Database::getTablePrefix(), $userId, $lastMessageId);
280
281        $result = $this->configuration->getDb()->query($query);
282        $messages = [];
283
284        while (true) {
285            $row = $this->configuration->getDb()->fetchObject($result);
286            if (!$row instanceof \stdClass) {
287                break;
288            }
289
290            $messages[] = $this->mapRowToEntity($row);
291        }
292
293        return $messages;
294    }
295
296    /**
297     * Searches for users by display name (for starting new conversations).
298     *
299     * @return array<int, array{userId: int, displayName: string}>
300     */
301    public function searchUsers(string $searchTerm, int $excludeUserId, int $limit = 10): array
302    {
303        $escapedTerm = $this->configuration->getDb()->escape(mb_strtolower($searchTerm));
304
305        // Escape LIKE metacharacters (%, _) to prevent wildcard injection
306        $escapedTerm = str_replace(['|', '%', '_'], ['||', '|%', '|_'], $escapedTerm);
307
308        $query = sprintf(
309            "SELECT u.user_id, ud.display_name
310             FROM %sfaquser u
311             LEFT JOIN %sfaquserdata ud ON u.user_id = ud.user_id
312             WHERE u.user_id != %d
313               AND u.user_id > 0
314               AND LOWER(ud.display_name) LIKE '%%%s%%' ESCAPE '|'
315               AND u.account_status = 'active'
316             LIMIT %d",
317            Database::getTablePrefix(),
318            Database::getTablePrefix(),
319            $excludeUserId,
320            $escapedTerm,
321            $limit,
322        );
323
324        $result = $this->configuration->getDb()->query($query);
325        $users = [];
326
327        while (true) {
328            $row = $this->configuration->getDb()->fetchObject($result);
329            if (!$row instanceof \stdClass) {
330                break;
331            }
332
333            $users[] = [
334                'userId' => (int) $row->user_id,
335                'displayName' => (string) ($row->display_name ?? 'Unknown'),
336            ];
337        }
338
339        return $users;
340    }
341
342    /**
343     * Converts a message to an array for JSON serialization.
344     *
345     * @return array<string, mixed>
346     */
347    public function messageToArray(ChatMessage $message): array
348    {
349        $senderInfo = $this->getUserInfo($message->getSenderId());
350
351        return [
352            'id' => $message->getId(),
353            'senderId' => $message->getSenderId(),
354            'senderName' => $senderInfo['display_name'] ?? 'Unknown User',
355            'recipientId' => $message->getRecipientId(),
356            'message' => $message->getMessage(),
357            'isRead' => $message->isRead(),
358            'createdAt' => $message->getCreatedAt()->format('c'),
359        ];
360    }
361
362    /**
363     * Converts multiple messages to an array for JSON serialization.
364     * Optimized to batch fetch user info for all senders in a single query.
365     *
366     * @param ChatMessage[] $messages
367     * @return list<array<string, mixed>>
368     */
369    public function messagesToArray(array $messages): array
370    {
371        if ($messages === []) {
372            return [];
373        }
374
375        // Collect unique sender IDs
376        $senderIds = array_unique(array_map(static fn(ChatMessage $m) => $m->getSenderId(), $messages));
377
378        // Batch fetch user info
379        $userInfo = $this->getBatchUserInfo($senderIds);
380
381        return array_values(array_map(static fn(ChatMessage $message) => [
382            'id' => $message->getId(),
383            'senderId' => $message->getSenderId(),
384            'senderName' => $userInfo[$message->getSenderId()] ?? 'Unknown User',
385            'recipientId' => $message->getRecipientId(),
386            'message' => $message->getMessage(),
387            'isRead' => $message->isRead(),
388            'createdAt' => $message->getCreatedAt()->format('c'),
389        ], $messages));
390    }
391
392    /**
393     * Gets user info for multiple users in a single query.
394     *
395     * @param int[] $userIds
396     * @return array<int, string> Map of userId => displayName
397     */
398    private function getBatchUserInfo(array $userIds): array
399    {
400        if ($userIds === []) {
401            return [];
402        }
403
404        $idList = implode(',', array_map('intval', $userIds));
405        $query = sprintf(
406            'SELECT user_id, display_name FROM %sfaquserdata WHERE user_id IN (%s)',
407            Database::getTablePrefix(),
408            $idList,
409        );
410
411        $result = $this->configuration->getDb()->query($query);
412        $userInfo = [];
413
414        while (true) {
415            $row = $this->configuration->getDb()->fetchObject($result);
416            if (!$row instanceof \stdClass) {
417                break;
418            }
419
420            $userInfo[(int) $row->user_id] = (string) ($row->display_name ?? 'Unknown User');
421        }
422
423        return $userInfo;
424    }
425
426    /**
427     * Gets user information by user ID.
428     *
429     * @return array{display_name: string|null, email: string|null}
430     */
431    private function getUserInfo(int $userId): array
432    {
433        $query = sprintf(
434            'SELECT display_name, email FROM %sfaquserdata WHERE user_id = %d',
435            Database::getTablePrefix(),
436            $userId,
437        );
438
439        $result = $this->configuration->getDb()->query($query);
440        $row = $this->configuration->getDb()->fetchObject($result);
441
442        return [
443            'display_name' => $row instanceof \stdClass ? (string) ($row->display_name ?? '') : null,
444            'email' => $row instanceof \stdClass ? (string) ($row->email ?? '') : null,
445        ];
446    }
447
448    /**
449     * Maps a database row to a ChatMessage entity.
450     * @throws DateMalformedStringException
451     */
452    private function mapRowToEntity(\stdClass $row): ChatMessage
453    {
454        $chatMessage = new ChatMessage();
455        $chatMessage
456            ->setId((int) $row->id)
457            ->setSenderId((int) $row->sender_id)
458            ->setRecipientId((int) $row->recipient_id)
459            ->setMessage((string) $row->message)
460            ->setIsRead((int) $row->is_read !== 0)
461            ->setCreatedAt(new DateTimeImmutable((string) $row->created_at));
462
463        return $chatMessage;
464    }
465}