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\Console\Commands;
7
8use App\Models\Forum\Post;
9use App\Models\Forum\Topic;
10use App\Models\User;
11use App\Models\UsernameChangeHistory;
12use Carbon\Carbon;
13use Exception;
14use Illuminate\Console\Command;
15
16class FixUsernameChangeTopicCache extends Command
17{
18 /**
19 * The name and signature of the console command.
20 *
21 * @var string
22 */
23 protected $signature = 'fix:username-change-topic-cache';
24
25 /**
26 * The console command description.
27 *
28 * @var string
29 */
30 protected $description = 'Refresh Topic cache from username changes changing wrong field';
31
32 /**
33 * Execute the console command.
34 *
35 * @return mixed
36 */
37 public function handle()
38 {
39 $continue = $this->confirm('WARNING! This will refresh the cache for forum topics effected by username changes after 2017/08/09');
40
41 if (!$continue) {
42 $this->error('User aborted!');
43 return;
44 }
45
46 $start = time();
47
48 $date = Carbon::parse('2017/08/09');
49 $ids = UsernameChangeHistory::where('timestamp', '>', $date)
50 ->distinct()
51 ->pluck('user_id'); // pluck is faster than select for this.
52
53 $userCount = count($ids);
54 $this->warn("{$userCount} users effected");
55
56 // where the user is the topic creator.
57 $this->info('Getting first poster counts...');
58 $topicsFirstPoster = Topic::withTrashed()->whereIn('topic_poster', $ids);
59 $count = $topicsFirstPoster->count();
60 $this->warn("Found {$count}");
61
62 // topics where they posted in - possible last posts.
63 // select is faster than pluck for the the whereNotIn().
64 $this->info('Getting possible last poster counts...');
65 $topicIds = Post::withTrashed()
66 ->whereIn('poster_id', $ids)
67 ->whereNotIn('topic_id', (clone $topicsFirstPoster)->select('topic_id'))
68 ->distinct()
69 ->pluck('topic_id');
70
71 $count += $topicIds->count();
72 $this->warn("Total {$count}");
73
74 $this->warn((time() - $start).'s to scan.');
75
76 // reconfirm.
77 if (!$this->confirm("This will affect an estimated {$count} Topics.")) {
78 $this->error('User aborted!');
79 return;
80 }
81
82 $start = time();
83 $bar = $this->output->createProgressBar($count);
84
85 $this->chunkAndProcess($topicsFirstPoster->pluck('topic_id')->toArray(), $bar);
86 $this->warn("\n".(time() - $start).'s taken.');
87
88 $this->chunkAndProcess($topicIds->toArray(), $bar);
89 $bar->finish();
90
91 $this->warn("\n".(time() - $start).'s taken.');
92 }
93
94 private function chunkAndProcess($array, $bar)
95 {
96 // This is a lot faster than Laravel's whereIn()->chunk for the number of records here.
97 // chunk() freezes every 1000 records as it queries and offsets.
98 $chunks = array_chunk($array, 1000);
99 foreach ($chunks as $chunk) {
100 $this->processChunk($chunk, $bar);
101 }
102 }
103
104 private function processChunk($chunk, $bar)
105 {
106 $topics = Topic::withTrashed()->whereIn('topic_id', $chunk)->get();
107 foreach ($topics as $topic) {
108 try {
109 if ($topic->topic_poster) {
110 $user = User::withoutGlobalScopes()->select('username')->find($topic->topic_poster);
111 if ($user) {
112 $username = $user->username;
113 if ($topic->topic_first_poster_name !== $username) {
114 $topic->update(['topic_first_poster_name' => $username]);
115 }
116 } else {
117 $this->warn("topic_poster not found for Topic {$topic->topic_id}");
118 }
119 }
120 } catch (Exception $e) {
121 $this->error("Exception caught, Topic {$topic->topic_id}");
122 $this->error($e->getMessage());
123 }
124
125 $bar->advance();
126 }
127 }
128}