1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Libraries;
7
8use App\Models\Notification;
9use App\Models\User;
10use App\Models\UserNotification;
11use DB;
12use Illuminate\Database\Eloquent\Collection;
13
14class NotificationsBundle
15{
16 const PER_STACK_LIMIT = 50;
17 const STACK_LIMIT = 50;
18
19 private static function stackKey(string $objectType, int $objectId, string $category): string
20 {
21 return "{$objectType}-{$objectId}-{$category}";
22 }
23
24 private $category;
25 private array $countByType;
26 private $cursorId;
27 private $objectId;
28 private $objectType;
29 private $stacks = [];
30 private $types = [];
31 private $unreadOnly;
32 private $user;
33 private Collection $userNotifications;
34
35 public function __construct(User $user, array $request)
36 {
37 $this->user = $user;
38 $this->userNotifications = new Collection();
39 $this->unreadOnly = get_bool($request['unread'] ?? false);
40 $this->cursorId = get_int($request['cursor']['id'] ?? null);
41
42 $this->objectId = get_int($request['object_id'] ?? null);
43 $this->objectType = presence($request['type'] ?? null) ?? presence($request['object_type'] ?? null);
44 $this->category = presence($request['category'] ?? null);
45 }
46
47 public function toArray()
48 {
49 if ($this->objectId && $this->objectType && $this->category) {
50 $this->fillStacks($this->objectType, $this->objectId, $this->category);
51 } else {
52 $this->fillTypes($this->objectType);
53 }
54
55 $this->fillStackTotal();
56
57 $response = [
58 'notifications' => json_collection($this->userNotifications->load('notification'), 'Notification'),
59 'stacks' => array_values($this->stacks),
60 'timestamp' => json_time(now()),
61 'types' => array_values($this->types),
62 ];
63
64 if ($this->unreadOnly) {
65 $response['unread_count'] = $this->getTotalNotificationCount();
66 }
67
68 return $response;
69 }
70
71 private function fillStacks(string $objectType, int $objectId, string $category)
72 {
73 $key = static::stackKey($objectType, $objectId, $category);
74 // skip multiple notification names mapped to the same category.
75 if (isset($this->stacks[$key])) {
76 return;
77 }
78
79 $query = $this
80 ->user
81 ->userNotifications()
82 ->hasPushDelivery()
83 ->joinRelation('notification', function ($q) use ($category, $objectId, $objectType) {
84 $q
85 ->where($q->qualifyColumn('notifiable_type'), $objectType)
86 ->where($q->qualifyColumn('notifiable_id'), $objectId)
87 ->whereIn($q->qualifyColumn('name'), Notification::namesInCategory($category))
88 ->orderByDesc($q->qualifyColumn('created_at'))
89 ->orderByDesc($q->qualifyColumn('id'));
90
91 if ($this->cursorId !== null) {
92 $q->where($q->qualifyColumn('id'), '<', $this->cursorId);
93 }
94 })
95 ->limit(static::PER_STACK_LIMIT);
96
97 if ($this->unreadOnly) {
98 $query->where($query->qualifyColumn('is_read'), false);
99 }
100 $query->select($query->qualifyColumn('*'));
101
102 $stack = $query->get();
103
104 $json = $this->stackToJson($stack, $objectType, $objectId, $category);
105 if ($json !== null) {
106 $this->stacks[$key] = $json;
107 $this->userNotifications = $this->userNotifications->merge($stack);
108 }
109 }
110
111 private function fillStackTotal(): void
112 {
113 if (empty($this->stacks)) {
114 return;
115 }
116
117 $binds = [];
118 $bindValues = [];
119 foreach ($this->stacks as $key => $stackJson) {
120 foreach (Notification::namesInCategory($stackJson['category']) as $name) {
121 $bindValues[] = $stackJson['object_type'];
122 $bindValues[] = $stackJson['object_id'];
123 $bindValues[] = $name;
124 $binds[] = '(?, ?, ?)';
125 }
126 }
127
128 $notificationModel = new Notification();
129 $userNotificationModel = new UserNotification();
130
131 $groupColumns = [
132 'type' => $notificationModel->qualifyColumn('notifiable_type'),
133 'id' => $notificationModel->qualifyColumn('notifiable_id'),
134 'name' => $notificationModel->qualifyColumn('name'),
135 ];
136
137 $bindsString = implode(', ', $binds);
138 $whereString = "({$groupColumns['type']}, {$groupColumns['id']}, {$groupColumns['name']}) IN ({$bindsString})";
139
140 $query = $this
141 ->user
142 ->userNotifications()
143 ->hasPushDelivery()
144 ->join(
145 $notificationModel->getTable(),
146 $notificationModel->qualifyColumn('id'),
147 '=',
148 $userNotificationModel->qualifyColumn('notification_id')
149 )
150 ->selectRaw("
151 COUNT(*) as count,
152 {$groupColumns['type']},
153 {$groupColumns['id']},
154 {$groupColumns['name']}")
155 ->whereRaw($whereString, $bindValues)
156 ->groupBy(array_values($groupColumns));
157
158 if ($this->unreadOnly) {
159 $query->where('is_read', false);
160 }
161
162 foreach ($query->get() as $row) {
163 $name = $row->getRawAttribute('name');
164 $category = Notification::NAME_TO_CATEGORY[$name] ?? $name;
165 $key = static::stackKey($row->getRawAttribute('notifiable_type'), $row->getRawAttribute('notifiable_id'), $category);
166 $this->stacks[$key]['total'] ??= 0;
167 $this->stacks[$key]['total'] += $row->getRawAttribute('count');
168 }
169 }
170
171 private function fillTypes(?string $type = null)
172 {
173 $heads = $this->getStackHeads($type);
174
175 $heads->each(function ($row) {
176 $this->fillStacks($row->notifiable_type, $row->notifiable_id, $row->category);
177 });
178
179 $last = $heads->last();
180 $cursor = $last !== null ? ['id' => $last->max_id] : null;
181 $this->types[$type] = [
182 'cursor' => $cursor,
183 'name' => $type,
184 'total' => $this->getTotalNotificationCount($type),
185 ];
186
187 // when notifications for all types, fill in the cursor and totals for the other types.
188 if ($type === null) {
189 $this->fillTypesWhenNull($cursor);
190 }
191 }
192
193 private function getTotalNotificationCount(?string $type = null)
194 {
195 if (!isset($this->countByType)) {
196 $query = Notification ::whereHas('userNotifications', function ($q) {
197 $q->hasPushDelivery()->where('user_id', $this->user->getKey());
198 if ($this->unreadOnly) {
199 $q->where('is_read', false);
200 }
201 });
202
203 if ($this->objectType !== null) {
204 $query->where('notifiable_type', $this->objectType);
205 }
206
207 $this->countByType = $query
208 ->groupBy('notifiable_type')
209 ->selectRaw('count(*) type_count, notifiable_type')
210 ->get()
211 ->mapWithKeys(fn ($agg) => [$agg->notifiable_type => $agg->type_count])
212 ->all();
213 }
214
215 return $type === null
216 ? array_sum($this->countByType)
217 : $this->countByType[$type] ?? 0;
218 }
219
220 private function getStackHeads(?string $type = null)
221 {
222 $heads = Notification::whereHas('userNotifications', function ($q) {
223 $q->hasPushDelivery()->where('user_id', $this->user->getKey());
224 if ($this->unreadOnly) {
225 $q->where('is_read', false);
226 }
227 })
228 ->groupBy('name', 'notifiable_type', 'notifiable_id')
229 ->orderBy('max_id', 'DESC')
230 ->select(DB::raw('MAX(id) as max_id'), 'name', 'notifiable_type', 'notifiable_id');
231
232 if ($type !== null) {
233 $heads->where('notifiable_type', $type);
234 }
235
236 // TODO: ignore cursor if params don't match
237 if ($this->cursorId !== null) {
238 $heads->having('max_id', '<', $this->cursorId);
239 }
240
241 return $heads->limit(static::STACK_LIMIT)->get();
242 }
243
244 private function fillTypesWhenNull($cursor)
245 {
246 foreach (Notification::NOTIFIABLE_CLASSES as $class) {
247 $type = MorphMap::getType($class);
248 $cursor['type'] = $type;
249
250 $this->types[$type] = [
251 'cursor' => $cursor,
252 'name' => $type,
253 'total' => $this->getTotalNotificationCount($type),
254 ];
255 }
256 }
257
258 private function stackToJson($stack, string $objectType, int $objectId, string $category)
259 {
260 $last = $stack->last();
261 if ($last === null) {
262 return;
263 }
264
265 $cursor = $stack->count() < static::PER_STACK_LIMIT ? null : [
266 'id' => $last->notification_id,
267 ];
268
269 return [
270 'category' => $category,
271 'cursor' => $cursor,
272 // TODO: deprecated. Actual value isn't used by osu-web and it's
273 // expensive to obtain at the point this function is called.
274 // Remove when not used by anything else.
275 'name' => Notification::namesInCategory($category)[0],
276 'object_type' => $objectType,
277 'object_id' => $objectId,
278 ];
279 }
280}