insertOrIgnore([ 'user_id' => $targetId, 'follower_id' => $actorId, 'created_at' => now(), ]); if ($rows === 0) { // Already following – nothing to do return; } $inserted = true; // Increment following_count for actor, followers_count for target $this->incrementCounter($actorId, 'following_count'); $this->incrementCounter($targetId, 'followers_count'); }); // Record activity event outside the transaction to avoid deadlocks if ($inserted) { try { \App\Models\ActivityEvent::record( actorId: $actorId, type: \App\Models\ActivityEvent::TYPE_FOLLOW, targetType: \App\Models\ActivityEvent::TARGET_USER, targetId: $targetId, ); } catch (\Throwable) {} try { app(UserActivityService::class)->logFollow($actorId, $targetId); } catch (\Throwable) {} $targetUser = User::query()->find($targetId); $actorUser = User::query()->find($actorId); if ($targetUser && $actorUser) { $this->notifications->notifyUserFollowed($targetUser->loadMissing('profile'), $actorUser->loadMissing('profile')); } $this->analytics->recordFollow($actorId, $targetId); $this->xp->awardFollowerReceived($targetId, $actorId); event(new AchievementCheckRequested($targetId)); } return $inserted; } /** * Unfollow $targetId on behalf of $actorId. * * @return bool true if a follow row was removed, false if wasn't following */ public function unfollow(int $actorId, int $targetId): bool { if ($actorId === $targetId) { return false; } $deleted = false; DB::transaction(function () use ($actorId, $targetId, &$deleted) { $rows = DB::table('user_followers') ->where('user_id', $targetId) ->where('follower_id', $actorId) ->delete(); if ($rows === 0) { return; } $deleted = true; $this->decrementCounter($actorId, 'following_count'); $this->decrementCounter($targetId, 'followers_count'); }); if ($deleted) { $this->analytics->recordUnfollow($actorId, $targetId); } return $deleted; } /** * Toggle follow state. Returns the new following state. */ public function toggle(int $actorId, int $targetId): bool { if ($this->isFollowing($actorId, $targetId)) { $this->unfollow($actorId, $targetId); return false; } $this->follow($actorId, $targetId); return true; } public function isFollowing(int $actorId, int $targetId): bool { return DB::table('user_followers') ->where('user_id', $targetId) ->where('follower_id', $actorId) ->exists(); } /** * Current followers_count for a user (from cached column, not live count). */ public function followersCount(int $userId): int { return (int) DB::table('user_statistics') ->where('user_id', $userId) ->value('followers_count'); } public function followingCount(int $userId): int { return (int) DB::table('user_statistics') ->where('user_id', $userId) ->value('following_count'); } public function getMutualFollowers(int $userA, int $userB, int $limit = 13): array { $rows = DB::table('user_followers as left_follow') ->join('user_followers as right_follow', 'right_follow.follower_id', '=', 'left_follow.follower_id') ->join('users as u', 'u.id', '=', 'left_follow.follower_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where('left_follow.user_id', $userA) ->where('right_follow.user_id', $userB) ->where('left_follow.follower_id', '!=', $userA) ->where('left_follow.follower_id', '!=', $userB) ->whereNull('u.deleted_at') ->where('u.is_active', true) ->orderByDesc('left_follow.created_at') ->limit(max(1, $limit)) ->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash']) ->get(); return $this->mapUsers($rows); } public function relationshipContext(int $viewerId, int $targetId): array { if ($viewerId === $targetId) { return [ 'follower_overlap' => null, 'shared_following' => null, 'mutual_followers' => [ 'count' => 0, 'users' => [], ], ]; } $followerOverlap = $this->buildFollowerOverlapSummary($viewerId, $targetId); $sharedFollowing = $this->buildSharedFollowingSummary($viewerId, $targetId); $mutualFollowers = $this->getMutualFollowers($viewerId, $targetId, 6); return [ 'follower_overlap' => $followerOverlap, 'shared_following' => $sharedFollowing, 'mutual_followers' => [ 'count' => count($mutualFollowers), 'users' => $mutualFollowers, ], ]; } // ─── Private helpers ───────────────────────────────────────────────────── private function incrementCounter(int $userId, string $column): void { DB::table('user_statistics')->updateOrInsert( ['user_id' => $userId], [ $column => DB::raw("COALESCE({$column}, 0) + 1"), 'updated_at' => now(), 'created_at' => now(), // ignored on update ] ); } private function decrementCounter(int $userId, string $column): void { DB::table('user_statistics') ->where('user_id', $userId) ->where($column, '>', 0) ->update([ $column => DB::raw("{$column} - 1"), 'updated_at' => now(), ]); } private function buildFollowerOverlapSummary(int $viewerId, int $targetId): ?array { $preview = DB::table('user_followers as viewer_following') ->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id') ->join('users as u', 'u.id', '=', 'viewer_following.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where('viewer_following.follower_id', $viewerId) ->where('target_followers.user_id', $targetId) ->whereNull('u.deleted_at') ->where('u.is_active', true) ->orderByDesc('target_followers.created_at') ->limit(3) ->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash']) ->get(); if ($preview->isEmpty()) { return null; } $count = DB::table('user_followers as viewer_following') ->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id') ->where('viewer_following.follower_id', $viewerId) ->where('target_followers.user_id', $targetId) ->count(); $lead = $preview->first(); $label = $count > 1 ? sprintf('Followed by %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's') : sprintf('Followed by %s', $lead->username ?? $lead->name ?? 'someone'); return [ 'count' => (int) $count, 'label' => $label, 'users' => $this->mapUsers($preview), ]; } private function buildSharedFollowingSummary(int $viewerId, int $targetId): ?array { $preview = DB::table('user_followers as viewer_following') ->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id') ->join('users as u', 'u.id', '=', 'viewer_following.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where('viewer_following.follower_id', $viewerId) ->where('target_following.follower_id', $targetId) ->whereNull('u.deleted_at') ->where('u.is_active', true) ->orderByDesc('viewer_following.created_at') ->limit(3) ->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash']) ->get(); if ($preview->isEmpty()) { return null; } $count = DB::table('user_followers as viewer_following') ->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id') ->where('viewer_following.follower_id', $viewerId) ->where('target_following.follower_id', $targetId) ->count(); $lead = $preview->first(); $label = $count > 1 ? sprintf('You both follow %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's') : sprintf('You both follow %s', $lead->username ?? $lead->name ?? 'someone'); return [ 'count' => (int) $count, 'label' => $label, 'users' => $this->mapUsers($preview), ]; } private function mapUsers(Collection $rows): array { return $rows->map(fn ($row) => [ 'id' => (int) $row->id, 'username' => (string) ($row->username ?? ''), 'name' => (string) ($row->name ?? $row->username ?? ''), 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 48), ])->values()->all(); } }