This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View 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;
}
}

View 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);
}
}

View File

@@ -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);

View 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);
}
}

View 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,
];
}
}

View 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;
}

View 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));
}
}

View File

@@ -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;

View 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();
}
}

View 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,
];
}
}

View File

@@ -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(

View 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);
});
}
}

View 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();
}
}

View 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';
}
}

View File

@@ -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
View 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;
}
}