Files
SkinbaseNova/app/Services/FollowService.php
2026-03-28 19:15:39 +01:00

327 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\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');
}
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();
}
}