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\Exceptions\ModelNotSavedException;
9use App\Traits\Validatable;
10use Carbon\Carbon;
11use DB;
12use Ds\Set;
13
14/**
15 * @property BeatmapDiscussion $beatmapDiscussion
16 * @property int $beatmap_discussion_id
17 * @property \Carbon\Carbon|null $created_at
18 * @property \Carbon\Carbon|null $deleted_at
19 * @property int|null $deleted_by_id
20 * @property int $id
21 * @property int|null $last_editor_id
22 * @property string $message
23 * @property bool $system
24 * @property \Carbon\Carbon|null $updated_at
25 * @property User $user
26 * @property int|null $user_id
27 */
28class BeatmapDiscussionPost extends Model implements Traits\ReportableInterface
29{
30 use Traits\Reportable, Validatable;
31
32 const MESSAGE_LIMIT = 16_000; // column limit for 4 bytes utf8
33 const MESSAGE_LIMIT_TIMELINE = 750;
34
35 protected $touches = ['beatmapDiscussion'];
36
37 protected $casts = [
38 'deleted_at' => 'datetime',
39 'system' => 'boolean',
40 ];
41
42 public static function search($rawParams = [])
43 {
44 [$query, $params] = static::searchQueryAndParams(cursor_from_params($rawParams) ?? $rawParams);
45
46 $isModerator = $rawParams['is_moderator'] ?? false;
47
48 if (isset($rawParams['user'])) {
49 $params['user'] = $rawParams['user'];
50 $findAll = $isModerator || (($rawParams['current_user_id'] ?? null) === $rawParams['user']);
51 $user = User::lookup($params['user'], null, $findAll);
52
53 if ($user === null) {
54 $query->none();
55 } else {
56 $query->where('user_id', $user->getKey());
57 }
58 }
59
60 $types = (new Set(get_arr($rawParams['types'] ?? null, 'get_string') ?? []))
61 ->intersect(new Set(['first', 'reply', 'system']));
62
63 if ($types->isEmpty()) {
64 $types->add('reply');
65 }
66
67 $query->byTypes($types);
68 $params['types'] = $types->toArray();
69
70 if (isset($rawParams['sort'])) {
71 $sort = explode('_', strtolower($rawParams['sort']));
72
73 if (in_array($sort[0] ?? null, ['id'], true)) {
74 $sortField = $sort[0];
75 }
76
77 if (in_array($sort[1] ?? null, ['asc', 'desc'], true)) {
78 $sortOrder = $sort[1];
79 }
80 }
81
82 $sortField ??= 'id';
83 $sortOrder ??= 'desc';
84
85 $params['sort'] = "{$sortField}_{$sortOrder}";
86 $query->orderBy($sortField, $sortOrder);
87
88 $params['beatmapset_discussion_id'] = get_int($rawParams['beatmapset_discussion_id'] ?? null);
89 if ($params['beatmapset_discussion_id'] !== null) {
90 // column name is beatmap_ =)
91 $query->where('beatmap_discussion_id', $params['beatmapset_discussion_id']);
92 }
93
94 $params['with_deleted'] = get_bool($rawParams['with_deleted'] ?? null) ?? false;
95
96 if (!$params['with_deleted']) {
97 // $query->visible() may be slow for listing; calls visibleBeatmapDiscussion which calls more scopes...
98 $query->withoutTrashed();
99 }
100
101 // TODO: normalize with main beatmapset discussion behaviour (needs React-side fixing)
102 if (!isset($params['user']) && !$isModerator) {
103 $query->whereHas('user', function ($userQuery) {
104 $userQuery->default();
105 });
106 }
107
108 return ['query' => $query, 'params' => $params];
109 }
110
111 public static function generateLogResolveChange($user, $resolved)
112 {
113 return new static([
114 'user_id' => $user->user_id,
115 'system' => true,
116 'message' => [
117 'type' => 'resolved',
118 'value' => $resolved,
119 ],
120 ]);
121 }
122
123 public static function parseTimestamp($message)
124 {
125 preg_match('/\b(\d{2,}):([0-5]\d)[:.](\d{3})\b/', $message, $matches);
126
127 if (count($matches) === 4) {
128 $m = (int) $matches[1];
129 $s = (int) $matches[2];
130 $ms = (int) $matches[3];
131
132 return ($m * 60 + $s) * 1000 + $ms;
133 }
134 }
135
136 public function beatmapset()
137 {
138 return $this->hasOneThrough(
139 Beatmapset::class,
140 BeatmapDiscussion::class,
141 'id',
142 'beatmapset_id',
143 'beatmap_discussion_id',
144 'beatmapset_id'
145 )->withTrashed();
146 }
147
148 public function beatmapDiscussion()
149 {
150 return $this->belongsTo(BeatmapDiscussion::class);
151 }
152
153 public function visibleBeatmapDiscussion()
154 {
155 return $this->beatmapDiscussion()->visible();
156 }
157
158 public function user()
159 {
160 return $this->belongsTo(User::class, 'user_id');
161 }
162
163 /**
164 * Whether a post can be edited/deleted.
165 *
166 * When a discussion is resolved, the posts preceeding the resolution are locked.
167 * Posts after the resolution are not locked, unless the issue is re-opened and resolved again.
168 *
169 * @return bool
170 */
171 public function canEdit()
172 {
173 if ($this->system) {
174 return false;
175 }
176
177 // The only system post type currently implemented is 'resolved', so we're making the assumption
178 // the next system post is always going to be either a resolve or unresolve.
179 // This will have to be changed if more types are added.
180 $systemPost = static::where('system', true)
181 ->where('id', '>', $this->id)
182 ->where('beatmap_discussion_id', $this->beatmap_discussion_id)
183 ->last();
184
185 return $this->getKey() > optional($systemPost)->getKey();
186 }
187
188 public function validateBeatmapsetDiscussion()
189 {
190 if ($this->beatmapDiscussion === null) {
191 $this->validationErrors()->add('beatmap_discussion_id', 'required');
192
193 return;
194 }
195
196 // only applies on saved posts
197 static $modifiableWhenLocked = [
198 'deleted_at',
199 'deleted_by_id',
200 ];
201
202 if (!$this->exists || count(array_diff(array_keys($this->getDirty()), $modifiableWhenLocked)) > 0) {
203 if ($this->beatmapDiscussion->isLocked()) {
204 $this->validationErrors()->add('beatmap_discussion_id', '.discussion_locked');
205 }
206 }
207 }
208
209 public function isValid()
210 {
211 $this->validationErrors()->reset();
212
213 if ($this->deleted_at !== null && $this->isFirstPost()) {
214 $this->validationErrors()->add('base', '.first_post');
215 }
216
217 $this->validateBeatmapsetDiscussion();
218
219 if (!$this->system) {
220 if (!present($this->message)) {
221 $this->validationErrors()->add('message', 'required');
222 }
223
224 $limit = $this->beatmapDiscussion?->timestamp === null
225 ? static::MESSAGE_LIMIT
226 : static::MESSAGE_LIMIT_TIMELINE;
227 $this->validateDbFieldLength($limit, 'message');
228 }
229
230 return $this->validationErrors()->isEmpty();
231 }
232
233 public function validationErrorsTranslationPrefix(): string
234 {
235 return 'beatmapset_discussion_post';
236 }
237
238 public function save(array $options = [])
239 {
240 if (!$this->isValid()) {
241 return false;
242 }
243
244 $origExists = $this->exists;
245
246 try {
247 return $this->getConnection()->transaction(function () use ($options) {
248 if (!$this->exists) {
249 $this->beatmapDiscussion->update(['last_post_at' => Carbon::now()]);
250 }
251
252 if (!parent::save($options)) {
253 throw new ModelNotSavedException();
254 }
255
256 $this->beatmapDiscussion->refreshTimestampOrExplode();
257
258 return true;
259 });
260 } catch (ModelNotSavedException $_e) {
261 $this->exists = $origExists;
262 $this->validationErrors()->merge($this->beatmapDiscussion->validationErrors());
263
264 return false;
265 }
266 }
267
268 public function getMessageAttribute($value)
269 {
270 if ($this->system) {
271 return json_decode($value, true);
272 } else {
273 return $value;
274 }
275 }
276
277 public function setMessageAttribute($value)
278 {
279 // don't shoot me ;_;
280 if ($this->system || is_array($value)) {
281 $value = json_encode($value);
282 }
283
284 $this->attributes['message'] = trim($value);
285 }
286
287 public function isFirstPost()
288 {
289 return !static
290 ::where('beatmap_discussion_id', $this->beatmap_discussion_id)
291 ->where('id', '<', $this->id)->exists();
292 }
293
294 public function relatedSystemPost()
295 {
296 if ($this->system) {
297 return;
298 }
299
300 $nextPost = static
301 ::where('id', '>', $this->getKey())
302 ->orderBy('id', 'ASC')
303 ->first();
304
305 if ($nextPost !== null && $nextPost->system && $nextPost->user_id === $this->user_id) {
306 return $nextPost;
307 }
308 }
309
310 public function restore($restoredBy)
311 {
312 return DB::transaction(function () use ($restoredBy) {
313 if ($restoredBy->getKey() !== $this->user_id) {
314 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_POST_RESTORE, $restoredBy, $this)->saveOrExplode();
315 }
316
317 // restore related system post
318 $systemPost = $this->relatedSystemPost();
319
320 if ($systemPost !== null) {
321 $systemPost->restore($restoredBy);
322 }
323
324 $this->update(['deleted_at' => null]);
325
326 $this->beatmapDiscussion->refreshResolved();
327
328 return true;
329 });
330 }
331
332 public function softDeleteOrExplode($deletedBy)
333 {
334 DB::transaction(function () use ($deletedBy) {
335 if ($deletedBy->getKey() !== $this->user_id) {
336 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_POST_DELETE, $deletedBy, $this)->saveOrExplode();
337 }
338
339 // delete related system post
340 $systemPost = $this->relatedSystemPost();
341
342 if ($systemPost !== null) {
343 $systemPost->softDeleteOrExplode($deletedBy);
344 }
345
346 $this->fill([
347 'deleted_by_id' => $deletedBy->user_id,
348 'deleted_at' => Carbon::now(),
349 ])->saveOrExplode();
350
351 $this->beatmapDiscussion->refreshResolved();
352 });
353 }
354
355 public function trashed()
356 {
357 return $this->deleted_at !== null;
358 }
359
360 public function timestamp()
361 {
362 return static::parseTimestamp($this->message);
363 }
364
365 public function scopeByTypes($query, Set $types)
366 {
367 $query->where(function ($q) use ($types) {
368 if ($types->contains('system')) {
369 $q->where('system', true);
370 }
371
372 $firstOrReplyCount = $types->intersect(new Set(['first', 'reply']))->count();
373 if ($firstOrReplyCount > 0) {
374 $q->orWhere(function ($replyQuery) use ($firstOrReplyCount, $types) {
375 $replyQuery->where('system', false);
376
377 if ($firstOrReplyCount === 1) {
378 $replyQuery->where(fn ($q) => $q->firstFilter($types->contains('first')));
379 }
380
381 return $replyQuery;
382 });
383 }
384
385 return $q;
386 });
387 }
388
389 public function scopeFirstFilter($query, $isFirst = true)
390 {
391 $table = $this->getTable();
392
393 $condition = $isFirst ? 'whereNotExists' : 'whereExists';
394
395 return $query->$condition(fn ($q) => $q
396 ->selectRaw(1)
397 ->from(DB::raw("{$table} d"))
398 ->whereRaw("d.beatmap_discussion_id = {$table}.beatmap_discussion_id")
399 ->whereRaw("d.id < {$table}.id"));
400 }
401
402 public function scopeWithoutTrashed($query)
403 {
404 $query->whereNull('deleted_at');
405 }
406
407 public function scopeWithoutSystem($query)
408 {
409 $query->where('system', '=', false);
410 }
411
412 public function scopeVisible($query)
413 {
414 $query->withoutTrashed()
415 ->whereHas('visibleBeatmapDiscussion');
416 }
417
418 public function url()
419 {
420 return route('beatmapsets.discussions.posts.show', $this->getKey());
421 }
422
423 protected function newReportableExtraParams(): array
424 {
425 return [
426 'reason' => 'Spam',
427 'user_id' => $this->user_id,
428 ];
429 }
430}