diff --git a/app/Console/Commands/CommentsDedupeCommand.php b/app/Console/Commands/CommentsDedupeCommand.php new file mode 100644 index 0000000..00f8a3b --- /dev/null +++ b/app/Console/Commands/CommentsDedupeCommand.php @@ -0,0 +1,122 @@ +option('window')); + $dryRun = (bool) $this->option('dry-run'); + + // Fetch (user_id, video_id, parent_id, body) groups that appear >1 time. + // We ignore the window at the SQL layer and apply it per-group in PHP: + // that avoids a correlated subquery and is fast enough for a one-shot + // cleanup. For very large tables, chunk by user_id. + $groups = DB::table('comments') + ->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt') + ->groupBy('user_id', 'video_id', 'parent_id', 'body') + ->havingRaw('COUNT(*) > 1') + ->get(); + + if ($groups->isEmpty()) { + $this->info('No duplicate (user, video, parent, body) groups found.'); + return self::SUCCESS; + } + + $totalRemoved = 0; + $groupsTouched = 0; + + foreach ($groups as $g) { + $rows = Comment::where('user_id', $g->user_id) + ->where('video_id', $g->video_id) + ->where(function ($q) use ($g) { + $g->parent_id === null + ? $q->whereNull('parent_id') + : $q->where('parent_id', $g->parent_id); + }) + ->where('body', $g->body) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + if ($rows->count() < 2) { + continue; + } + + $keeper = $rows->first(); + $cluster = collect([$keeper]); + $previous = $keeper; + + foreach ($rows->slice(1) as $row) { + // Same cluster if row is within window of the previous row — + // which is the behavior the server guard enforces today. + if ($row->created_at->diffInSeconds($previous->created_at) <= $window) { + $cluster->push($row); + $previous = $row; + } + } + + if ($cluster->count() < 2) { + continue; + } + + $toDelete = $cluster->skip(1); + $groupsTouched++; + $totalRemoved += $toDelete->count(); + + $this->line(sprintf( + ' user=%d video=%d parent=%s keep=%d remove=%s body="%s"', + $g->user_id, + $g->video_id, + $g->parent_id ?? 'null', + $keeper->id, + $toDelete->pluck('id')->join(','), + mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'), + )); + + if ($dryRun) { + continue; + } + + DB::transaction(function () use ($keeper, $toDelete) { + $ids = $toDelete->pluck('id')->all(); + + // Reparent any replies that were nested under a doomed row. + Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]); + + // Move likes to the keeper, dropping dupes. + DB::table('comment_likes') + ->whereIn('comment_id', $ids) + ->update(['comment_id' => $keeper->id]); + + Comment::whereIn('id', $ids)->delete(); + }); + } + + $this->info(sprintf( + '%s %d duplicate rows across %d groups.', + $dryRun ? 'Would remove' : 'Removed', + $totalRemoved, + $groupsTouched, + )); + + return self::SUCCESS; + } +}