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}