Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
98 / 98
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
MessagesController
100.00% covered (success)
100.00%
98 / 98
100.00% covered (success)
100.00%
10 / 10
15
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listConversations
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 redirectToConversation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 messagesOfConversation
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 send
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 numberOfUnreadMessagesPerConversation
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 latestMessagePerConversation
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 raw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers;
6
7use Engelsystem\Database\Database;
8use Engelsystem\Helpers\Authenticator;
9use Engelsystem\Http\Exceptions\HttpForbidden;
10use Engelsystem\Http\Redirector;
11use Engelsystem\Http\Request;
12use Engelsystem\Http\Response;
13use Engelsystem\Models\Message;
14use Engelsystem\Models\User\User;
15use Illuminate\Database\Query\Expression as QueryExpression;
16use Illuminate\Support\Collection;
17use Psr\Http\Message\RequestInterface;
18
19class 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}