update
This commit is contained in:
228
app/Services/AchievementService.php
Normal file
228
app/Services/AchievementService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Notifications\AchievementUnlockedNotification;
|
||||
use App\Services\Posts\PostAchievementService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AchievementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XPService $xp,
|
||||
private readonly PostAchievementService $achievementPosts,
|
||||
) {}
|
||||
|
||||
public function checkAchievements(User|int $user): array
|
||||
{
|
||||
$currentUser = $this->resolveUser($user);
|
||||
$unlocked = [];
|
||||
|
||||
foreach ($this->unlockableDefinitions($currentUser) as $achievement) {
|
||||
if ($this->unlockAchievement($currentUser, $achievement)) {
|
||||
$unlocked[] = $achievement->slug;
|
||||
}
|
||||
}
|
||||
|
||||
$this->forgetSummaryCache((int) $currentUser->id);
|
||||
|
||||
return $unlocked;
|
||||
}
|
||||
|
||||
public function previewUnlocks(User|int $user): array
|
||||
{
|
||||
$currentUser = $this->resolveUser($user);
|
||||
|
||||
return $this->unlockableDefinitions($currentUser)
|
||||
->pluck('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function unlockAchievement(User|int $user, Achievement|int $achievement): bool
|
||||
{
|
||||
$currentUser = $user instanceof User ? $user : User::query()->findOrFail($user);
|
||||
$currentAchievement = $achievement instanceof Achievement
|
||||
? $achievement
|
||||
: Achievement::query()->findOrFail($achievement);
|
||||
|
||||
$inserted = false;
|
||||
|
||||
DB::transaction(function () use ($currentUser, $currentAchievement, &$inserted): void {
|
||||
$result = UserAchievement::query()->insertOrIgnore([
|
||||
'user_id' => (int) $currentUser->id,
|
||||
'achievement_id' => (int) $currentAchievement->id,
|
||||
'unlocked_at' => now(),
|
||||
]);
|
||||
|
||||
if ($result === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inserted = true;
|
||||
});
|
||||
|
||||
if (! $inserted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $currentAchievement->xp_reward > 0) {
|
||||
$this->xp->addXP(
|
||||
(int) $currentUser->id,
|
||||
(int) $currentAchievement->xp_reward,
|
||||
'achievement_unlocked:' . $currentAchievement->slug,
|
||||
(int) $currentAchievement->id,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
$currentUser->notify(new AchievementUnlockedNotification($currentAchievement));
|
||||
$this->achievementPosts->achievementUnlocked($currentUser, $currentAchievement);
|
||||
$this->forgetSummaryCache((int) $currentUser->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function hasAchievement(User|int $user, string $achievementSlug): bool
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
return UserAchievement::query()
|
||||
->where('user_id', $userId)
|
||||
->whereHas('achievement', fn ($query) => $query->where('slug', $achievementSlug))
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function summary(User|int $user): array
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
return Cache::remember($this->summaryCacheKey($userId), now()->addMinutes(10), function () use ($userId): array {
|
||||
$currentUser = User::query()->with('statistics')->findOrFail($userId);
|
||||
$progress = $this->progressSnapshot($currentUser);
|
||||
$unlockedMap = UserAchievement::query()
|
||||
->where('user_id', $userId)
|
||||
->get()
|
||||
->keyBy('achievement_id');
|
||||
|
||||
$items = $this->definitions()->map(function (Achievement $achievement) use ($progress, $unlockedMap): array {
|
||||
$progressValue = $this->progressValue($progress, $achievement);
|
||||
/** @var UserAchievement|null $unlocked */
|
||||
$unlocked = $unlockedMap->get($achievement->id);
|
||||
|
||||
return [
|
||||
'id' => (int) $achievement->id,
|
||||
'name' => $achievement->name,
|
||||
'slug' => $achievement->slug,
|
||||
'description' => $achievement->description,
|
||||
'icon' => $achievement->icon,
|
||||
'xp_reward' => (int) $achievement->xp_reward,
|
||||
'type' => $achievement->type,
|
||||
'condition_type' => $achievement->condition_type,
|
||||
'condition_value' => (int) $achievement->condition_value,
|
||||
'progress' => min((int) $achievement->condition_value, $progressValue),
|
||||
'progress_percent' => $achievement->condition_value > 0
|
||||
? (int) round((min((int) $achievement->condition_value, $progressValue) / (int) $achievement->condition_value) * 100)
|
||||
: 100,
|
||||
'unlocked' => $unlocked !== null,
|
||||
'unlocked_at' => $unlocked?->unlocked_at?->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'unlocked' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->values()->all(),
|
||||
'locked' => $items->where('unlocked', false)->values()->all(),
|
||||
'recent' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->take(4)->values()->all(),
|
||||
'counts' => [
|
||||
'total' => $items->count(),
|
||||
'unlocked' => $items->where('unlocked', true)->count(),
|
||||
'locked' => $items->where('unlocked', false)->count(),
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function definitions()
|
||||
{
|
||||
return Cache::remember('achievements:definitions', now()->addHour(), function () {
|
||||
return Achievement::query()->orderBy('type')->orderBy('condition_value')->get();
|
||||
});
|
||||
}
|
||||
|
||||
public function forgetDefinitionsCache(): void
|
||||
{
|
||||
Cache::forget('achievements:definitions');
|
||||
}
|
||||
|
||||
private function progressValue(array $progress, Achievement $achievement): int
|
||||
{
|
||||
return (int) ($progress[$achievement->condition_type] ?? 0);
|
||||
}
|
||||
|
||||
private function resolveUser(User|int $user): User
|
||||
{
|
||||
return $user instanceof User
|
||||
? $user->loadMissing('statistics')
|
||||
: User::query()->with('statistics')->findOrFail($user);
|
||||
}
|
||||
|
||||
private function unlockableDefinitions(User $user): Collection
|
||||
{
|
||||
$progress = $this->progressSnapshot($user);
|
||||
$unlockedSlugs = $this->unlockedSlugs((int) $user->id);
|
||||
|
||||
return $this->definitions()->filter(function (Achievement $achievement) use ($progress, $unlockedSlugs): bool {
|
||||
if ($this->progressValue($progress, $achievement) < (int) $achievement->condition_value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! isset($unlockedSlugs[$achievement->slug]);
|
||||
})->values();
|
||||
}
|
||||
|
||||
private function progressSnapshot(User $user): array
|
||||
{
|
||||
return [
|
||||
'upload_count' => Artwork::query()
|
||||
->published()
|
||||
->where('user_id', $user->id)
|
||||
->count(),
|
||||
'likes_received' => (int) DB::table('artwork_likes as likes')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->count(),
|
||||
'followers_count' => (int) ($user->statistics?->followers_count ?? $user->followers()->count()),
|
||||
'stories_published' => Story::query()->published()->where('creator_id', $user->id)->count(),
|
||||
'level_reached' => (int) ($user->level ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
private function unlockedSlugs(int $userId): array
|
||||
{
|
||||
return UserAchievement::query()
|
||||
->where('user_id', $userId)
|
||||
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
|
||||
->pluck('achievements.slug')
|
||||
->flip()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function forgetSummaryCache(int $userId): void
|
||||
{
|
||||
Cache::forget($this->summaryCacheKey($userId));
|
||||
}
|
||||
|
||||
private function summaryCacheKey(int $userId): string
|
||||
{
|
||||
return 'achievements:summary:' . $userId;
|
||||
}
|
||||
}
|
||||
34
app/Services/ActivityService.php
Normal file
34
app/Services/ActivityService.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\User;
|
||||
|
||||
final class ActivityService
|
||||
{
|
||||
public function __construct(private readonly CommunityActivityService $communityActivity) {}
|
||||
|
||||
public function record(int $actorId, string $type, string $targetType, int $targetId, array $meta = []): void
|
||||
{
|
||||
ActivityEvent::record(
|
||||
actorId: $actorId,
|
||||
type: $type,
|
||||
targetType: $targetType,
|
||||
targetId: $targetId,
|
||||
meta: $meta,
|
||||
);
|
||||
}
|
||||
|
||||
public function communityFeed(?User $viewer, string $filter = 'all', int $page = 1, int $perPage = CommunityActivityService::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
|
||||
{
|
||||
return $this->communityActivity->getFeed($viewer, $filter, $page, $perPage, $actorUserId);
|
||||
}
|
||||
|
||||
public function requiresAuthentication(string $filter): bool
|
||||
{
|
||||
return $this->communityActivity->requiresAuthentication($filter);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\CommentReaction;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
@@ -74,13 +76,15 @@ final class CommunityActivityService
|
||||
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
|
||||
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
|
||||
$reactionModels = $this->fetchReactionModels($sourceLimit);
|
||||
$recordedActivities = $this->fetchRecordedActivities($sourceLimit);
|
||||
|
||||
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
|
||||
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
|
||||
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
|
||||
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
|
||||
|
||||
$merged = $commentActivities
|
||||
$merged = $recordedActivities
|
||||
->concat($commentActivities)
|
||||
->concat($replyActivities)
|
||||
->concat($reactionActivities)
|
||||
->concat($mentionActivities)
|
||||
@@ -136,6 +140,89 @@ final class CommunityActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchRecordedActivities(int $limit): Collection
|
||||
{
|
||||
$events = ActivityEvent::query()
|
||||
->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at'])
|
||||
->with([
|
||||
'actor' => function ($query) {
|
||||
$query
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks');
|
||||
},
|
||||
])
|
||||
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($events->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$artworkIds = $events
|
||||
->where('target_type', ActivityEvent::TARGET_ARTWORK)
|
||||
->pluck('target_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$storyIds = $events
|
||||
->where('target_type', ActivityEvent::TARGET_STORY)
|
||||
->pluck('target_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$targetUserIds = $events
|
||||
->where('target_type', ActivityEvent::TARGET_USER)
|
||||
->pluck('target_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$artworks = empty($artworkIds)
|
||||
? collect()
|
||||
: Artwork::query()
|
||||
->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved')
|
||||
->whereIn('id', $artworkIds)
|
||||
->public()
|
||||
->published()
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$stories = empty($storyIds)
|
||||
? collect()
|
||||
: Story::query()
|
||||
->select('id', 'creator_id', 'title', 'slug', 'cover_image', 'published_at', 'status')
|
||||
->whereIn('id', $storyIds)
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$targetUsers = empty($targetUserIds)
|
||||
? collect()
|
||||
: User::query()
|
||||
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks')
|
||||
->whereIn('id', $targetUserIds)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $events
|
||||
->map(fn (ActivityEvent $event) => $this->mapRecordedActivity($event, $artworks, $stories, $targetUsers))
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
|
||||
{
|
||||
return ArtworkComment::query()
|
||||
@@ -262,6 +349,52 @@ final class CommunityActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function mapRecordedActivity(ActivityEvent $event, Collection $artworks, Collection $stories, Collection $targetUsers): ?array
|
||||
{
|
||||
if ($event->type === ActivityEvent::TYPE_COMMENT && $event->target_type === ActivityEvent::TARGET_ARTWORK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$artwork = $event->target_type === ActivityEvent::TARGET_ARTWORK
|
||||
? $artworks->get((int) $event->target_id)
|
||||
: null;
|
||||
|
||||
$story = $event->target_type === ActivityEvent::TARGET_STORY
|
||||
? $stories->get((int) $event->target_id)
|
||||
: null;
|
||||
|
||||
$targetUser = $event->target_type === ActivityEvent::TARGET_USER
|
||||
? $targetUsers->get((int) $event->target_id)
|
||||
: null;
|
||||
|
||||
if ($event->target_type === ActivityEvent::TARGET_ARTWORK && ! $artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($event->target_type === ActivityEvent::TARGET_STORY && ! $story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($event->target_type === ActivityEvent::TARGET_USER && ! $targetUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$iso = $event->created_at?->toIso8601String();
|
||||
|
||||
return [
|
||||
'id' => 'event:' . $event->id,
|
||||
'type' => (string) $event->type,
|
||||
'user' => $this->buildUserPayload($event->actor),
|
||||
'artwork' => $this->buildArtworkPayload($artwork),
|
||||
'story' => $this->buildStoryPayload($story),
|
||||
'target_user' => $this->buildUserPayload($targetUser),
|
||||
'meta' => is_array($event->meta) ? $event->meta : [],
|
||||
'created_at' => $iso,
|
||||
'time_ago' => $event->created_at?->diffForHumans(),
|
||||
'sort_timestamp' => $iso,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchMentionActivities(int $limit): Collection
|
||||
{
|
||||
if (! Schema::hasTable('user_mentions')) {
|
||||
@@ -384,6 +517,20 @@ final class CommunityActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStoryPayload(?Story $story): ?array
|
||||
{
|
||||
if (! $story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $story->id,
|
||||
'title' => html_entity_decode((string) ($story->title ?? 'Story'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('stories.show', ['slug' => $story->slug]),
|
||||
'cover_url' => $story->cover_url,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildCommentPayload(ArtworkComment $comment): array
|
||||
{
|
||||
$artwork = $this->buildArtworkPayload($comment->artwork);
|
||||
|
||||
100
app/Services/Countries/CountryCatalogService.php
Normal file
100
app/Services/Countries/CountryCatalogService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class CountryCatalogService
|
||||
{
|
||||
public const ACTIVE_ALL_CACHE_KEY = 'countries.active.all';
|
||||
public const PROFILE_SELECT_CACHE_KEY = 'countries.profile.select';
|
||||
|
||||
/**
|
||||
* @return Collection<int, Country>
|
||||
*/
|
||||
public function activeCountries(): Collection
|
||||
{
|
||||
if (! Schema::hasTable('countries')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
/** @var Collection<int, Country> $countries */
|
||||
$countries = Cache::remember(
|
||||
self::ACTIVE_ALL_CACHE_KEY,
|
||||
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
|
||||
fn (): Collection => Country::query()->active()->ordered()->get(),
|
||||
);
|
||||
|
||||
return $countries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function profileSelectOptions(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
self::PROFILE_SELECT_CACHE_KEY,
|
||||
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
|
||||
fn (): array => $this->activeCountries()
|
||||
->map(fn (Country $country): array => [
|
||||
'id' => $country->id,
|
||||
'iso2' => $country->iso2,
|
||||
'name' => $country->name_common,
|
||||
'flag_emoji' => $country->flag_emoji,
|
||||
'flag_css_class' => $country->flag_css_class,
|
||||
'is_featured' => $country->is_featured,
|
||||
'flag_path' => $country->local_flag_path,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(?int $countryId): ?Country
|
||||
{
|
||||
if ($countryId === null || $countryId <= 0 || ! Schema::hasTable('countries')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::query()->find($countryId);
|
||||
}
|
||||
|
||||
public function findByIso2(?string $iso2): ?Country
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $iso2));
|
||||
|
||||
if ($normalized === '' || ! preg_match('/^[A-Z]{2}$/', $normalized) || ! Schema::hasTable('countries')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::query()->where('iso2', $normalized)->first();
|
||||
}
|
||||
|
||||
public function resolveUserCountry(User $user): ?Country
|
||||
{
|
||||
if ($user->relationLoaded('country') && $user->country instanceof Country) {
|
||||
return $user->country;
|
||||
}
|
||||
|
||||
if (! empty($user->country_id)) {
|
||||
return $this->findById((int) $user->country_id);
|
||||
}
|
||||
|
||||
$countryCode = strtoupper((string) ($user->profile?->country_code ?? ''));
|
||||
|
||||
return $countryCode !== '' ? $this->findByIso2($countryCode) : null;
|
||||
}
|
||||
|
||||
public function flushCache(): void
|
||||
{
|
||||
Cache::forget(self::ACTIVE_ALL_CACHE_KEY);
|
||||
Cache::forget(self::PROFILE_SELECT_CACHE_KEY);
|
||||
}
|
||||
}
|
||||
115
app/Services/Countries/CountryRemoteProvider.php
Normal file
115
app/Services/Countries/CountryRemoteProvider.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use RuntimeException;
|
||||
|
||||
final class CountryRemoteProvider implements CountryRemoteProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpFactory $http,
|
||||
) {
|
||||
}
|
||||
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$endpoint = trim((string) config('skinbase-countries.endpoint', ''));
|
||||
|
||||
if ($endpoint === '') {
|
||||
throw new RuntimeException('Country sync endpoint is not configured.');
|
||||
}
|
||||
|
||||
$response = $this->http->acceptJson()
|
||||
->connectTimeout(max(1, (int) config('skinbase-countries.connect_timeout', 5)))
|
||||
->timeout(max(1, (int) config('skinbase-countries.timeout', 10)))
|
||||
->retry(
|
||||
max(0, (int) config('skinbase-countries.retry_times', 2)),
|
||||
max(0, (int) config('skinbase-countries.retry_sleep_ms', 250)),
|
||||
throw: false,
|
||||
)
|
||||
->get($endpoint);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(sprintf('Country sync request failed with status %d.', $response->status()));
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new RuntimeException('Country sync response was not a JSON array.');
|
||||
}
|
||||
|
||||
return $this->normalizePayload($payload);
|
||||
}
|
||||
|
||||
public function normalizePayload(array $payload): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($payload as $record) {
|
||||
if (! is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$country = $this->normalizeRecord($record);
|
||||
|
||||
if ($country !== null) {
|
||||
$normalized[] = $country;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function normalizeRecord(array $record): ?array
|
||||
{
|
||||
$iso2 = strtoupper(trim((string) ($record['cca2'] ?? $record['iso2'] ?? '')));
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$iso3 = strtoupper(trim((string) ($record['cca3'] ?? $record['iso3'] ?? '')));
|
||||
$iso3 = preg_match('/^[A-Z]{3}$/', $iso3) ? $iso3 : null;
|
||||
|
||||
$numericCode = trim((string) ($record['ccn3'] ?? $record['numeric_code'] ?? ''));
|
||||
$numericCode = preg_match('/^\d{1,3}$/', $numericCode)
|
||||
? str_pad($numericCode, 3, '0', STR_PAD_LEFT)
|
||||
: null;
|
||||
|
||||
$name = $record['name'] ?? [];
|
||||
$nameCommon = trim((string) ($name['common'] ?? $record['name_common'] ?? ''));
|
||||
|
||||
if ($nameCommon === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nameOfficial = trim((string) ($name['official'] ?? $record['name_official'] ?? ''));
|
||||
$flags = $record['flags'] ?? [];
|
||||
$flagSvgUrl = trim((string) ($flags['svg'] ?? $record['flag_svg_url'] ?? ''));
|
||||
$flagPngUrl = trim((string) ($flags['png'] ?? $record['flag_png_url'] ?? ''));
|
||||
$flagEmoji = trim((string) ($record['flag'] ?? $record['flag_emoji'] ?? ''));
|
||||
$region = trim((string) ($record['region'] ?? ''));
|
||||
$subregion = trim((string) ($record['subregion'] ?? ''));
|
||||
|
||||
return [
|
||||
'iso2' => $iso2,
|
||||
'iso3' => $iso3,
|
||||
'numeric_code' => $numericCode,
|
||||
'name_common' => $nameCommon,
|
||||
'name_official' => $nameOfficial !== '' ? $nameOfficial : null,
|
||||
'region' => $region !== '' ? $region : null,
|
||||
'subregion' => $subregion !== '' ? $subregion : null,
|
||||
'flag_svg_url' => $flagSvgUrl !== '' ? $flagSvgUrl : null,
|
||||
'flag_png_url' => $flagPngUrl !== '' ? $flagPngUrl : null,
|
||||
'flag_emoji' => $flagEmoji !== '' ? $flagEmoji : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal file
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
interface CountryRemoteProviderInterface
|
||||
{
|
||||
/**
|
||||
* Fetch and normalize all remote countries.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function fetchAll(): array;
|
||||
|
||||
/**
|
||||
* Normalize a raw payload into syncable country records.
|
||||
*
|
||||
* @param array<int, mixed> $payload
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function normalizePayload(array $payload): array;
|
||||
}
|
||||
193
app/Services/Countries/CountrySyncService.php
Normal file
193
app/Services/Countries/CountrySyncService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use App\Models\Country;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CountrySyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CountryRemoteProviderInterface $remoteProvider,
|
||||
private readonly CountryCatalogService $catalog,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function sync(bool $allowFallback = true, ?bool $deactivateMissing = null): array
|
||||
{
|
||||
if (! (bool) config('skinbase-countries.enabled', true)) {
|
||||
throw new RuntimeException('Countries sync is disabled by configuration.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'source' => null,
|
||||
'total_fetched' => 0,
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'invalid' => 0,
|
||||
'deactivated' => 0,
|
||||
'backfilled_users' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$records = $this->remoteProvider->fetchAll();
|
||||
$summary['source'] = (string) config('skinbase-countries.remote_source', 'remote');
|
||||
} catch (Throwable $exception) {
|
||||
if (! $allowFallback || ! (bool) config('skinbase-countries.fallback_seed_enabled', true)) {
|
||||
throw new RuntimeException('Country sync failed: '.$exception->getMessage(), previous: $exception);
|
||||
}
|
||||
|
||||
$records = $this->loadFallbackRecords();
|
||||
$summary['source'] = 'fallback';
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
throw new RuntimeException('Country sync did not yield any valid country records.');
|
||||
}
|
||||
|
||||
$summary['total_fetched'] = count($records);
|
||||
$seenIso2 = [];
|
||||
$featured = array_values(array_filter(array_map(
|
||||
static fn (mixed $iso2): string => strtoupper(trim((string) $iso2)),
|
||||
(array) config('skinbase-countries.featured_countries', []),
|
||||
)));
|
||||
$featuredOrder = array_flip($featured);
|
||||
|
||||
DB::transaction(function () use (&$summary, $records, &$seenIso2, $featuredOrder, $deactivateMissing): void {
|
||||
foreach ($records as $record) {
|
||||
$iso2 = strtoupper((string) ($record['iso2'] ?? ''));
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
|
||||
$summary['invalid']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seenIso2[$iso2])) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenIso2[$iso2] = true;
|
||||
|
||||
$country = Country::query()->firstOrNew(['iso2' => $iso2]);
|
||||
$exists = $country->exists;
|
||||
$featuredIndex = $featuredOrder[$iso2] ?? null;
|
||||
|
||||
$country->fill([
|
||||
'iso' => $iso2,
|
||||
'iso3' => $record['iso3'] ?? null,
|
||||
'numeric_code' => $record['numeric_code'] ?? null,
|
||||
'name' => $record['name_common'],
|
||||
'native' => $record['name_official'] ?? null,
|
||||
'continent' => $this->continentCode($record['region'] ?? null),
|
||||
'name_common' => $record['name_common'],
|
||||
'name_official' => $record['name_official'] ?? null,
|
||||
'region' => $record['region'] ?? null,
|
||||
'subregion' => $record['subregion'] ?? null,
|
||||
'flag_svg_url' => $record['flag_svg_url'] ?? null,
|
||||
'flag_png_url' => $record['flag_png_url'] ?? null,
|
||||
'flag_emoji' => $record['flag_emoji'] ?? null,
|
||||
'active' => true,
|
||||
'is_featured' => $featuredIndex !== null,
|
||||
'sort_order' => $featuredIndex !== null ? $featuredIndex + 1 : 1000,
|
||||
]);
|
||||
|
||||
if (! $exists) {
|
||||
$country->save();
|
||||
$summary['inserted']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($country->isDirty()) {
|
||||
$country->save();
|
||||
$summary['updated']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['skipped']++;
|
||||
}
|
||||
|
||||
if ($deactivateMissing ?? (bool) config('skinbase-countries.deactivate_missing', false)) {
|
||||
$summary['deactivated'] = Country::query()
|
||||
->where('active', true)
|
||||
->whereNotIn('iso2', array_keys($seenIso2))
|
||||
->update(['active' => false]);
|
||||
}
|
||||
});
|
||||
|
||||
$summary['backfilled_users'] = $this->backfillUsersFromLegacyProfileCodes();
|
||||
$this->catalog->flushCache();
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadFallbackRecords(): array
|
||||
{
|
||||
$path = (string) config('skinbase-countries.fallback_seed_path', database_path('data/countries-fallback.json'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('Country fallback dataset is missing.');
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new RuntimeException('Country fallback dataset is invalid JSON.', previous: $exception);
|
||||
}
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
throw new RuntimeException('Country fallback dataset is not a JSON array.');
|
||||
}
|
||||
|
||||
return $this->remoteProvider->normalizePayload($decoded);
|
||||
}
|
||||
|
||||
private function backfillUsersFromLegacyProfileCodes(): int
|
||||
{
|
||||
if (! Schema::hasTable('user_profiles') || ! Schema::hasTable('users') || ! Schema::hasColumn('users', 'country_id')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rows = DB::table('users as users')
|
||||
->join('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
|
||||
->join('countries as countries', 'countries.iso2', '=', 'profiles.country_code')
|
||||
->whereNull('users.country_id')
|
||||
->whereNotNull('profiles.country_code')
|
||||
->select(['users.id as user_id', 'countries.id as country_id'])
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
DB::table('users')
|
||||
->where('id', (int) $row->user_id)
|
||||
->update(['country_id' => (int) $row->country_id]);
|
||||
}
|
||||
|
||||
return $rows->count();
|
||||
}
|
||||
|
||||
private function continentCode(?string $region): ?string
|
||||
{
|
||||
return Arr::get([
|
||||
'Africa' => 'AF',
|
||||
'Americas' => 'AM',
|
||||
'Asia' => 'AS',
|
||||
'Europe' => 'EU',
|
||||
'Oceania' => 'OC',
|
||||
'Antarctic' => 'AN',
|
||||
], trim((string) $region));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\UserFollowedNotification;
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -16,6 +19,8 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
final class FollowService
|
||||
{
|
||||
public function __construct(private readonly XPService $xp) {}
|
||||
|
||||
/**
|
||||
* Follow $targetId on behalf of $actorId.
|
||||
*
|
||||
@@ -60,6 +65,15 @@ final class FollowService
|
||||
targetId: $targetId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$targetUser = User::query()->find($targetId);
|
||||
$actorUser = User::query()->find($actorId);
|
||||
if ($targetUser && $actorUser) {
|
||||
$targetUser->notify(new UserFollowedNotification($actorUser));
|
||||
}
|
||||
|
||||
$this->xp->awardFollowerReceived($targetId, $actorId);
|
||||
event(new AchievementCheckRequested($targetId));
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
|
||||
553
app/Services/LeaderboardService.php
Normal file
553
app/Services/LeaderboardService.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryLike;
|
||||
use App\Models\StoryView;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LeaderboardService
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 3600;
|
||||
private const CREATOR_STORE_LIMIT = 10000;
|
||||
private const ENTITY_STORE_LIMIT = 500;
|
||||
|
||||
public function calculateCreatorLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeCreatorRows()
|
||||
: $this->windowedCreatorRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_CREATOR, $normalizedPeriod, $rows, self::CREATOR_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function calculateArtworkLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeArtworkRows()
|
||||
: $this->windowedArtworkRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_ARTWORK, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function calculateStoryLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeStoryRows()
|
||||
: $this->windowedStoryRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function refreshAll(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ([
|
||||
Leaderboard::TYPE_CREATOR,
|
||||
Leaderboard::TYPE_ARTWORK,
|
||||
Leaderboard::TYPE_STORY,
|
||||
] as $type) {
|
||||
foreach ($this->periods() as $period) {
|
||||
$results[$type][$period] = match ($type) {
|
||||
Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period),
|
||||
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
|
||||
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function getLeaderboard(string $type, string $period, int $limit = 50): array
|
||||
{
|
||||
$normalizedType = $this->normalizeType($type);
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$limit = max(1, min($limit, 100));
|
||||
|
||||
return Cache::remember(
|
||||
$this->cacheKey($normalizedType, $normalizedPeriod, $limit),
|
||||
self::CACHE_TTL_SECONDS,
|
||||
function () use ($normalizedType, $normalizedPeriod, $limit): array {
|
||||
$items = Leaderboard::query()
|
||||
->where('type', $normalizedType)
|
||||
->where('period', $normalizedPeriod)
|
||||
->orderByDesc('score')
|
||||
->orderBy('entity_id')
|
||||
->limit($limit)
|
||||
->get(['entity_id', 'score'])
|
||||
->values();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return [
|
||||
'type' => $normalizedType,
|
||||
'period' => $normalizedPeriod,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$entities = match ($normalizedType) {
|
||||
Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
|
||||
};
|
||||
|
||||
return [
|
||||
'type' => $normalizedType,
|
||||
'period' => $normalizedPeriod,
|
||||
'items' => $items->values()->map(function (Leaderboard $row, int $index) use ($entities): array {
|
||||
return [
|
||||
'rank' => $index + 1,
|
||||
'score' => round((float) $row->score, 1),
|
||||
'entity' => $entities[(int) $row->entity_id] ?? null,
|
||||
];
|
||||
})->filter(fn (array $item): bool => $item['entity'] !== null)->values()->all(),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function creatorRankSummary(int $userId, string $period = Leaderboard::PERIOD_WEEKLY): ?array
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
|
||||
return Cache::remember(
|
||||
sprintf('leaderboard:creator-rank:%d:%s', $userId, $normalizedPeriod),
|
||||
self::CACHE_TTL_SECONDS,
|
||||
function () use ($userId, $normalizedPeriod): ?array {
|
||||
$row = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_CREATOR)
|
||||
->where('period', $normalizedPeriod)
|
||||
->where('entity_id', $userId)
|
||||
->first(['entity_id', 'score']);
|
||||
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$higherScores = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_CREATOR)
|
||||
->where('period', $normalizedPeriod)
|
||||
->where(function ($query) use ($row): void {
|
||||
$query->where('score', '>', $row->score)
|
||||
->orWhere(function ($ties) use ($row): void {
|
||||
$ties->where('score', '=', $row->score)
|
||||
->where('entity_id', '<', $row->entity_id);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'period' => $normalizedPeriod,
|
||||
'rank' => $higherScores + 1,
|
||||
'score' => round((float) $row->score, 1),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function periods(): array
|
||||
{
|
||||
return [
|
||||
Leaderboard::PERIOD_DAILY,
|
||||
Leaderboard::PERIOD_WEEKLY,
|
||||
Leaderboard::PERIOD_MONTHLY,
|
||||
Leaderboard::PERIOD_ALL_TIME,
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizePeriod(string $period): string
|
||||
{
|
||||
return match (strtolower(trim($period))) {
|
||||
'daily' => Leaderboard::PERIOD_DAILY,
|
||||
'weekly' => Leaderboard::PERIOD_WEEKLY,
|
||||
'monthly' => Leaderboard::PERIOD_MONTHLY,
|
||||
'all', 'all_time', 'all-time' => Leaderboard::PERIOD_ALL_TIME,
|
||||
default => Leaderboard::PERIOD_WEEKLY,
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
return match (strtolower(trim($type))) {
|
||||
'creator', 'creators' => Leaderboard::TYPE_CREATOR,
|
||||
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
|
||||
'story', 'stories' => Leaderboard::TYPE_STORY,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
}
|
||||
|
||||
private function periodStart(string $period): CarbonImmutable
|
||||
{
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return match ($period) {
|
||||
Leaderboard::PERIOD_DAILY => $now->subDay(),
|
||||
Leaderboard::PERIOD_WEEKLY => $now->subWeek(),
|
||||
Leaderboard::PERIOD_MONTHLY => $now->subMonth(),
|
||||
default => $now->subWeek(),
|
||||
};
|
||||
}
|
||||
|
||||
private function persistRows(string $type, string $period, Collection $rows, int $limit): int
|
||||
{
|
||||
$trimmed = $rows
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
DB::transaction(function () use ($type, $period, $trimmed): void {
|
||||
Leaderboard::query()
|
||||
->where('type', $type)
|
||||
->where('period', $period)
|
||||
->delete();
|
||||
|
||||
if ($trimmed->isNotEmpty()) {
|
||||
$timestamp = now();
|
||||
Leaderboard::query()->insert(
|
||||
$trimmed->map(fn (array $row): array => [
|
||||
'type' => $type,
|
||||
'period' => $period,
|
||||
'entity_id' => (int) $row['entity_id'],
|
||||
'score' => round((float) $row['score'], 2),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
])->all()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$this->flushCache($type, $period);
|
||||
|
||||
return $trimmed->count();
|
||||
}
|
||||
|
||||
private function flushCache(string $type, string $period): void
|
||||
{
|
||||
foreach ([10, 25, 50, 100] as $limit) {
|
||||
Cache::forget($this->cacheKey($type, $period, $limit));
|
||||
}
|
||||
|
||||
if ($type === Leaderboard::TYPE_CREATOR) {
|
||||
Cache::forget('leaderboard:top-creators-widget:' . $period);
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheKey(string $type, string $period, int $limit): string
|
||||
{
|
||||
return sprintf('leaderboard:%s:%s:%d', $type, $period, $limit);
|
||||
}
|
||||
|
||||
private function allTimeCreatorRows(): Collection
|
||||
{
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where('users.is_active', true)
|
||||
->select([
|
||||
'users.id',
|
||||
DB::raw('COALESCE(users.xp, 0) as xp'),
|
||||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(us.favorites_received_count, 0) as likes_received'),
|
||||
DB::raw('COALESCE(us.artwork_views_received_count, 0) as artwork_views'),
|
||||
])
|
||||
->get()
|
||||
->map(function ($row): array {
|
||||
$score = ((int) $row->xp * 1)
|
||||
+ ((int) $row->followers_count * 10)
|
||||
+ ((int) $row->likes_received * 2)
|
||||
+ ((int) $row->artwork_views * 0.1);
|
||||
|
||||
return [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedCreatorRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$xp = DB::table('user_xp_logs')
|
||||
->select('user_id', DB::raw('SUM(xp) as xp'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('user_id');
|
||||
|
||||
$followers = DB::table('user_followers')
|
||||
->select('user_id', DB::raw('COUNT(*) as followers_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('user_id');
|
||||
|
||||
$likes = DB::table('artwork_likes as likes')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
|
||||
->select('artworks.user_id', DB::raw('COUNT(*) as likes_received'))
|
||||
->where('likes.created_at', '>=', $start)
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
$views = DB::query()
|
||||
->fromSub($this->artworkSnapshotDeltas($start), 'deltas')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'deltas.artwork_id')
|
||||
->select('artworks.user_id', DB::raw('SUM(deltas.views_delta) as artwork_views'))
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoinSub($xp, 'xp', 'xp.user_id', '=', 'users.id')
|
||||
->leftJoinSub($followers, 'followers', 'followers.user_id', '=', 'users.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.user_id', '=', 'users.id')
|
||||
->leftJoinSub($views, 'views', 'views.user_id', '=', 'users.id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where('users.is_active', true)
|
||||
->select([
|
||||
'users.id',
|
||||
DB::raw('COALESCE(xp.xp, 0) as xp'),
|
||||
DB::raw('COALESCE(followers.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(likes.likes_received, 0) as likes_received'),
|
||||
DB::raw('COALESCE(views.artwork_views, 0) as artwork_views'),
|
||||
])
|
||||
->get()
|
||||
->map(function ($row): array {
|
||||
$score = ((int) $row->xp * 1)
|
||||
+ ((int) $row->followers_count * 10)
|
||||
+ ((int) $row->likes_received * 2)
|
||||
+ ((float) $row->artwork_views * 0.1);
|
||||
|
||||
return [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function allTimeArtworkRows(): Collection
|
||||
{
|
||||
return Artwork::query()
|
||||
->from('artworks')
|
||||
->join('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
|
||||
->public()
|
||||
->select([
|
||||
'artworks.id',
|
||||
DB::raw('COALESCE(stats.favorites, 0) as likes_count'),
|
||||
DB::raw('COALESCE(stats.views, 0) as views_count'),
|
||||
DB::raw('COALESCE(stats.downloads, 0) as downloads_count'),
|
||||
DB::raw('COALESCE(stats.comments_count, 0) as comments_count'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->likes_count * 3)
|
||||
+ ((int) $row->views_count * 1)
|
||||
+ ((int) $row->downloads_count * 5)
|
||||
+ ((int) $row->comments_count * 4),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedArtworkRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$views = $this->artworkSnapshotDeltas($start);
|
||||
|
||||
$likes = DB::table('artwork_likes')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as favourites_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$downloads = DB::table('artwork_downloads')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as downloads_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$comments = DB::table('artwork_comments')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as comments_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('artwork_id');
|
||||
|
||||
return Artwork::query()
|
||||
->from('artworks')
|
||||
->leftJoinSub($views, 'views', 'views.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($downloads, 'downloads', 'downloads.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($comments, 'comments', 'comments.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->whereNotNull('artworks.published_at')
|
||||
->select([
|
||||
'artworks.id',
|
||||
DB::raw('COALESCE(likes.favourites_delta, 0) as favourites_delta'),
|
||||
DB::raw('COALESCE(views.views_delta, 0) as views_delta'),
|
||||
DB::raw('COALESCE(downloads.downloads_delta, 0) as downloads_delta'),
|
||||
DB::raw('COALESCE(comments.comments_delta, 0) as comments_delta'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->favourites_delta * 3)
|
||||
+ ((int) $row->views_delta * 1)
|
||||
+ ((int) $row->downloads_delta * 5)
|
||||
+ ((int) $row->comments_delta * 4),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function allTimeStoryRows(): Collection
|
||||
{
|
||||
return Story::query()
|
||||
->published()
|
||||
->select(['id', 'views', 'likes_count', 'comments_count', 'reading_time'])
|
||||
->get()
|
||||
->map(fn (Story $story): array => [
|
||||
'entity_id' => (int) $story->id,
|
||||
'score' => ((int) $story->views * 1)
|
||||
+ ((int) $story->likes_count * 3)
|
||||
+ ((int) $story->comments_count * 4)
|
||||
+ ((int) $story->reading_time * 0.5),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedStoryRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$views = StoryView::query()
|
||||
->select('story_id', DB::raw('COUNT(*) as views_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('story_id');
|
||||
|
||||
$likes = StoryLike::query()
|
||||
->select('story_id', DB::raw('COUNT(*) as likes_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('story_id');
|
||||
|
||||
return Story::query()
|
||||
->from('stories')
|
||||
->leftJoinSub($views, 'views', 'views.story_id', '=', 'stories.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.story_id', '=', 'stories.id')
|
||||
->published()
|
||||
->select([
|
||||
'stories.id',
|
||||
'stories.comments_count',
|
||||
'stories.reading_time',
|
||||
DB::raw('COALESCE(views.views_count, 0) as views_count'),
|
||||
DB::raw('COALESCE(likes.likes_count, 0) as likes_count'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->views_count * 1)
|
||||
+ ((int) $row->likes_count * 3)
|
||||
+ ((int) $row->comments_count * 4)
|
||||
+ ((int) $row->reading_time * 0.5),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder
|
||||
{
|
||||
return ArtworkMetricSnapshotHourly::query()
|
||||
->from('artwork_metric_snapshots_hourly as snapshots')
|
||||
->where('snapshots.bucket_hour', '>=', $start)
|
||||
->select([
|
||||
'snapshots.artwork_id',
|
||||
DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'),
|
||||
])
|
||||
->groupBy('snapshots.artwork_id')
|
||||
->toBase();
|
||||
}
|
||||
|
||||
private function creatorEntities(array $ids): array
|
||||
{
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoin('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
|
||||
->whereIn('users.id', $ids)
|
||||
->select([
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.name',
|
||||
'users.level',
|
||||
'users.rank',
|
||||
'profiles.avatar_hash',
|
||||
])
|
||||
->get()
|
||||
->mapWithKeys(fn ($row): array => [
|
||||
(int) $row->id => [
|
||||
'id' => (int) $row->id,
|
||||
'type' => Leaderboard::TYPE_CREATOR,
|
||||
'name' => (string) ($row->username ?: $row->name ?: 'Creator'),
|
||||
'username' => $row->username,
|
||||
'url' => $row->username ? '/@' . $row->username : null,
|
||||
'avatar' => \App\Support\AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 128),
|
||||
'level' => (int) ($row->level ?? 1),
|
||||
'rank' => (string) ($row->rank ?? 'Newbie'),
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function artworkEntities(array $ids): array
|
||||
{
|
||||
return Artwork::query()
|
||||
->with(['user.profile'])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->mapWithKeys(fn (Artwork $artwork): array => [
|
||||
(int) $artwork->id => [
|
||||
'id' => (int) $artwork->id,
|
||||
'type' => Leaderboard::TYPE_ARTWORK,
|
||||
'name' => $artwork->title,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'image' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
|
||||
'creator_name' => (string) ($artwork->user?->username ?: $artwork->user?->name ?: 'Creator'),
|
||||
'creator_url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function storyEntities(array $ids): array
|
||||
{
|
||||
return Story::query()
|
||||
->with('creator.profile')
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->mapWithKeys(fn (Story $story): array => [
|
||||
(int) $story->id => [
|
||||
'id' => (int) $story->id,
|
||||
'type' => Leaderboard::TYPE_STORY,
|
||||
'name' => $story->title,
|
||||
'url' => '/stories/' . $story->slug,
|
||||
'image' => $story->cover_url,
|
||||
'creator_name' => (string) ($story->creator?->username ?: $story->creator?->name ?: 'Creator'),
|
||||
'creator_url' => $story->creator?->username ? '/@' . $story->creator->username : null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
90
app/Services/NotificationService.php
Normal file
90
app/Services/NotificationService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class NotificationService
|
||||
{
|
||||
public function listForUser(User $user, int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
$resolvedPage = max(1, $page);
|
||||
$resolvedPerPage = max(1, min(50, $perPage));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->paginate($resolvedPerPage, ['*'], 'page', $resolvedPage);
|
||||
|
||||
$actorIds = collect($notifications->items())
|
||||
->map(function (Notification $notification): ?int {
|
||||
$data = is_array($notification->data) ? $notification->data : [];
|
||||
|
||||
return isset($data['actor_id']) ? (int) $data['actor_id'] : null;
|
||||
})
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$actors = $actorIds->isEmpty()
|
||||
? collect()
|
||||
: User::query()
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->whereIn('id', $actorIds->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return [
|
||||
'data' => collect($notifications->items())
|
||||
->map(fn (Notification $notification) => $this->mapNotification($notification, $actors))
|
||||
->values()
|
||||
->all(),
|
||||
'unread_count' => $user->unreadNotifications()->count(),
|
||||
'meta' => [
|
||||
'total' => $notifications->total(),
|
||||
'current_page' => $notifications->currentPage(),
|
||||
'last_page' => $notifications->lastPage(),
|
||||
'per_page' => $notifications->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markAllRead(User $user): void
|
||||
{
|
||||
$user->unreadNotifications()->update(['read_at' => now()]);
|
||||
}
|
||||
|
||||
public function markRead(User $user, string $id): void
|
||||
{
|
||||
$notification = $user->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
private function mapNotification(Notification $notification, Collection $actors): array
|
||||
{
|
||||
$data = is_array($notification->data) ? $notification->data : [];
|
||||
$actorId = isset($data['actor_id']) ? (int) $data['actor_id'] : null;
|
||||
$actor = $actorId ? $actors->get($actorId) : null;
|
||||
|
||||
return [
|
||||
'id' => (string) $notification->id,
|
||||
'type' => (string) ($data['type'] ?? $notification->type ?? 'notification'),
|
||||
'message' => (string) ($data['message'] ?? 'New activity'),
|
||||
'url' => $data['url'] ?? null,
|
||||
'created_at' => $notification->created_at?->toIso8601String(),
|
||||
'time_ago' => $notification->created_at?->diffForHumans(),
|
||||
'read' => $notification->read_at !== null,
|
||||
'actor' => $actor ? [
|
||||
'id' => (int) $actor->id,
|
||||
'name' => $actor->name,
|
||||
'username' => $actor->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $actor->id, $actor->profile?->avatar_hash, 64),
|
||||
'profile_url' => $actor->username ? '/@' . $actor->username : null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
@@ -67,6 +68,16 @@ class PostAchievementService
|
||||
], $artworkId);
|
||||
}
|
||||
|
||||
public function achievementUnlocked(User $user, Achievement $achievement): void
|
||||
{
|
||||
$this->createAchievementPost($user, 'unlock_' . $achievement->slug, [
|
||||
'achievement_id' => $achievement->id,
|
||||
'achievement_name' => $achievement->name,
|
||||
'message' => '🎉 Unlocked achievement: ' . $achievement->name,
|
||||
'xp_reward' => (int) $achievement->xp_reward,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function createAchievementPost(
|
||||
|
||||
67
app/Services/ReceivedCommentsInboxService.php
Normal file
67
app/Services/ReceivedCommentsInboxService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ReceivedCommentsInboxService
|
||||
{
|
||||
public function queryForUser(User $user): Builder
|
||||
{
|
||||
return ArtworkComment::query()
|
||||
->whereHas('artwork', function ($query) use ($user): void {
|
||||
$query->where('user_id', $user->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at');
|
||||
})
|
||||
->where('user_id', '!=', $user->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
public function unreadCountForUser(User $user): int
|
||||
{
|
||||
return (int) $this->unreadQueryForUser($user)->count();
|
||||
}
|
||||
|
||||
public function markInboxRead(User $user): void
|
||||
{
|
||||
$readAt = Carbon::now();
|
||||
|
||||
$this->unreadQueryForUser($user)
|
||||
->select('artwork_comments.id')
|
||||
->orderBy('artwork_comments.id')
|
||||
->chunkById(200, function ($comments) use ($user, $readAt): void {
|
||||
$rows = collect($comments)->map(function ($comment) use ($user, $readAt): array {
|
||||
return [
|
||||
'user_id' => $user->id,
|
||||
'artwork_comment_id' => (int) $comment->id,
|
||||
'read_at' => $readAt,
|
||||
'created_at' => $readAt,
|
||||
'updated_at' => $readAt,
|
||||
];
|
||||
})->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('user_received_comment_reads')->insertOrIgnore($rows);
|
||||
}
|
||||
}, 'artwork_comments.id', 'id');
|
||||
}
|
||||
|
||||
private function unreadQueryForUser(User $user): Builder
|
||||
{
|
||||
return $this->queryForUser($user)
|
||||
->whereNotExists(function ($query) use ($user): void {
|
||||
$query->selectRaw('1')
|
||||
->from('user_received_comment_reads as ucr')
|
||||
->whereColumn('ucr.artwork_comment_id', 'artwork_comments.id')
|
||||
->where('ucr.user_id', $user->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
300
app/Services/SocialService.php
Normal file
300
app/Services/SocialService.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryBookmark;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\StoryLike;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryCommentedNotification;
|
||||
use App\Notifications\StoryLikedNotification;
|
||||
use App\Notifications\StoryMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SocialService
|
||||
{
|
||||
private const COMMENT_MAX_LENGTH = 10000;
|
||||
|
||||
public function __construct(
|
||||
private readonly \App\Services\ActivityService $activity,
|
||||
private readonly FollowService $followService,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
public function toggleFollow(int $actorId, int $targetId, bool $state): array
|
||||
{
|
||||
if ($state) {
|
||||
$this->followService->follow($actorId, $targetId);
|
||||
} else {
|
||||
$this->followService->unfollow($actorId, $targetId);
|
||||
}
|
||||
|
||||
return [
|
||||
'following' => $state,
|
||||
'followers_count' => $this->followService->followersCount($targetId),
|
||||
];
|
||||
}
|
||||
|
||||
public function toggleStoryLike(User $actor, Story $story, bool $state): array
|
||||
{
|
||||
$changed = false;
|
||||
|
||||
if ($state) {
|
||||
$like = StoryLike::query()->firstOrCreate([
|
||||
'story_id' => (int) $story->id,
|
||||
'user_id' => (int) $actor->id,
|
||||
]);
|
||||
$changed = $like->wasRecentlyCreated;
|
||||
} else {
|
||||
$changed = StoryLike::query()
|
||||
->where('story_id', $story->id)
|
||||
->where('user_id', $actor->id)
|
||||
->delete() > 0;
|
||||
}
|
||||
|
||||
$likesCount = StoryLike::query()->where('story_id', $story->id)->count();
|
||||
$story->forceFill(['likes_count' => $likesCount])->save();
|
||||
|
||||
if ($state && $changed) {
|
||||
$this->activity->record((int) $actor->id, 'story_like', 'story', (int) $story->id);
|
||||
|
||||
if ((int) $story->creator_id > 0 && (int) $story->creator_id !== (int) $actor->id) {
|
||||
$creator = User::query()->find($story->creator_id);
|
||||
if ($creator) {
|
||||
$creator->notify(new StoryLikedNotification($story, $actor));
|
||||
event(new AchievementCheckRequested((int) $creator->id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
|
||||
'likes_count' => $likesCount,
|
||||
];
|
||||
}
|
||||
|
||||
public function toggleStoryBookmark(User $actor, Story $story, bool $state): array
|
||||
{
|
||||
if ($state) {
|
||||
StoryBookmark::query()->firstOrCreate([
|
||||
'story_id' => (int) $story->id,
|
||||
'user_id' => (int) $actor->id,
|
||||
]);
|
||||
} else {
|
||||
StoryBookmark::query()
|
||||
->where('story_id', $story->id)
|
||||
->where('user_id', $actor->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
|
||||
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function listStoryComments(Story $story, ?int $viewerId, int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
$comments = StoryComment::query()
|
||||
->with(['user.profile', 'approvedReplies'])
|
||||
->where('story_id', $story->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
->whereNull('deleted_at')
|
||||
->latest('created_at')
|
||||
->paginate($perPage, ['*'], 'page', max(1, $page));
|
||||
|
||||
return [
|
||||
'data' => $comments->getCollection()->map(fn (StoryComment $comment) => $this->formatComment($comment, $viewerId, true))->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function addStoryComment(User $actor, Story $story, string $raw, ?int $parentId = null): StoryComment
|
||||
{
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) > self::COMMENT_MAX_LENGTH) {
|
||||
abort(422, 'Invalid comment content.');
|
||||
}
|
||||
|
||||
$errors = ContentSanitizer::validate($trimmed);
|
||||
if ($errors) {
|
||||
abort(422, implode(' ', $errors));
|
||||
}
|
||||
|
||||
$parent = null;
|
||||
if ($parentId !== null) {
|
||||
$parent = StoryComment::query()
|
||||
->where('story_id', $story->id)
|
||||
->where('id', $parentId)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if (! $parent) {
|
||||
abort(422, 'The comment you are replying to is no longer available.');
|
||||
}
|
||||
}
|
||||
|
||||
$comment = DB::transaction(function () use ($actor, $story, $trimmed, $parent): StoryComment {
|
||||
$comment = StoryComment::query()->create([
|
||||
'story_id' => (int) $story->id,
|
||||
'user_id' => (int) $actor->id,
|
||||
'parent_id' => $parent?->id,
|
||||
'content' => $trimmed,
|
||||
'raw_content' => $trimmed,
|
||||
'rendered_content' => ContentSanitizer::render($trimmed),
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$commentsCount = StoryComment::query()
|
||||
->where('story_id', $story->id)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$story->forceFill(['comments_count' => $commentsCount])->save();
|
||||
|
||||
return $comment;
|
||||
});
|
||||
|
||||
$comment->load(['user.profile', 'approvedReplies']);
|
||||
|
||||
$this->activity->record((int) $actor->id, 'story_comment', 'story', (int) $story->id, ['comment_id' => (int) $comment->id]);
|
||||
$this->xp->awardCommentCreated((int) $actor->id, (int) $comment->id, 'story');
|
||||
|
||||
$this->notifyStoryCommentRecipients($story, $comment, $actor, $parent);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
public function deleteStoryComment(User $actor, StoryComment $comment): void
|
||||
{
|
||||
$story = $comment->story;
|
||||
$canDelete = (int) $comment->user_id === (int) $actor->id
|
||||
|| (int) ($story?->creator_id ?? 0) === (int) $actor->id
|
||||
|| $actor->hasRole('admin')
|
||||
|| $actor->hasRole('moderator');
|
||||
|
||||
abort_unless($canDelete, 403);
|
||||
|
||||
$comment->delete();
|
||||
|
||||
if ($story) {
|
||||
$commentsCount = StoryComment::query()
|
||||
->where('story_id', $story->id)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$story->forceFill(['comments_count' => $commentsCount])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function storyStateFor(?User $viewer, Story $story): array
|
||||
{
|
||||
if (! $viewer) {
|
||||
return [
|
||||
'liked' => false,
|
||||
'bookmarked' => false,
|
||||
'is_following_creator' => false,
|
||||
'likes_count' => (int) $story->likes_count,
|
||||
'comments_count' => (int) $story->comments_count,
|
||||
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
|
||||
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
|
||||
'is_following_creator' => $story->creator_id ? $this->followService->isFollowing((int) $viewer->id, (int) $story->creator_id) : false,
|
||||
'likes_count' => StoryLike::query()->where('story_id', $story->id)->count(),
|
||||
'comments_count' => StoryComment::query()->where('story_id', $story->id)->whereNull('deleted_at')->count(),
|
||||
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function formatComment(StoryComment $comment, ?int $viewerId, bool $includeReplies = false): array
|
||||
{
|
||||
$user = $comment->user;
|
||||
$avatarHash = $user?->profile?->avatar_hash;
|
||||
|
||||
return [
|
||||
'id' => (int) $comment->id,
|
||||
'parent_id' => $comment->parent_id,
|
||||
'raw_content' => $comment->raw_content ?? $comment->content,
|
||||
'rendered_content' => $comment->rendered_content,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null,
|
||||
'can_delete' => $viewerId !== null && ((int) $comment->user_id === $viewerId || (int) ($comment->story?->creator_id ?? 0) === $viewerId),
|
||||
'user' => [
|
||||
'id' => (int) ($user?->id ?? 0),
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : null,
|
||||
'avatar_url' => AvatarUrl::forUser((int) ($user?->id ?? 0), $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'replies' => $includeReplies && $comment->relationLoaded('approvedReplies')
|
||||
? $comment->approvedReplies->map(fn (StoryComment $reply) => $this->formatComment($reply, $viewerId, true))->values()->all()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function notifyStoryCommentRecipients(Story $story, StoryComment $comment, User $actor, ?StoryComment $parent): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
|
||||
if ((int) ($story->creator_id ?? 0) > 0 && (int) $story->creator_id !== (int) $actor->id) {
|
||||
$creator = User::query()->find($story->creator_id);
|
||||
if ($creator) {
|
||||
$creator->notify(new StoryCommentedNotification($story, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $creator->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parent && (int) $parent->user_id !== (int) $actor->id && ! in_array((int) $parent->user_id, $notifiedUserIds, true)) {
|
||||
$parentUser = User::query()->find($parent->user_id);
|
||||
if ($parentUser) {
|
||||
$parentUser->notify(new StoryCommentedNotification($story, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $parentUser->id;
|
||||
}
|
||||
}
|
||||
|
||||
$mentionedUsers = User::query()
|
||||
->whereIn(DB::raw('LOWER(username)'), $this->extractMentions((string) ($comment->raw_content ?? '')))
|
||||
->get();
|
||||
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
if ((int) $mentionedUser->id === (int) $actor->id || in_array((int) $mentionedUser->id, $notifiedUserIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mentionedUser->notify(new StoryMentionedNotification($story, $comment, $actor));
|
||||
}
|
||||
}
|
||||
|
||||
private function extractMentions(string $content): array
|
||||
{
|
||||
preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches);
|
||||
|
||||
return collect($matches[2] ?? [])
|
||||
->map(fn ($username) => strtolower((string) $username))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
72
app/Services/StoryPublicationService.php
Normal file
72
app/Services/StoryPublicationService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Story;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
|
||||
final class StoryPublicationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XPService $xp,
|
||||
private readonly ActivityService $activity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function publish(Story $story, string $notificationEvent = 'published', array $attributes = []): Story
|
||||
{
|
||||
$wasPublished = $this->isPublished($story);
|
||||
|
||||
$story->fill(array_merge([
|
||||
'status' => 'published',
|
||||
'published_at' => $story->published_at ?? now(),
|
||||
'scheduled_for' => null,
|
||||
], $attributes));
|
||||
|
||||
if ($story->isDirty()) {
|
||||
$story->save();
|
||||
}
|
||||
|
||||
$this->afterPersistence($story, $notificationEvent, $wasPublished);
|
||||
|
||||
return $story;
|
||||
}
|
||||
|
||||
public function afterPersistence(Story $story, string $notificationEvent = 'published', bool $wasPublished = false): void
|
||||
{
|
||||
if (! $this->isPublished($story)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $wasPublished && $story->creator_id !== null) {
|
||||
$this->xp->awardStoryPublished((int) $story->creator_id, (int) $story->id);
|
||||
event(new AchievementCheckRequested((int) $story->creator_id));
|
||||
|
||||
try {
|
||||
$this->activity->record(
|
||||
actorId: (int) $story->creator_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_STORY,
|
||||
targetId: (int) $story->id,
|
||||
meta: [
|
||||
'story_slug' => (string) $story->slug,
|
||||
'story_title' => (string) $story->title,
|
||||
],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Activity logging should not block publication.
|
||||
}
|
||||
}
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, $notificationEvent));
|
||||
}
|
||||
|
||||
private function isPublished(Story $story): bool
|
||||
{
|
||||
return $story->published_at !== null || $story->status === 'published';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Jobs\IndexUserJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* UserStatsService – single source of truth for user_statistics counters.
|
||||
@@ -253,7 +254,7 @@ final class UserStatsService
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"),
|
||||
$column => $this->nonNegativeCounterExpression($column, $by),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
@@ -264,7 +265,7 @@ final class UserStatsService
|
||||
->where('user_id', $userId)
|
||||
->where($column, '>', 0)
|
||||
->update([
|
||||
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"),
|
||||
$column => $this->nonNegativeCounterExpression($column, -$by),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
@@ -279,6 +280,22 @@ final class UserStatsService
|
||||
]);
|
||||
}
|
||||
|
||||
private function nonNegativeCounterExpression(string $column, int $delta)
|
||||
{
|
||||
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $column)) {
|
||||
throw new InvalidArgumentException('Invalid statistics column name.');
|
||||
}
|
||||
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$deltaSql = $delta >= 0 ? "+ {$delta}" : "- ".abs($delta);
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return DB::raw("max(0, COALESCE({$column}, 0) {$deltaSql})");
|
||||
}
|
||||
|
||||
return DB::raw("GREATEST(0, COALESCE({$column}, 0) {$deltaSql})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Meilisearch reindex for the user.
|
||||
* Uses IndexUserJob to avoid blocking the request.
|
||||
|
||||
292
app/Services/XPService.php
Normal file
292
app/Services/XPService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\Achievements\UserXpUpdated;
|
||||
use App\Models\User;
|
||||
use App\Models\UserXpLog;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class XPService
|
||||
{
|
||||
private const LEVEL_THRESHOLDS = [
|
||||
1 => 0,
|
||||
2 => 100,
|
||||
3 => 300,
|
||||
4 => 800,
|
||||
5 => 2000,
|
||||
6 => 5000,
|
||||
7 => 12000,
|
||||
];
|
||||
|
||||
private const RANKS = [
|
||||
1 => 'Newbie',
|
||||
2 => 'Explorer',
|
||||
3 => 'Contributor',
|
||||
4 => 'Creator',
|
||||
5 => 'Pro Creator',
|
||||
6 => 'Elite',
|
||||
7 => 'Legend',
|
||||
];
|
||||
|
||||
private const DAILY_CAPS = [
|
||||
'artwork_view_received' => 200,
|
||||
'comment_created' => 100,
|
||||
'story_published' => 200,
|
||||
'artwork_published' => 250,
|
||||
'follower_received' => 400,
|
||||
'artwork_like_received' => 500,
|
||||
];
|
||||
|
||||
public function addXP(
|
||||
User|int $user,
|
||||
int $amount,
|
||||
string $action,
|
||||
?int $referenceId = null,
|
||||
bool $dispatchEvent = true,
|
||||
): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$baseAction = $this->baseAction($action);
|
||||
$awardAmount = $this->applyDailyCap($userId, $amount, $baseAction);
|
||||
|
||||
if ($awardAmount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($userId, $awardAmount, $action, $referenceId): void {
|
||||
/** @var User $lockedUser */
|
||||
$lockedUser = User::query()->lockForUpdate()->findOrFail($userId);
|
||||
$nextXp = max(0, (int) $lockedUser->xp + $awardAmount);
|
||||
$level = $this->calculateLevel($nextXp);
|
||||
$rank = $this->getRank($level);
|
||||
|
||||
$lockedUser->forceFill([
|
||||
'xp' => $nextXp,
|
||||
'level' => $level,
|
||||
'rank' => $rank,
|
||||
])->save();
|
||||
|
||||
UserXpLog::query()->create([
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'xp' => $awardAmount,
|
||||
'reference_id' => $referenceId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->forgetSummaryCache($userId);
|
||||
|
||||
if ($dispatchEvent) {
|
||||
event(new UserXpUpdated($userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function awardArtworkPublished(int $userId, int $artworkId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 50, 'artwork_published', $artworkId);
|
||||
}
|
||||
|
||||
public function awardArtworkLikeReceived(int $userId, int $artworkId, int $actorId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 5, 'artwork_like_received', $artworkId, $actorId);
|
||||
}
|
||||
|
||||
public function awardFollowerReceived(int $userId, int $followerId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 20, 'follower_received', $followerId, $followerId);
|
||||
}
|
||||
|
||||
public function awardStoryPublished(int $userId, int $storyId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 40, 'story_published', $storyId);
|
||||
}
|
||||
|
||||
public function awardCommentCreated(int $userId, int $referenceId, string $scope = 'generic'): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId);
|
||||
}
|
||||
|
||||
public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool
|
||||
{
|
||||
$viewerKey = $viewerId !== null && $viewerId > 0
|
||||
? 'user:' . $viewerId
|
||||
: 'guest:' . sha1((string) ($ipAddress ?: 'guest'));
|
||||
|
||||
$expiresAt = now()->endOfDay();
|
||||
$qualifierKey = sprintf('xp:view:qualifier:%d:%d:%s:%s', $userId, $artworkId, $viewerKey, now()->format('Ymd'));
|
||||
if (! Cache::add($qualifierKey, true, $expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bucketKey = sprintf('xp:view:bucket:%d:%s', $userId, now()->format('Ymd'));
|
||||
Cache::add($bucketKey, 0, $expiresAt);
|
||||
$bucketCount = Cache::increment($bucketKey);
|
||||
|
||||
if ($bucketCount % 10 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->addXP($userId, 1, 'artwork_view_received', $artworkId);
|
||||
}
|
||||
|
||||
public function calculateLevel(int $xp): int
|
||||
{
|
||||
$resolvedLevel = 1;
|
||||
|
||||
foreach (self::LEVEL_THRESHOLDS as $level => $threshold) {
|
||||
if ($xp >= $threshold) {
|
||||
$resolvedLevel = $level;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolvedLevel;
|
||||
}
|
||||
|
||||
public function getRank(int $level): string
|
||||
{
|
||||
return self::RANKS[$level] ?? Arr::last(self::RANKS);
|
||||
}
|
||||
|
||||
public function summary(User|int $user): array
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
return Cache::remember(
|
||||
$this->summaryCacheKey($userId),
|
||||
now()->addMinutes(10),
|
||||
function () use ($userId): array {
|
||||
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
|
||||
$currentLevel = max(1, (int) $currentUser->level);
|
||||
$currentXp = max(0, (int) $currentUser->xp);
|
||||
$currentThreshold = self::LEVEL_THRESHOLDS[$currentLevel] ?? 0;
|
||||
$nextLevel = min($currentLevel + 1, array_key_last(self::LEVEL_THRESHOLDS));
|
||||
$nextLevelXp = self::LEVEL_THRESHOLDS[$nextLevel] ?? $currentXp;
|
||||
$range = max(1, $nextLevelXp - $currentThreshold);
|
||||
$progressWithinLevel = min($range, max(0, $currentXp - $currentThreshold));
|
||||
$progressPercent = $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS)
|
||||
? 100
|
||||
: (int) round(($progressWithinLevel / $range) * 100);
|
||||
|
||||
return [
|
||||
'xp' => $currentXp,
|
||||
'level' => $currentLevel,
|
||||
'rank' => (string) ($currentUser->rank ?: $this->getRank($currentLevel)),
|
||||
'current_level_xp' => $currentThreshold,
|
||||
'next_level_xp' => $nextLevelXp,
|
||||
'progress_xp' => $progressWithinLevel,
|
||||
'progress_percent' => $progressPercent,
|
||||
'max_level' => $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function recalculateStoredProgress(User|int $user, bool $write = true): array
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
/** @var User $currentUser */
|
||||
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
|
||||
|
||||
$computedXp = (int) UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->sum('xp');
|
||||
|
||||
$computedLevel = $this->calculateLevel($computedXp);
|
||||
$computedRank = $this->getRank($computedLevel);
|
||||
$changed = (int) $currentUser->xp !== $computedXp
|
||||
|| (int) $currentUser->level !== $computedLevel
|
||||
|| (string) $currentUser->rank !== $computedRank;
|
||||
|
||||
if ($write && $changed) {
|
||||
$currentUser->forceFill([
|
||||
'xp' => $computedXp,
|
||||
'level' => $computedLevel,
|
||||
'rank' => $computedRank,
|
||||
])->save();
|
||||
|
||||
$this->forgetSummaryCache($userId);
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'changed' => $changed,
|
||||
'previous' => [
|
||||
'xp' => (int) $currentUser->xp,
|
||||
'level' => (int) $currentUser->level,
|
||||
'rank' => (string) $currentUser->rank,
|
||||
],
|
||||
'computed' => [
|
||||
'xp' => $computedXp,
|
||||
'level' => $computedLevel,
|
||||
'rank' => $computedRank,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function awardUnique(int $userId, int $amount, string $action, int $referenceId, ?int $actorId = null): bool
|
||||
{
|
||||
$actionKey = $actorId !== null ? $action . ':' . $actorId : $action;
|
||||
|
||||
$alreadyAwarded = UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action', $actionKey)
|
||||
->where('reference_id', $referenceId)
|
||||
->exists();
|
||||
|
||||
if ($alreadyAwarded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->addXP($userId, $amount, $actionKey, $referenceId);
|
||||
}
|
||||
|
||||
private function applyDailyCap(int $userId, int $amount, string $baseAction): int
|
||||
{
|
||||
$cap = self::DAILY_CAPS[$baseAction] ?? null;
|
||||
if ($cap === null) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$dayStart = Carbon::now()->startOfDay();
|
||||
$awardedToday = (int) UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action', 'like', $baseAction . '%')
|
||||
->where('created_at', '>=', $dayStart)
|
||||
->sum('xp');
|
||||
|
||||
return max(0, min($amount, $cap - $awardedToday));
|
||||
}
|
||||
|
||||
private function baseAction(string $action): string
|
||||
{
|
||||
return explode(':', $action, 2)[0];
|
||||
}
|
||||
|
||||
private function forgetSummaryCache(int $userId): void
|
||||
{
|
||||
Cache::forget($this->summaryCacheKey($userId));
|
||||
}
|
||||
|
||||
private function summaryCacheKey(int $userId): string
|
||||
{
|
||||
return 'xp:summary:' . $userId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user