diff --git a/app/Console/Commands/FixBugs/FixPostCounts.php b/app/Console/Commands/FixBugs/FixPostCounts.php new file mode 100644 index 000000000..a00cfeb28 --- /dev/null +++ b/app/Console/Commands/FixBugs/FixPostCounts.php @@ -0,0 +1,248 @@ + + */ + protected const METRICS = ['likes', 'boosts', 'comments']; + + /** + * Valid --scope values for --all mode. + * + * @var array + */ + protected const SCOPES = ['local', 'remote', 'both']; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Resync a post\'s cached counts (likes, boosts, comments) from source-of-truth tables. Use --all --scope=local|remote|both, or --active, for bulk reconciliation.'; + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $id = $this->argument('id'); + $all = $this->option('all'); + $activeDays = $this->resolveActiveDays(); + $active = $activeDays !== null; + + // Exactly one mode: a single id, --all, or --active. + $modes = (int) (bool) $id + (int) $all + (int) $active; + + if ($modes === 0) { + $this->error('Provide a status id, or pass --all or --active.'); + + return 1; + } + + if ($modes > 1) { + $this->error('Pass only one of: , --all, or --active.'); + + return 1; + } + + $type = $this->option('type'); + if ($type !== null && ! in_array($type, self::METRICS, true)) { + $this->error('Invalid --type "'.$type.'". Use one of: '.implode(', ', self::METRICS).'.'); + + return 1; + } + + $scope = $this->option('scope'); + if ($scope !== null && ! in_array($scope, self::SCOPES, true)) { + $this->error('Invalid --scope "'.$scope.'". Use one of: '.implode(', ', self::SCOPES).'.'); + + return 1; + } + + if ($all && $scope === null) { + $this->error('--all requires --scope (local, remote, or both).'); + + return 1; + } + + if ($active && $scope !== null && $scope !== 'local') { + // --active filters on the author's users.last_active_at, which only + // exists for local accounts, so remote/both make no sense here. + $this->error('--active only applies to local accounts; --scope must be omitted or "local".'); + + return 1; + } + + if ($id) { + $status = Status::find($id); + + if (! $status) { + $this->error('No status found for id "'.$id.'".'); + + return 1; + } + + $this->resyncOne($status); + + return 0; + } + + // Bulk mode (--all or --active): scan and only touch drifted statuses. + $dryRun = $this->option('dry-run'); + + $scopeLabel = $active + ? 'posts by local accounts active in the last '.$activeDays.' days' + : $scope.' statuses'; + + if (! $dryRun && ! $this->option('force') && ! $this->confirm('Resync cached counts for '.$scopeLabel.'?', true)) { + $this->comment('Aborted.'); + + return 0; + } + + $query = Status::whereNull('deleted_at'); + + if ($active) { + // Restrict to statuses authored by LOCAL profiles whose linked user + // logged in recently. Remote posts have no local user, so they are + // excluded here. + $cutoff = now()->subDays($activeDays); + $query->whereLocal(true) + ->whereHas('profile.user', function ($q) use ($cutoff) { + $q->whereNotNull('last_active_at') + ->where('last_active_at', '>=', $cutoff); + }); + } elseif ($scope === 'local') { + $query->whereLocal(true); + } elseif ($scope === 'remote') { + $query->whereLocal(false); + } + // scope === 'both' applies no local/remote filter. + + $fixed = 0; + $scanned = 0; + $query->lazyById(500)->each(function ($status) use (&$fixed, &$scanned) { + $scanned++; + if ($this->resyncOne($status)) { + $fixed++; + } + }); + + $this->newLine(); + $this->info('Scanned '.$scanned.' statuses ('.$scopeLabel.'); '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); + + return 0; + } + + /** + * Resolve the --active window in days, or null if the flag was not passed. + */ + protected function resolveActiveDays(): ?int + { + $values = (array) $this->option('active'); + + if (empty($values)) { + return null; + } + + $last = end($values); + + // `--active` with no value arrives as an empty string / null. + if ($last === null || $last === '') { + return self::DEFAULT_ACTIVE_DAYS; + } + + $days = (int) $last; + + return $days > 0 ? $days : self::DEFAULT_ACTIVE_DAYS; + } + + /** + * Recompute (or report) the cached counts for a single status. + * Only emits output when drift is detected; silent otherwise. + * + * @return bool whether the status was drifted + */ + protected function resyncOne(Status $status): bool + { + // Which metrics to consider: a single --type, or all three. + $type = $this->option('type'); + $metrics = $type !== null ? [$type] : self::METRICS; + + // Compute drift only for the selected metrics, using the canonical + // source-of-truth logic shared with StatusService. + $drift = []; + if (in_array('likes', $metrics, true)) { + $drift['likes'] = [ + 'cached' => (int) $status->likes_count, + 'live' => StatusService::recalculateLikeCount($status->id), + ]; + } + if (in_array('boosts', $metrics, true)) { + $drift['boosts'] = [ + 'cached' => (int) $status->reblogs_count, + 'live' => StatusService::recalculateReblogCount($status->id), + ]; + } + if (in_array('comments', $metrics, true)) { + $drift['comments'] = [ + 'cached' => (int) $status->reply_count, + 'live' => StatusService::recalculateReplyCount($status->id), + ]; + } + + $drifted = array_filter($drift, fn ($m) => $m['cached'] !== $m['live']); + + if (empty($drifted)) { + // No drift on the selected metrics: stay silent. + return false; + } + + // Drift detected: report exactly what drifted. + $this->warn('status id '.$status->id.' drift detected:'); + foreach ($drifted as $metric => $m) { + $this->line(' '.str_pad($metric.':', 11).'cached='.$m['cached'].' live='.$m['live']); + } + + if ($this->option('dry-run')) { + return true; + } + + // Inline recompute of the selected metrics via the shared reconciler. + StatusService::reconcileStatusCounts($status, $metrics); + $status->refresh(); + $this->info(' resynced to likes='.$status->likes_count.', boosts='.$status->reblogs_count.', comments='.$status->reply_count.'.'); + + return true; + } +} diff --git a/app/Console/Commands/README.md b/app/Console/Commands/README.md index 4ce9dec65..7665c7638 100644 --- a/app/Console/Commands/README.md +++ b/app/Console/Commands/README.md @@ -69,6 +69,7 @@ full argument and option list. | `media:fix-nonlocal-driver` | Repair filesystem records when `FILESYSTEM_DRIVER` is not set to local. | | `app:fix-missing-user-profile` | Interactively create a missing profile for an affected user. | | `admin:fixProfileCounts` | Resync a profile's cached counts (followers, following, statuses) from source tables; supports bulk `--all`/`--active`. | +| `admin:fixPostCounts` | Resync a post's cached counts (likes, boosts, comments) from source tables; supports bulk `--all`/`--active`. | | `fix:usernames` | Fix invalid usernames. | | `app:hashtag-related-generate` | Generate related-hashtag data for a given tag. | | `media:fix` | Null out media `filter_class` values no longer present in `Filter::classes()`. Still relevant: image filters remain an active feature. | diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 0ccfaf56f..4e6330d57 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Like; use App\Models\Status; use App\Transformer\Api\StatusStatelessTransformer; use Illuminate\Support\Facades\Cache; @@ -394,4 +395,92 @@ class StatusService return true; } + + /** + * Canonical source-of-truth like count for a status. + */ + public static function recalculateLikeCount($id): int + { + return (int) Like::whereStatusId($id)->count(); + } + + /** + * Canonical source-of-truth boost/reblog count for a status. + */ + public static function recalculateReblogCount($id): int + { + return (int) Status::whereReblogOfId($id)->count(); + } + + /** + * Canonical source-of-truth reply/comment count for a status. + */ + public static function recalculateReplyCount($id): int + { + return (int) Status::whereInReplyToId($id)->count(); + } + + /** + * Reconcile a status's cached count columns (likes_count, reblogs_count, + * reply_count) against source-of-truth tables. Only writes and busts the + * cache when a column actually drifted. + * + * @param array $only Restrict to a subset of + * ['likes','boosts','comments']. + * @return array + * Per-metric before/after summary. + */ + public static function reconcileStatusCounts($status, array $only = ['likes', 'boosts', 'comments']): array + { + if (! $status instanceof Status) { + $status = Status::find($status); + } + + if (! $status) { + return []; + } + + $summary = []; + $changed = false; + + if (in_array('likes', $only, true)) { + $cached = (int) $status->likes_count; + $live = self::recalculateLikeCount($status->id); + $drift = $cached !== $live; + if ($drift) { + $status->likes_count = $live; + $changed = true; + } + $summary['likes'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if (in_array('boosts', $only, true)) { + $cached = (int) $status->reblogs_count; + $live = self::recalculateReblogCount($status->id); + $drift = $cached !== $live; + if ($drift) { + $status->reblogs_count = $live; + $changed = true; + } + $summary['boosts'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if (in_array('comments', $only, true)) { + $cached = (int) $status->reply_count; + $live = self::recalculateReplyCount($status->id); + $drift = $cached !== $live; + if ($drift) { + $status->reply_count = $live; + $changed = true; + } + $summary['comments'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if ($changed) { + $status->save(); + self::del($status->id, true); + } + + return $summary; + } }