Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,326 @@
<?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();
}
}