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\Libraries\BeatmapsetDiscussion\Review;
9use App\Models\Beatmap;
10use App\Models\BeatmapDiscussion;
11use App\Models\BeatmapDiscussionPost;
12use App\Models\BeatmapDiscussionVote;
13use App\Models\Beatmapset;
14use App\Models\BeatmapsetEvent;
15use App\Models\User;
16use App\Traits\Memoizes;
17use App\Transformers\UserTransformer;
18use Ds\Set;
19use Illuminate\Database\Eloquent\Relations\MorphTo;
20use Illuminate\Pagination\LengthAwarePaginator;
21
22class ModdingHistoryEventsBundle
23{
24 use Memoizes;
25
26 const KUDOSU_PER_PAGE = 5;
27
28 protected $isModerator;
29 protected $isKudosuModerator;
30 protected $searchParams;
31
32 private $params;
33 private $total;
34 private $user;
35 private $withExtras = false; // TODO: change to includes list instead.
36
37 public static function forProfile(User $user, array $searchParams)
38 {
39 $searchParams['limit'] = 10;
40 $searchParams['sort'] = 'id_desc';
41
42 $obj = static::forListing($user, $searchParams);
43 $obj->withExtras = true;
44
45 return $obj;
46 }
47
48 public static function forListing(?User $user, array $searchParams)
49 {
50 $obj = new static();
51 $obj->user = $user;
52 $obj->searchParams = $searchParams;
53 $obj->isModerator = priv_check('BeatmapDiscussionModerate')->can();
54 $obj->isKudosuModerator = priv_check('BeatmapDiscussionAllowOrDenyKudosu')->can();
55
56 $obj->searchParams['is_moderator'] = $obj->isModerator;
57
58 if (!$obj->isModerator) {
59 $obj->searchParams['with_deleted'] = false;
60 }
61
62 return $obj;
63 }
64
65 public function getPaginator()
66 {
67 $events = $this->getEvents();
68 $params = $this->params;
69
70 return new LengthAwarePaginator(
71 $events,
72 $this->total, // set in getEvents()
73 $params['limit'],
74 $params['page'],
75 [
76 'path' => LengthAwarePaginator::resolveCurrentPath(),
77 'query' => $params,
78 ]
79 );
80 }
81
82 public function getParams()
83 {
84 return $this->params;
85 }
86
87 public function toArray(): array
88 {
89 return $this->memoize(__FUNCTION__, function () {
90 $array = [
91 'events' => json_collection(
92 $this->getEvents(),
93 'BeatmapsetEvent',
94 ['discussion.starting_post', 'beatmapset.user']
95 ),
96 'reviewsConfig' => Review::config(),
97 'users' => json_collection(
98 $this->getUsers(),
99 'UserCompact',
100 ['groups']
101 ),
102 ];
103
104 if ($this->withExtras) {
105 $array['beatmaps'] = json_collection(
106 $this->getBeatmaps(),
107 'Beatmap'
108 );
109
110 $array['beatmapsets'] = json_collection(
111 $this->getBeatmapsets(),
112 'Beatmapset'
113 );
114
115 $array['discussions'] = json_collection(
116 $this->getDiscussions(),
117 'BeatmapDiscussion',
118 ['starting_post', 'current_user_attributes']
119 );
120
121 $array['posts'] = json_collection(
122 $this->getPosts(),
123 'BeatmapDiscussionPost',
124 // TODO: should get beatmapset from top level beatmapset key instead of embedded property.
125 ['beatmap_discussion.beatmapset.availability']
126 );
127
128 $array['votes'] = $this->getVotes();
129
130 if ($this->user !== null) {
131 $kudosu = $this->user
132 ->receivedKudosu()
133 ->with('post', 'post.topic', 'giver')
134 ->with(['kudosuable' => function (MorphTo $morphTo) {
135 $morphTo->morphWith([BeatmapDiscussion::class => ['beatmap', 'beatmapset']]);
136 }])
137 ->orderBy('exchange_id', 'desc')
138 ->limit(static::KUDOSU_PER_PAGE + 1)
139 ->get();
140
141 $array['extras'] = [
142 'recentlyReceivedKudosu' => json_collection($kudosu, 'KudosuHistory'),
143 ];
144 // only recentlyReceivedKudosu is set, do we even need it?
145 // every other item has a show more link that goes to a listing.
146 $array['perPage'] = [
147 'recentlyReceivedKudosu' => static::KUDOSU_PER_PAGE,
148 ];
149
150 $array['user'] = json_item(
151 $this->user,
152 (new UserTransformer())->setMode($this->user->playmode),
153 [
154 ...UserTransformer::PROFILE_HEADER_INCLUDES,
155 'graveyard_beatmapset_count',
156 'loved_beatmapset_count',
157 'pending_beatmapset_count',
158 'ranked_beatmapset_count',
159 'statistics',
160 'statistics.country_rank',
161 'statistics.rank',
162 ]
163 );
164 }
165 }
166
167 return $array;
168 });
169 }
170
171 private function getBeatmaps()
172 {
173 return $this->memoize(__FUNCTION__, function () {
174 if (!$this->withExtras) {
175 return collect();
176 }
177
178 $beatmapsetId = $this->getBeatmapsets()
179 ->pluck('beatmapset_id');
180
181 return Beatmap::whereIn('beatmapset_id', $beatmapsetId)->get();
182 });
183 }
184
185 private function getBeatmapsets()
186 {
187 return $this->memoize(__FUNCTION__, function () {
188 if (!$this->withExtras) {
189 return collect();
190 }
191
192 $beatmapsetId = $this->getDiscussions()
193 ->pluck('beatmapset_id')
194 ->unique()
195 ->toArray();
196
197 return Beatmapset::whereIn('beatmapset_id', $beatmapsetId)->get();
198 });
199 }
200
201 private function getDiscussions()
202 {
203 return $this->memoize(__FUNCTION__, function () {
204 static $includes = [
205 'beatmap',
206 'beatmapDiscussionVotes',
207 'beatmapset',
208 'startingPost',
209 ];
210
211 if (!$this->withExtras) {
212 return collect();
213 }
214
215 $parents = BeatmapDiscussion::search($this->searchParams);
216 $parents['query']->with($includes);
217
218 if ($this->isModerator) {
219 $parents['query']->visibleWithTrashed();
220 } else {
221 $parents['query']->visible();
222 }
223
224 $discussions = $parents['query']->get();
225
226 $children = BeatmapDiscussion::whereIn('parent_id', $discussions->pluck('id'))->with($includes);
227
228 if ($this->isModerator) {
229 $children->visibleWithTrashed();
230 } else {
231 $children->visible();
232 }
233
234 return $discussions->merge($children->get());
235 });
236 }
237
238 private function getEvents()
239 {
240 return $this->memoize(__FUNCTION__, function () {
241 $events = BeatmapsetEvent::search($this->searchParams);
242 // beatmapset has global scopes with deleted_at and active but these are not indexed,
243 // which makes whereHas('beatmapset') unusable.
244 $events['query'] = $events['query']->with([
245 'beatmapset.user',
246 'beatmapDiscussion.beatmapset',
247 'beatmapDiscussion.startingPost',
248 ]);
249
250 if ($this->isModerator) {
251 $events['query']->with(['beatmapset' => function ($query) {
252 $query->withTrashed();
253 }]);
254 }
255
256 // just for the paginator
257 $this->total = $events['query']->realCount();
258 $this->params = $events['params'];
259
260 return $events['query']->get();
261 });
262 }
263
264 private function getPosts()
265 {
266 return $this->memoize(__FUNCTION__, function () {
267 if (!$this->withExtras) {
268 return collect();
269 }
270
271 $posts = BeatmapDiscussionPost::search($this->searchParams);
272 $posts['query']->with([
273 'beatmapDiscussion.beatmap',
274 'beatmapDiscussion.beatmapset',
275 ]);
276
277 if (!$this->isModerator) {
278 $posts['query']->visible();
279 }
280
281 return $posts['query']->get();
282 });
283 }
284
285 private function getUsers()
286 {
287 return $this->memoize(__FUNCTION__, function () {
288 $discussions = $this->getDiscussions();
289 $events = $this->getEvents();
290 $posts = $this->getPosts();
291 $votes = $this->getVotes();
292
293 $userIds = new Set();
294 foreach ($discussions as $discussion) {
295 $userIds->add(
296 $discussion->user_id,
297 $discussion->startingPost->last_editor_id
298 );
299 }
300
301 $userIds->add(
302 ...$posts->pluck('user_id'),
303 ...$posts->pluck('last_editor_id'),
304 ...$events->pluck('user_id'),
305 ...$events->pluck('beatmapDiscussion')->pluck('user_id'),
306 ...$votes['given']->pluck('user_id'),
307 ...$votes['received']->pluck('user_id')
308 );
309
310 if ($this->user !== null) {
311 // Always add current user to the result array (assuming no need to do too many additional preloads).
312 // This prevents them from potentially get removed by the `default` scope.
313 $userIds->remove($this->user->getKey());
314 }
315
316 $users = User::whereIn('user_id', $userIds->toArray())->with('userGroups');
317 if (!$this->isModerator) {
318 $users->default();
319 }
320
321 $users = $users->get();
322 if ($this->user !== null) {
323 $users->push($this->user);
324 }
325
326 return $users;
327 });
328 }
329
330 private function getVotes()
331 {
332 return $this->memoize(__FUNCTION__, function () {
333 if ($this->withExtras && $this->user !== null) {
334 return [
335 'given' => BeatmapDiscussionVote::recentlyGivenByUser($this->user->getKey()),
336 'received' => BeatmapDiscussionVote::recentlyReceivedByUser($this->user->getKey()),
337 ];
338 } else {
339 return [
340 'given' => collect(),
341 'received' => collect(),
342 ];
343 }
344 });
345 }
346}