the browser-facing portion of osu!
at master 24 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\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}