Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
98 / 98 |
|
100.00% |
10 / 10 |
CRAP | |
100.00% |
1 / 1 |
MessagesController | |
100.00% |
98 / 98 |
|
100.00% |
10 / 10 |
15 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
index | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
listConversations | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
redirectToConversation | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
messagesOfConversation | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
send | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
delete | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
numberOfUnreadMessagesPerConversation | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
latestMessagePerConversation | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
raw | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Engelsystem\Controllers; |
6 | |
7 | use Engelsystem\Database\Database; |
8 | use Engelsystem\Helpers\Authenticator; |
9 | use Engelsystem\Http\Exceptions\HttpForbidden; |
10 | use Engelsystem\Http\Redirector; |
11 | use Engelsystem\Http\Request; |
12 | use Engelsystem\Http\Response; |
13 | use Engelsystem\Models\Message; |
14 | use Engelsystem\Models\User\User; |
15 | use Illuminate\Database\Query\Expression as QueryExpression; |
16 | use Illuminate\Support\Collection; |
17 | use Psr\Http\Message\RequestInterface; |
18 | |
19 | class MessagesController extends BaseController |
20 | { |
21 | protected RequestInterface $request; |
22 | |
23 | /** @var string[] */ |
24 | protected array $permissions = [ |
25 | 'user_messages', |
26 | ]; |
27 | |
28 | public function __construct( |
29 | protected Authenticator $auth, |
30 | protected Redirector $redirect, |
31 | protected Response $response, |
32 | Request $request, |
33 | protected Database $db, |
34 | protected Message $message, |
35 | protected User $user |
36 | ) { |
37 | $this->request = $request; |
38 | } |
39 | |
40 | public function index(): Response |
41 | { |
42 | return $this->listConversations(); |
43 | } |
44 | |
45 | /** |
46 | * Returns a list of conversations of the current user, each containing the other user, |
47 | * the most recent message, and the number of unread messages. |
48 | */ |
49 | public function listConversations(): Response |
50 | { |
51 | $currentUser = $this->auth->user(); |
52 | |
53 | $latestMessages = $this->latestMessagePerConversation($currentUser); |
54 | $numberOfUnreadMessages = $this->numberOfUnreadMessagesPerConversation($currentUser); |
55 | |
56 | $conversations = []; |
57 | foreach ($latestMessages as $msg) { |
58 | $otherUser = $msg->user_id == $currentUser->id ? $msg->receiver : $msg->sender; |
59 | $unreadMessages = $numberOfUnreadMessages[$otherUser->id] ?? 0; |
60 | |
61 | $conversations[] = [ |
62 | 'other_user' => $otherUser, |
63 | 'latest_message' => $msg, |
64 | 'unread_messages' => $unreadMessages, |
65 | ]; |
66 | } |
67 | |
68 | /** @var Collection $users */ |
69 | $users = $this->user->orderBy('name')->get() |
70 | ->except($currentUser->id) |
71 | ->mapWithKeys(function (User $u) { |
72 | return [$u->id => $u->displayName]; |
73 | }); |
74 | |
75 | $users->prepend($currentUser->displayName, $currentUser->id); |
76 | |
77 | return $this->response->withView( |
78 | 'pages/messages/index.twig', |
79 | [ |
80 | 'conversations' => $conversations, |
81 | 'users' => $users, |
82 | ] |
83 | ); |
84 | } |
85 | |
86 | /** |
87 | * Redirects to the conversation with the user of the given id. |
88 | */ |
89 | public function redirectToConversation(Request $request): Response |
90 | { |
91 | $data = $this->validate($request, ['user_id' => 'required|int']); |
92 | return $this->redirect->to('/messages/' . $data['user_id'] . '#newest'); |
93 | } |
94 | |
95 | /** |
96 | * Returns a list of messages between the current user and a user with the given id. Unread messages will be marked |
97 | * as read during this call. Still, they will be shown as unread in the frontend to show that they are new. |
98 | */ |
99 | public function messagesOfConversation(Request $request): Response |
100 | { |
101 | $userId = (int) $request->getAttribute('user_id'); |
102 | |
103 | $currentUser = $this->auth->user(); |
104 | $otherUser = $this->user->findOrFail($userId); |
105 | |
106 | $messages = $this->message |
107 | ->where(function ($query) use ($currentUser, $otherUser): void { |
108 | $query->whereUserId($currentUser->id) |
109 | ->whereReceiverId($otherUser->id); |
110 | }) |
111 | ->orWhere(function ($query) use ($currentUser, $otherUser): void { |
112 | $query->whereUserId($otherUser->id) |
113 | ->whereReceiverId($currentUser->id); |
114 | }) |
115 | ->orderBy('created_at') |
116 | ->get(); |
117 | |
118 | $unreadMessages = $messages->filter(function ($m) use ($otherUser) { |
119 | return $m->user_id == $otherUser->id && !$m->read; |
120 | }); |
121 | |
122 | foreach ($unreadMessages as $msg) { |
123 | $msg->read = true; |
124 | $msg->save(); |
125 | $msg->read = false; // change back to true to display it to the frontend one more time. |
126 | } |
127 | |
128 | return $this->response->withView( |
129 | 'pages/messages/conversation.twig', |
130 | ['messages' => $messages, 'other_user' => $otherUser] |
131 | ); |
132 | } |
133 | |
134 | /** |
135 | * Sends a message to another user. |
136 | */ |
137 | public function send(Request $request): Response |
138 | { |
139 | $userId = (int) $request->getAttribute('user_id'); |
140 | |
141 | $currentUser = $this->auth->user(); |
142 | |
143 | $data = $this->validate($request, ['text' => 'required']); |
144 | |
145 | $otherUser = $this->user->findOrFail($userId); |
146 | |
147 | $newMessage = new Message(); |
148 | $newMessage->sender()->associate($currentUser); |
149 | $newMessage->receiver()->associate($otherUser); |
150 | $newMessage->text = $data['text']; |
151 | $newMessage->read = $otherUser->id == $currentUser->id; // if its to myself, I obviously read it. |
152 | $newMessage->save(); |
153 | |
154 | event('message.created', ['message' => $newMessage]); |
155 | |
156 | return $this->redirect->to('/messages/' . $otherUser->id . '#newest'); |
157 | } |
158 | |
159 | /** |
160 | * Deletes a message with a given id, as long as this message was send by the current user. |
161 | * The given user id is used to redirect back to the conversation with that user. |
162 | */ |
163 | public function delete(Request $request): Response |
164 | { |
165 | $otherUserId = (int) $request->getAttribute('user_id'); |
166 | $msgId = (int) $request->getAttribute('msg_id'); |
167 | |
168 | $currentUser = $this->auth->user(); |
169 | $msg = $this->message->findOrFail($msgId); |
170 | |
171 | if ($msg->user_id == $currentUser->id) { |
172 | $msg->delete(); |
173 | } else { |
174 | throw new HttpForbidden(); |
175 | } |
176 | |
177 | return $this->redirect->to('/messages/' . $otherUserId . '#newest'); |
178 | } |
179 | |
180 | /** |
181 | * The number of unread messages per conversation of the current user. |
182 | * @return Collection of unread message amounts. Each object with key=other user, value=amount of unread messages |
183 | */ |
184 | protected function numberOfUnreadMessagesPerConversation(User $currentUser): Collection |
185 | { |
186 | return $currentUser->messagesReceived() |
187 | ->select('user_id', $this->raw('count(*) as amount')) |
188 | ->where('read', false) |
189 | ->groupBy('user_id') |
190 | ->get(['user_id', 'amount']) |
191 | ->mapWithKeys(function ($unread) { |
192 | return [$unread->user_id => $unread->amount]; |
193 | }); |
194 | } |
195 | |
196 | /** |
197 | * Returns the latest message for each conversation, |
198 | * which were either send by or addressed to the current user. |
199 | * @return Collection of messages |
200 | */ |
201 | protected function latestMessagePerConversation(User $currentUser): Collection |
202 | { |
203 | /* requesting the IDs first, grouped by "conversation". |
204 | The more complex grouping is required for associating the messages to the correct conversations. |
205 | Without this, a database change might have been needed to realize the "conversations" concept. */ |
206 | $latestMessageIds = $this->message |
207 | ->select($this->raw('max(id) as last_id')) |
208 | ->where('user_id', '=', $currentUser->id) |
209 | ->orWhere('receiver_id', '=', $currentUser->id) |
210 | ->groupBy($this->raw( |
211 | '(CASE WHEN user_id = ' . (int) $currentUser->id . |
212 | ' THEN receiver_id ELSE user_id END)' |
213 | )); |
214 | |
215 | // then getting the full message objects for each ID. |
216 | return $this->message |
217 | ->joinSub($latestMessageIds, 'conversations', function ($join): void { |
218 | $join->on('messages.id', '=', 'conversations.last_id'); |
219 | }) |
220 | ->orderBy('created_at', 'DESC') |
221 | ->get() |
222 | ->load(['receiver.personalData', 'receiver.state']); |
223 | } |
224 | |
225 | protected function raw(mixed $value): QueryExpression |
226 | { |
227 | return $this->db->getConnection()->raw($value); |
228 | } |
229 | } |