Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
412 lines
14 KiB
PHP
412 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\User;
|
||
use App\Services\Activity\UserActivityService;
|
||
use App\Events\Achievements\AchievementCheckRequested;
|
||
use App\Services\FollowAnalyticsService;
|
||
use App\Support\AvatarUrl;
|
||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* FollowService
|
||
*
|
||
* Manages follow / unfollow operations on the user_followers table.
|
||
* Convention:
|
||
* follower_id = the user doing the following
|
||
* user_id = the user being followed
|
||
*
|
||
* Counters in user_statistics are kept in sync atomically inside a transaction.
|
||
*/
|
||
final class FollowService
|
||
{
|
||
public function __construct(
|
||
private readonly XPService $xp,
|
||
private readonly NotificationService $notifications,
|
||
private readonly FollowAnalyticsService $analytics,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* Follow $targetId on behalf of $actorId.
|
||
*
|
||
* @return bool true if a new follow was created, false if already following
|
||
*
|
||
* @throws \InvalidArgumentException if self-follow attempted
|
||
*/
|
||
public function follow(int $actorId, int $targetId): bool
|
||
{
|
||
if ($actorId === $targetId) {
|
||
throw new \InvalidArgumentException('Cannot follow yourself.');
|
||
}
|
||
|
||
$inserted = false;
|
||
|
||
DB::transaction(function () use ($actorId, $targetId, &$inserted) {
|
||
$rows = DB::table('user_followers')->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<int, array<string, mixed>>
|
||
*/
|
||
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<int, array<string, mixed>>
|
||
*/
|
||
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<int, array<string, mixed>>
|
||
*/
|
||
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<string, mixed>
|
||
*/
|
||
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();
|
||
}
|
||
}
|