the browser-facing portion of osu!
at master 238 lines 8.4 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\BeatmapsetDiscussion; 7 8use App\Exceptions\InvariantException; 9use App\Jobs\Notifications\BeatmapsetDiscussionReviewNew; 10use App\Libraries\BeatmapsetDiscussion\Traits\HandlesProblem; 11use App\Models\BeatmapDiscussion; 12use App\Models\BeatmapDiscussionPost; 13use App\Models\Beatmapset; 14use App\Models\User; 15 16class Review 17{ 18 use HandlesProblem; 19 20 const BLOCK_TEXT_LENGTH_LIMIT = 750; 21 22 private bool $isUpdate; 23 24 private function __construct( 25 private Beatmapset $beatmapset, 26 private User $user, 27 private array $document, 28 private ?BeatmapDiscussion $discussion = null 29 ) { 30 if (empty($document)) { 31 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.invalid_document')); 32 } 33 34 $this->isUpdate = $this->discussion !== null; 35 } 36 37 public static function config() 38 { 39 return [ 40 'max_blocks' => $GLOBALS['cfg']['osu']['beatmapset']['discussion_review_max_blocks'], 41 ]; 42 } 43 44 public static function create(Beatmapset $beatmapset, array $document, User $user) 45 { 46 return (new static($beatmapset, $user, $document))->process(); 47 } 48 49 public static function getStats(array $document) 50 { 51 $stats = [ 52 'praises' => 0, 53 'suggestions' => 0, 54 'problems' => 0, 55 ]; 56 $embedIds = []; 57 58 foreach ($document as $block) { 59 if ($block['type'] === 'embed') { 60 $embedIds[] = $block['discussion_id']; 61 } 62 } 63 64 $embeds = BeatmapDiscussion::whereIn('id', $embedIds)->get(); 65 foreach ($embeds as $embed) { 66 switch ($embed->message_type) { 67 case 'praise': 68 $stats['praises']++; 69 break; 70 71 case 'suggestion': 72 $stats['suggestions']++; 73 break; 74 75 case 'problem': 76 $stats['problems']++; 77 break; 78 } 79 } 80 81 return $stats; 82 } 83 84 public static function update(BeatmapDiscussion $discussion, array $document, User $user) 85 { 86 // fail if updating on deleted beatmapset. 87 $beatmapset = Beatmapset::findOrFail($discussion->beatmapset_id); 88 89 return (new static($beatmapset, $user, $document, $discussion))->process(); 90 } 91 92 private function createDiscussion(string $discussionType, string $message, int $beatmapId = null, string $timestamp = null) 93 { 94 $userId = $this->user->getKey(); 95 96 $newDiscussion = new BeatmapDiscussion([ 97 'beatmapset_id' => $this->beatmapset->getKey(), 98 'user_id' => $userId, 99 'resolved' => false, 100 'message_type' => $discussionType, 101 'timestamp' => $timestamp, 102 'beatmap_id' => $beatmapId, 103 ]); 104 105 $this->maybeSetProblemDiscussion($newDiscussion); 106 107 $newDiscussion->saveOrExplode(); 108 109 $postParams = [ 110 'user_id' => $userId, 111 'message' => $message, 112 ]; 113 $newPost = new BeatmapDiscussionPost($postParams); 114 $newPost->beatmapDiscussion()->associate($newDiscussion); 115 $newPost->saveOrExplode(); 116 117 return $newDiscussion; 118 } 119 120 private function parseBlock($block) 121 { 122 if (!isset($block['type'])) { 123 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.invalid_block_type')); 124 } 125 126 $message = get_string($block['text'] ?? null); 127 // message check can be skipped for updates if block is embed and has discussion_id set. 128 if ($message === null && !($this->isUpdate && $block['type'] === 'embed' && isset($block['discussion_id']))) { 129 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.missing_text')); 130 } 131 132 switch ($block['type']) { 133 case 'embed': 134 if ($this->isUpdate && isset($block['discussion_id'])) { 135 $childId = $block['discussion_id']; 136 } else { 137 if (!isset($block['discussion_type'])) { 138 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.invalid_discussion_type')); 139 } 140 141 $embeddedDiscussion = $this->createDiscussion( 142 $block['discussion_type'], 143 $message, 144 $block['beatmap_id'] ?? null, 145 $block['timestamp'] ?? null 146 ); 147 148 $childId = $embeddedDiscussion->getKey(); 149 } 150 151 return [ 152 'type' => 'embed', 153 'discussion_id' => $childId, 154 ]; 155 156 case 'paragraph': 157 if (mb_strlen($block['text']) > static::BLOCK_TEXT_LENGTH_LIMIT) { 158 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.block_too_large', ['limit' => static::BLOCK_TEXT_LENGTH_LIMIT])); 159 } 160 return [ 161 'type' => 'paragraph', 162 'text' => $block['text'], 163 ]; 164 165 default: 166 // invalid block type 167 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.invalid_block_type')); 168 } 169 } 170 171 private function parseDocument() 172 { 173 $output = []; 174 // create the issues for the embeds first 175 foreach ($this->document as $block) { 176 $output[] = $this->parseBlock($block); 177 } 178 179 $childIds = array_values(array_filter(array_pluck($output, 'discussion_id'))); 180 181 $minIssues = $GLOBALS['cfg']['osu']['beatmapset']['discussion_review_min_issues']; 182 if (empty($childIds) || count($childIds) < $minIssues) { 183 throw new InvariantException(osu_trans_choice('beatmap_discussions.review.validation.minimum_issues', $minIssues)); 184 } 185 186 $maxBlocks = $GLOBALS['cfg']['osu']['beatmapset']['discussion_review_max_blocks']; 187 $blockCount = count($this->document); 188 if ($blockCount > $maxBlocks) { 189 throw new InvariantException(osu_trans_choice('beatmap_discussions.review.validation.too_many_blocks', $maxBlocks)); 190 } 191 192 return [$output, $childIds]; 193 } 194 195 private function process() 196 { 197 $this->beatmapset->getConnection()->transaction(function () { 198 [$output, $childIds] = $this->parseDocument(); 199 200 if (!$this->isUpdate) { 201 $this->discussion = $this->createDiscussion( 202 'review', 203 json_encode($output) 204 ); 205 } else { 206 // ensure all referenced embeds belong to this discussion 207 $externalEmbeds = BeatmapDiscussion::whereIn('id', $childIds)->where('parent_id', '<>', $this->discussion->getKey())->count(); 208 if ($externalEmbeds > 0) { 209 throw new InvariantException(osu_trans('beatmap_discussions.review.validation.external_references')); 210 } 211 212 // update the review post 213 $post = $this->discussion->startingPost; 214 $post['message'] = json_encode($output); 215 $post['last_editor_id'] = $this->user->getKey(); 216 $post->saveOrExplode(); 217 218 // unlink any embeds that were removed from the review 219 BeatmapDiscussion::where('parent_id', $this->discussion->getKey()) 220 ->whereNotIn('id', $childIds) 221 ->update(['parent_id' => null]); 222 } 223 224 // associate children with parent 225 BeatmapDiscussion::whereIn('id', $childIds) 226 ->update(['parent_id' => $this->discussion->getKey()]); 227 228 $this->handleProblemDiscussion(); 229 230 if (!$this->isUpdate) { 231 // TODO: make transactional 232 (new BeatmapsetDiscussionReviewNew($this->discussion, $this->user))->dispatch(); 233 } 234 }); 235 236 return $this->discussion; 237 } 238}