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\Models;
7
8use App\Jobs\RefreshBeatmapsetUserKudosu;
9use App\Traits\Validatable;
10use Cache;
11use Carbon\Carbon;
12use DB;
13use Exception;
14
15/**
16 * @property \Illuminate\Database\Eloquent\Collection $beatmapDiscussionPosts BeatmapDiscussionPost
17 * @property \Illuminate\Database\Eloquent\Collection $beatmapDiscussionVotes BeatmapDiscussionVote
18 * @property int|null $beatmap_id
19 * @property int $beatmapset_id
20 * @property Beatmapset $beatmapset
21 * @property \Carbon\Carbon|null $created_at
22 * @property \Carbon\Carbon|null $deleted_at
23 * @property int|null $deleted_by_id
24 * @property int $id
25 * @property KudosuHistory $kudosuHistory
26 * @property bool $kudosu_denied
27 * @property int|null $kudosu_denied_by_id
28 * @property int|null $message_type
29 * @property bool $resolved
30 * @property int|null $resolver_id
31 * @property BeatmapDiscussionPost $startingPost
32 * @property int|null $timestamp
33 * @property \Carbon\Carbon|null $updated_at
34 * @property User $user
35 * @property int|null $user_id
36 * @property Beatmap $visibleBeatmap
37 * @property Beatmapset $visibleBeatmapset
38 */
39class BeatmapDiscussion extends Model
40{
41 use Validatable;
42
43 protected $attributes = [
44 'resolved' => false,
45 ];
46
47 protected $casts = [
48 'deleted_at' => 'datetime',
49 'kudosu_denied' => 'boolean',
50 'last_post_at' => 'datetime',
51 'resolved' => 'boolean',
52 ];
53
54 const KUDOSU_STEPS = [1, 2, 5];
55
56 const MESSAGE_TYPES = [
57 'suggestion' => 1,
58 'problem' => 2,
59 'mapper_note' => 3,
60 'praise' => 0,
61 'hype' => 4,
62 'review' => 5,
63 ];
64
65 const RESOLVABLE_TYPES = [1, 2];
66 const KUDOSUABLE_TYPES = [1, 2];
67
68 const VALID_BEATMAPSET_STATUSES = ['ranked', 'qualified', 'disqualified', 'never_qualified'];
69 const VOTES_TO_SHOW = 50;
70
71 // FIXME: This and other static search functions should be extracted out.
72 public static function search($rawParams = [])
73 {
74 [$query, $params] = static::searchQueryAndParams(cursor_from_params($rawParams) ?? $rawParams);
75
76 $isModerator = $rawParams['is_moderator'] ?? false;
77
78 if (present($rawParams['user'] ?? null)) {
79 $params['user'] = $rawParams['user'];
80 $findAll = $isModerator || (($rawParams['current_user_id'] ?? null) === $rawParams['user']);
81 $user = User::lookup($params['user'], null, $findAll);
82
83 if ($user === null) {
84 $query->none();
85 } else {
86 $query->where('user_id', '=', $user->getKey());
87 }
88 } else {
89 $params['user'] = null;
90 }
91
92 if (isset($rawParams['sort'])) {
93 $sort = explode('_', strtolower($rawParams['sort']));
94
95 if (in_array($sort[0] ?? null, ['id'], true)) {
96 $sortField = $sort[0];
97 }
98
99 if (in_array($sort[1] ?? null, ['asc', 'desc'], true)) {
100 $sortOrder = $sort[1];
101 }
102 }
103
104 $sortField ?? ($sortField = 'id');
105 $sortOrder ?? ($sortOrder = 'desc');
106
107 $params['sort'] = "{$sortField}_{$sortOrder}";
108 $query->orderBy($sortField, $sortOrder);
109
110 if (isset($rawParams['message_types'])) {
111 $params['message_types'] = get_arr($rawParams['message_types'], 'get_string');
112
113 $query->ofType($params['message_types']);
114 } else {
115 $params['message_types'] = array_keys(static::MESSAGE_TYPES);
116 }
117
118 $params['beatmapset_status'] = static::getValidBeatmapsetStatus($rawParams['beatmapset_status'] ?? null);
119 if ($params['beatmapset_status']) {
120 $query->whereHas('beatmapset', function ($beatmapsetQuery) use ($params) {
121 $scope = camel_case($params['beatmapset_status']);
122 $beatmapsetQuery->$scope();
123 });
124 }
125
126 $params['beatmapset_id'] = get_int($rawParams['beatmapset_id'] ?? null);
127 if ($params['beatmapset_id'] !== null) {
128 $query->where('beatmapset_id', $params['beatmapset_id']);
129 }
130
131 $params['beatmap_id'] = get_int($rawParams['beatmap_id'] ?? null);
132 if ($params['beatmap_id'] !== null) {
133 $query->where('beatmap_id', $params['beatmap_id']);
134 }
135
136 if (isset($rawParams['mode']) && isset(Beatmap::MODES[$rawParams['mode']])) {
137 $params['mode'] = $rawParams['mode'];
138 $query->forMode($params['mode']);
139 }
140
141 $params['only_unresolved'] = get_bool($rawParams['only_unresolved'] ?? null) ?? false;
142
143 if ($params['only_unresolved']) {
144 $query->openIssues();
145 }
146
147 $params['with_deleted'] = get_bool($rawParams['with_deleted'] ?? null) ?? false;
148
149 if (!$params['with_deleted']) {
150 $query->withoutTrashed();
151 }
152
153 $params['show_review_embeds'] = get_bool($rawParams['show_review_embeds'] ?? null) ?? false;
154 if (!$params['show_review_embeds']) {
155 $query->whereNull('parent_id');
156 }
157
158 return ['query' => $query, 'params' => $params];
159 }
160
161 private static function getValidBeatmapsetStatus($rawParam)
162 {
163 if (in_array($rawParam, static::VALID_BEATMAPSET_STATUSES, true)) {
164 return $rawParam;
165 }
166 }
167
168 public function beatmap()
169 {
170 return $this->visibleBeatmap()->withTrashed();
171 }
172
173 public function visibleBeatmap()
174 {
175 return $this->belongsTo(Beatmap::class, 'beatmap_id');
176 }
177
178 public function beatmapset()
179 {
180 return $this->visibleBeatmapset()->withTrashed();
181 }
182
183 public function visibleBeatmapset()
184 {
185 return $this->belongsTo(Beatmapset::class, 'beatmapset_id');
186 }
187
188 public function beatmapDiscussionPosts()
189 {
190 return $this->hasMany(BeatmapDiscussionPost::class);
191 }
192
193 public function startingPost()
194 {
195 return $this->hasOne(BeatmapDiscussionPost::class)->whereNotExists(function ($query) {
196 $table = (new BeatmapDiscussionPost())->getTable();
197
198 $query->selectRaw(1)
199 ->from(DB::raw("{$table} d"))
200 ->where('beatmap_discussion_id', $this->beatmap_discussion_id)
201 ->whereRaw("d.id < {$table}.id");
202 });
203 }
204
205 public function beatmapDiscussionVotes()
206 {
207 return $this->hasMany(BeatmapDiscussionVote::class);
208 }
209
210 public function user()
211 {
212 return $this->belongsTo(User::class, 'user_id');
213 }
214
215 public function kudosuHistory()
216 {
217 return $this->morphMany(KudosuHistory::class, 'kudosuable');
218 }
219
220 public function getMessageTypeAttribute($value)
221 {
222 return array_search_null(get_int($value), static::MESSAGE_TYPES);
223 }
224
225 public function setMessageTypeAttribute($value)
226 {
227 return $this->attributes['message_type'] = static::MESSAGE_TYPES[$value] ?? null;
228 }
229
230 public function getResolvedAttribute($value)
231 {
232 return $this->canBeResolved() ? (bool) $value : false;
233 }
234
235 public function setResolvedAttribute($value)
236 {
237 if (!$this->canBeResolved()) {
238 $value = false;
239 }
240
241 $this->attributes['resolved'] = $value;
242 }
243
244 public function canBeResolved()
245 {
246 return in_array($this->attributes['message_type'] ?? null, static::RESOLVABLE_TYPES, true);
247 }
248
249 public function canGrantKudosu()
250 {
251 return in_array($this->attributes['message_type'] ?? null, static::KUDOSUABLE_TYPES, true) &&
252 $this->user_id !== $this->beatmapset->user_id &&
253 !$this->trashed() &&
254 !$this->kudosu_denied;
255 }
256
257 public function isProblem()
258 {
259 return $this->message_type === 'problem';
260 }
261
262 public function refreshKudosu($event, $eventExtraData = [])
263 {
264 // remove own votes
265 $this->beatmapDiscussionVotes()->where([
266 'user_id' => $this->user_id,
267 ])->delete();
268
269 // inb4 timing problem
270 $currentVotes = $this->canGrantKudosu() ?
271 (int) $this->beatmapDiscussionVotes()->where('score', '>', 0)->sum('score') :
272 0;
273 // remove kudosu by bots here instead of in canGrantKudosu due to
274 // the function is also called by transformer without user preloaded
275 if ($this->user !== null && $this->user->isBot()) {
276 $currentVotes = 0;
277 }
278 $kudosuGranted = (int) $this->kudosuHistory()->sum('amount');
279 $targetKudosu = 0;
280
281 foreach (static::KUDOSU_STEPS as $step) {
282 if ($currentVotes >= $step) {
283 $targetKudosu++;
284 } else {
285 break;
286 }
287 }
288
289 $beatmapsetKudosuGranted = (int) KudosuHistory
290 ::where('kudosuable_type', $this->getMorphClass())
291 ->whereIn(
292 'kudosuable_id',
293 static::where('kudosu_denied', '=', false)
294 ->where('beatmapset_id', '=', $this->beatmapset_id)
295 ->where('user_id', '=', $this->user_id)
296 ->select('id')
297 )->sum('amount');
298
299 $availableKudosu = $GLOBALS['cfg']['osu']['beatmapset']['discussion_kudosu_per_user'] - $beatmapsetKudosuGranted;
300 $maxChange = $targetKudosu - $kudosuGranted;
301 $change = min($availableKudosu, $maxChange);
302
303 if ($change === 0) {
304 return;
305 }
306
307 // This should only happen when the rule is changed so always assume recalculation.
308 if (abs($change) > 1) {
309 $event = 'recalculate';
310 }
311
312 DB::transaction(function () use ($change, $event, $eventExtraData) {
313 if ($event === 'vote') {
314 if ($change > 0) {
315 $beatmapsetEventType = BeatmapsetEvent::KUDOSU_GAIN;
316 } else {
317 $beatmapsetEventType = BeatmapsetEvent::KUDOSU_LOST;
318 }
319
320 $eventExtraData['votes'] = $this
321 ->beatmapDiscussionVotes
322 ->map
323 ->forEvent();
324 } elseif ($event === 'recalculate') {
325 $beatmapsetEventType = BeatmapsetEvent::KUDOSU_RECALCULATE;
326 }
327
328 if (isset($beatmapsetEventType)) {
329 BeatmapsetEvent::log($beatmapsetEventType, $this->user, $this, $eventExtraData)->saveOrExplode();
330 }
331
332 KudosuHistory::create([
333 'receiver_id' => $this->user->user_id,
334 'amount' => $change,
335 'action' => $change > 0 ? 'give' : 'reset',
336 'date' => Carbon::now(),
337 'kudosuable_type' => $this->getMorphClass(),
338 'kudosuable_id' => $this->id,
339 'details' => [
340 'event' => $event,
341 ],
342 ]);
343
344 $this->user->update([
345 'osu_kudostotal' => DB::raw("osu_kudostotal + {$change}"),
346 'osu_kudosavailable' => DB::raw("osu_kudosavailable + {$change}"),
347 ]);
348 });
349
350 // When user lost kudosu, check if there's extra kudosu available.
351 if ($event !== 'recalculate' && $change < 0) {
352 dispatch(new RefreshBeatmapsetUserKudosu(['beatmapsetId' => $this->beatmapset_id, 'userId' => $this->user_id]));
353 }
354 }
355
356 public function refreshResolved()
357 {
358 $systemPosts = $this
359 ->beatmapDiscussionPosts()
360 ->withoutTrashed()
361 ->where('system', '=', true)
362 ->orderBy('id', 'DESC')
363 ->get();
364
365 foreach ($systemPosts as $post) {
366 if ($post->message['type'] === 'resolved') {
367 return $this->update(['resolved' => $post->message['value']]);
368 }
369 }
370
371 return $this->update(['resolved' => false]);
372 }
373
374 public function refreshTimestampOrExplode()
375 {
376 if ($this->timestamp === null) {
377 return;
378 }
379
380 if ($this->startingPost === null) {
381 return;
382 }
383
384 return $this->fill([
385 'timestamp' => $this->startingPost->timestamp() ?? null,
386 ])->saveOrExplode();
387 }
388
389 public function fixBeatmapsetId()
390 {
391 if (!$this->isDirty('beatmap_id') || $this->beatmap === null) {
392 return;
393 }
394
395 $this->beatmapset_id = $this->beatmap->beatmapset_id;
396 }
397
398 public function validateLockStatus()
399 {
400 static $modifiableWhenLocked = [
401 'deleted_at',
402 'deleted_by_id',
403 'kudosu_denied',
404 'kudosu_denied_by_id',
405 ];
406
407 if (
408 $this->exists &&
409 count(array_diff(array_keys($this->getDirty()), $modifiableWhenLocked)) > 0 &&
410 $this->isLocked()
411 ) {
412 $this->validationErrors()->add('base', '.locked');
413 }
414 }
415
416 public function validateMessageType()
417 {
418 if ($this->message_type === null) {
419 return $this->validationErrors()->add('message_type', 'required');
420 }
421
422 if (!$this->isDirty('message_type')) {
423 return;
424 }
425
426 if ($this->message_type === 'hype') {
427 if ($this->beatmap_id !== null) {
428 $this->validationErrors()->add('message_type', '.hype_requires_null_beatmap');
429 }
430
431 if (!$this->beatmapset->canBeHyped()) {
432 $this->validationErrors()->add('message_type', '.beatmapset_no_hype');
433 }
434
435 $beatmapsetHypeValidate = $this->beatmapset->validateHypeBy($this->user);
436
437 if (!$beatmapsetHypeValidate['result']) {
438 $this->validationErrors()->addTranslated('base', $beatmapsetHypeValidate['message']);
439 }
440 }
441 }
442
443 public function validateParents()
444 {
445 if ($this->beatmap_id !== null && $this->beatmap === null) {
446 $this->validationErrors()->add('beatmap_id', '.invalid_beatmap_id');
447 }
448
449 if ($this->beatmapset_id === null) {
450 $this->validationErrors()->add('beatmapset_id', 'required');
451 } elseif ($this->beatmapset === null) {
452 $this->validationErrors()->add('beatmap_id', '.invalid_beatmapset_id');
453 }
454 }
455
456 public function validateTimestamp()
457 {
458 // skip validation if not changed
459 if (!$this->isDirty('timestamp')) {
460 return;
461 }
462
463 // skip validation if changed timestamp from null to null
464 if ($this->getOriginal('timestamp') === null && $this->timestamp === null) {
465 return;
466 }
467
468 if ($this->beatmap === null) {
469 return $this->validationErrors()->add('beatmap_id', '.beatmap_missing');
470 }
471
472 if ($this->timestamp === null) {
473 $this->validationErrors()->add('timestamp', 'required');
474 }
475
476 if ($this->timestamp < 0) {
477 $this->validationErrors()->add('timestamp', '.timestamp.negative');
478 }
479
480 // FIXME: total_length is only for existing hit objects.
481 // FIXME: The chart in discussion page will need to account this as well.
482 if ($this->timestamp > ($this->beatmap->total_length + 10) * 1000) {
483 $this->validationErrors()->add('timestamp', '.timestamp.exceeds_beatmapset_length');
484 }
485 }
486
487 public function isValid()
488 {
489 $this->validationErrors()->reset();
490
491 $this->validateLockStatus();
492 $this->validateParents();
493 $this->validateMessageType();
494 $this->validateTimestamp();
495
496 return $this->validationErrors()->isEmpty();
497 }
498
499 public function validationErrorsTranslationPrefix(): string
500 {
501 return 'beatmapset_discussion';
502 }
503
504 /*
505 * Also applies to:
506 * - voting
507 * - saving posts (editing, creating)
508 */
509 public function isLocked()
510 {
511 if ($this->trashed()) {
512 return true;
513 }
514
515 if ($this->beatmapset !== null) {
516 if ($this->beatmapset->trashed()) {
517 return true;
518 }
519 }
520
521 if ($this->beatmap_id !== null) {
522 if ($this->beatmap === null || $this->beatmap->trashed()) {
523 return true;
524 }
525 }
526
527 return false;
528 }
529
530 public function votesSummary()
531 {
532 $votes = [
533 'up' => 0,
534 'down' => 0,
535 'voters' => [
536 'up' => [],
537 'down' => [],
538 ],
539 ];
540
541 foreach ($this->beatmapDiscussionVotes->sortByDesc('created_at') as $vote) {
542 if ($vote->score === 0) {
543 continue;
544 }
545
546 $direction = $vote->score > 0 ? 'up' : 'down';
547
548 if ($votes[$direction] < static::VOTES_TO_SHOW) {
549 $votes['voters'][$direction][] = $vote->user_id;
550 }
551 $votes[$direction] += 1;
552 }
553
554 return $votes;
555 }
556
557 public function vote($params)
558 {
559 if ($this->isLocked()) {
560 return false;
561 }
562
563 return DB::transaction(function () use ($params) {
564 $vote = $this->beatmapDiscussionVotes()->where(['user_id' => $params['user_id']])->firstOrNew([]);
565 $previousScore = $vote->score ?? 0;
566 $vote->fill($params);
567
568 if ($previousScore !== $vote->score) {
569 if ($vote->score === 0) {
570 $vote->delete();
571 } else {
572 try {
573 $vote->save();
574 } catch (Exception $e) {
575 if (is_sql_unique_exception($e)) {
576 // abort and pretend it's saved correctly
577 return true;
578 }
579
580 throw $e;
581 }
582 }
583
584 $this->userRecentVotesCount($vote->user, true);
585 $this->refreshKudosu('vote', ['new_vote' => $vote->forEvent()]);
586 }
587
588 return true;
589 });
590 }
591
592 public function title()
593 {
594 if ($this->beatmapset !== null) {
595 if ($this->beatmap_id === null) {
596 return $this->beatmapset->title;
597 }
598
599 if ($this->beatmap !== null) {
600 return "{$this->beatmapset->title} [{$this->beatmap->version}]";
601 }
602 }
603
604 return '[deleted beatmap]';
605 }
606
607 public function url()
608 {
609 return route('beatmapsets.discussions.show', $this->id);
610 }
611
612 public function allowKudosu($allowedBy)
613 {
614 DB::transaction(function () use ($allowedBy) {
615 BeatmapsetEvent::log(BeatmapsetEvent::KUDOSU_ALLOW, $allowedBy, $this)->saveOrExplode();
616 $this->fill(['kudosu_denied' => false])->saveOrExplode();
617 $this->refreshKudosu('allow_kudosu');
618 });
619 }
620
621 public function denyKudosu($deniedBy)
622 {
623 DB::transaction(function () use ($deniedBy) {
624 BeatmapsetEvent::log(BeatmapsetEvent::KUDOSU_DENY, $deniedBy, $this)->saveOrExplode();
625 $this->fill([
626 'kudosu_denied_by_id' => $deniedBy->user_id ?? null,
627 'kudosu_denied' => true,
628 ])->saveOrExplode();
629 $this->refreshKudosu('deny_kudosu');
630 });
631 }
632
633 public function managedBy(User $user): bool
634 {
635 return $this->beatmapset->user_id === $user->getKey()
636 || ($this->beatmap !== null && $this->beatmap->isOwner($user));
637 }
638
639 public function userRecentVotesCount($user, $increment = false)
640 {
641 $key = "beatmapDiscussion:{$this->getKey()}:votes:{$user->getKey()}";
642
643 if ($increment) {
644 Cache::add($key, 0, 3600);
645
646 return Cache::increment($key);
647 } else {
648 return get_int(Cache::get($key)) ?? 0;
649 }
650 }
651
652 public function restore($restoredBy)
653 {
654 DB::transaction(function () use ($restoredBy) {
655 if ($restoredBy->getKey() !== $this->user_id) {
656 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_RESTORE, $restoredBy, $this)->saveOrExplode();
657 }
658
659 $this->beatmapDiscussionPosts()->where('deleted_at', $this->deleted_at)->update(['deleted_at' => null]);
660 $this->update(['deleted_at' => null]);
661 $this->refreshKudosu('restore');
662 });
663 }
664
665 public function save(array $options = [])
666 {
667 $this->fixBeatmapsetId();
668
669 if (!$this->isValid()) {
670 return false;
671 }
672
673 $ret = parent::save($options);
674 $this->beatmapset->update([
675 'hype' => $this->beatmapset->freshHype(),
676 ]);
677
678 return $ret;
679 }
680
681 public function softDeleteOrExplode($deletedBy)
682 {
683 DB::transaction(function () use ($deletedBy) {
684 if ($deletedBy->getKey() !== $this->user_id) {
685 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_DELETE, $deletedBy, $this)->saveOrExplode();
686 }
687
688 $deleteAttributes = [
689 'deleted_by_id' => $deletedBy->user_id ?? null,
690 'deleted_at' => Carbon::now(),
691 ];
692 $this->fill($deleteAttributes)->saveOrExplode();
693 $this->beatmapDiscussionPosts()->whereNull('deleted_at')->update($deleteAttributes);
694 $this->refreshKudosu('delete');
695 });
696 }
697
698 public function trashed()
699 {
700 return $this->deleted_at !== null;
701 }
702
703 /**
704 * Filter based on mode
705 *
706 * Either:
707 * - null beatmap_id (general all) which beatmapset contain beatmap of $mode
708 * - beatmap_id which beatmap of $mode
709 */
710 public function scopeForMode($query, string $modeStr)
711 {
712 $modeInt = Beatmap::MODES[$modeStr];
713
714 $query->where(function ($q) use ($modeInt) {
715 return $q
716 ->where(function ($qq) use ($modeInt) {
717 return $qq
718 ->whereNull('beatmap_id')
719 ->whereHas('visibleBeatmapset', function ($beatmapsetQuery) use ($modeInt) {
720 return $beatmapsetQuery->hasMode($modeInt);
721 });
722 })
723 ->orWhereHas('visibleBeatmap', function ($beatmapQuery) use ($modeInt) {
724 $beatmapQuery->where('playmode', $modeInt);
725 });
726 });
727 }
728
729 public function scopeOfType($query, $types)
730 {
731 foreach ((array) $types as $type) {
732 $intType = static::MESSAGE_TYPES[$type] ?? null;
733
734 if ($intType !== null) {
735 $intTypes[] = $intType;
736 }
737 }
738
739 return $query->whereIn('message_type', $intTypes ?? []);
740 }
741
742 public function scopeOpenIssues($query)
743 {
744 return $query
745 ->visible()
746 ->whereIn('message_type', static::RESOLVABLE_TYPES)
747 ->where(['resolved' => false]);
748 }
749
750 public function scopeOpenProblems($query)
751 {
752 return $query
753 ->visible()
754 ->ofType('problem')
755 ->where(['resolved' => false]);
756 }
757
758 public function scopeWithoutTrashed($query)
759 {
760 return $query->whereNull('deleted_at');
761 }
762
763 public function scopeVisible($query)
764 {
765 return $query->visibleWithTrashed()
766 ->withoutTrashed();
767 }
768
769 public function scopeVisibleWithTrashed($query)
770 {
771 return $query->whereHas('visibleBeatmapset')
772 ->where(function ($q) {
773 $q->whereNull('beatmap_id')
774 ->orWhereHas('visibleBeatmap');
775 });
776 }
777}