the browser-facing portion of osu!
at master 9.6 kB view raw
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}