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'); } /** * People who follow $userId (user_id = the profile owner being followed). * * @return LengthAwarePaginator> */ public function paginatedFollowers(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator { return $this->paginateFollowRelationship( ownerColumn: 'uf.user_id', relatedUserColumn: 'uf.follower_id', ownerId: $userId, perPage: $perPage, page: $page, ); } /** * People $userId follows (follower_id = the profile owner). * * @return LengthAwarePaginator> */ public function paginatedFollowing(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator { return $this->paginateFollowRelationship( ownerColumn: 'uf.follower_id', relatedUserColumn: 'uf.user_id', ownerId: $userId, perPage: $perPage, page: $page, ); } 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 ───────────────────────────────────────────────────── /** * @return LengthAwarePaginator> */ private function paginateFollowRelationship( string $ownerColumn, string $relatedUserColumn, int $ownerId, int $perPage, int $page, ): LengthAwarePaginator { $perPage = max(1, min($perPage, 100)); $page = max(1, $page); return DB::table('user_followers as uf') ->join('users as u', 'u.id', '=', $relatedUserColumn) ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where($ownerColumn, $ownerId) ->whereNull('u.deleted_at') ->orderByDesc('uf.created_at') ->select([ 'u.id', 'u.username', 'u.name', 'u.level', 'u.rank', 'up.avatar_hash', 'uf.created_at as followed_at', ]) ->paginate($perPage, ['*'], 'page', $page) ->through(fn ($row) => $this->mapPublicFollowUser($row)); } /** * @return array */ private function mapPublicFollowUser(object $row): array { $username = (string) ($row->username ?? ''); return [ 'id' => (int) $row->id, 'username' => $username, 'name' => (string) ($row->name ?? $username), 'display_name' => $username !== '' ? $username : (string) ($row->name ?? ''), 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 50), 'profile_url' => '/@' . strtolower($username !== '' ? $username : (string) $row->id), 'followed_at' => $row->followed_at, 'level' => (int) ($row->level ?? 1), 'rank' => (string) ($row->rank ?? 'Newbie'), ]; } 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(); } }