feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -4,45 +4,24 @@ declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArtworkAwardService
{
public function __construct(private readonly ArtworkMedalService $medals)
{
}
/**
* Award an artwork with the given medal.
* Throws ValidationException if the user already awarded this artwork.
*/
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
{
$this->validateMedal($medal);
$existing = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($existing) {
throw ValidationException::withMessages([
'medal' => 'You have already awarded this artwork. Use change to update.',
]);
}
$award = ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
]);
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
return $award;
return $this->medals->award($artwork, $user, $medal);
}
/**
@@ -50,21 +29,7 @@ class ArtworkAwardService
*/
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
{
$this->validateMedal($medal);
$award = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$award->update([
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
]);
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
return $award->fresh();
return $this->medals->changeMedal($artwork, $user, $medal);
}
/**
@@ -73,17 +38,7 @@ class ArtworkAwardService
*/
public function removeAward(Artwork $artwork, User $user): void
{
$award = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($award) {
$award->delete(); // fires ArtworkAwardObserver::deleted
} else {
// Nothing to remove, but still sync stats to be safe.
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
}
$this->medals->removeMedal($artwork, $user);
}
/**
@@ -91,32 +46,7 @@ class ArtworkAwardService
*/
public function recalcStats(int $artworkId): ArtworkAwardStat
{
$counts = DB::table('artwork_awards')
->where('artwork_id', $artworkId)
->selectRaw('
SUM(medal = \'gold\') AS gold_count,
SUM(medal = \'silver\') AS silver_count,
SUM(medal = \'bronze\') AS bronze_count
')
->first();
$gold = (int) ($counts->gold_count ?? 0);
$silver = (int) ($counts->silver_count ?? 0);
$bronze = (int) ($counts->bronze_count ?? 0);
$score = ($gold * 3) + ($silver * 2) + ($bronze * 1);
$stat = ArtworkAwardStat::updateOrCreate(
['artwork_id' => $artworkId],
[
'gold_count' => $gold,
'silver_count' => $silver,
'bronze_count' => $bronze,
'score_total' => $score,
'updated_at' => now(),
]
);
return $stat;
return $this->medals->recalculateStats($artworkId);
}
/**
@@ -124,15 +54,6 @@ class ArtworkAwardService
*/
public function syncToSearch(Artwork $artwork): void
{
IndexArtworkJob::dispatch($artwork->id);
}
private function validateMedal(string $medal): void
{
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
throw ValidationException::withMessages([
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
]);
}
$this->medals->syncArtworkToSearch((int) $artwork->id);
}
}

View File

@@ -0,0 +1,644 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VectorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ArtworkEvolutionService
{
/**
* @return list<string>
*/
public static function relationTypes(): array
{
return [
ArtworkRelation::TYPE_REMAKE_OF,
ArtworkRelation::TYPE_REMASTER_OF,
ArtworkRelation::TYPE_REVISION_OF,
ArtworkRelation::TYPE_INSPIRED_BY,
ArtworkRelation::TYPE_VARIATION_OF,
];
}
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly GroupService $groups,
private readonly VectorService $vectors,
) {
}
/**
* @return array<int, array<string, string>>
*/
public function relationTypeOptions(): array
{
return array_map(fn (string $type): array => [
'value' => $type,
'label' => $this->relationTypeLabel($type),
'short_label' => $this->relationTypeShortLabel($type),
], self::relationTypes());
}
/**
* @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
*/
public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');
$targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
$relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
$note = $this->normalizeNote($payload['note'] ?? null);
if ($targetArtworkId <= 0) {
ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();
return null;
}
if ($targetArtworkId === (int) $sourceArtwork->id) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
]);
}
$targetArtwork = Artwork::query()
->with(['group.members'])
->find($targetArtworkId);
if (! $targetArtwork) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
]);
}
$this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');
if (! $this->isPubliclyVisible($targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
]);
}
if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
]);
}
return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
ArtworkRelation::query()
->where('source_artwork_id', (int) $sourceArtwork->id)
->delete();
return ArtworkRelation::query()->create([
'source_artwork_id' => (int) $sourceArtwork->id,
'target_artwork_id' => (int) $targetArtwork->id,
'relation_type' => $relationType,
'note' => $note,
'sort_order' => 0,
'created_by_user_id' => (int) $actor->id,
])->load([
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
]);
});
}
/**
* @return array<string, mixed>|null
*/
public function editorRelation(Artwork $artwork, User $actor): ?array
{
$relation = ArtworkRelation::query()
->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
if (! $relation || ! $relation->targetArtwork) {
return null;
}
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
'note' => $relation->note,
'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');
$manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter()
->values();
$term = trim($search);
$safeLimit = max(1, min($limit, 36));
$rankedOptions = [];
$rankedIds = [];
if ($this->vectors->isConfigured()) {
$rankedOptions = $this->similarityRankedOptions($sourceArtwork, $actor, $manageableGroupIds->all(), $term, $safeLimit);
$rankedIds = array_map(static fn (array $option): int => (int) ($option['id'] ?? 0), $rankedOptions);
$rankedIds = array_values(array_filter($rankedIds));
}
if (count($rankedOptions) >= $safeLimit) {
return array_slice($rankedOptions, 0, $safeLimit);
}
$fallbackOptions = $this->fallbackSearchOptions(
$sourceArtwork,
$actor,
$manageableGroupIds->all(),
$term,
$safeLimit,
$rankedIds,
);
return collect(array_merge($rankedOptions, $fallbackOptions))
->unique('id')
->take($safeLimit)
->values()
->all();
}
/**
* @return array<string, mixed>|null
*/
public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
{
$primaryRelation = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
$incomingRelations = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('target_artwork_id', (int) $artwork->id)
->orderByDesc('updated_at')
->orderByDesc('id')
->limit(4)
->get();
$primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
$updates = $incomingRelations
->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
->filter()
->values()
->all();
if ($primary === null && $updates === []) {
return null;
}
return [
'eyebrow' => 'Artwork Evolution',
'primary' => $primary,
'updates' => $updates,
];
}
private function ensureManageable(User $actor, Artwork $artwork, string $message): void
{
if (! Gate::forUser($actor)->allows('update', $artwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => $message,
]);
}
}
private function isPubliclyVisible(Artwork $artwork): bool
{
return ! $artwork->trashed()
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
{
$sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
$targetTimestamp = $this->comparisonTimestamp($targetArtwork);
if ($sourceTimestamp === null || $targetTimestamp === null) {
return true;
}
return $targetTimestamp->lt($sourceTimestamp);
}
private function comparisonTimestamp(Artwork $artwork): ?Carbon
{
$value = $artwork->published_at ?: $artwork->created_at;
return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
}
private function normalizeRelationType(string $type): string
{
$normalized = Str::lower(trim($type));
return in_array($normalized, self::relationTypes(), true)
? $normalized
: ArtworkRelation::TYPE_REMAKE_OF;
}
private function normalizeNote(mixed $note): ?string
{
$resolved = trim((string) $note);
return $resolved !== '' ? $resolved : null;
}
/**
* @return array<string, mixed>
*/
private function mapStudioOption(Artwork $artwork, User $actor, array $context = []): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publishedAt = $artwork->published_at;
$year = $publishedAt?->year ?: $artwork->created_at?->year;
$similarityScore = array_key_exists('similarity_score', $context) && is_numeric($context['similarity_score'])
? round((float) $context['similarity_score'], 5)
: null;
return [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'year' => $year,
'published_at' => optional($publishedAt)->toIsoString(),
'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
'similarity_score' => $similarityScore,
'sort_source' => (string) ($context['sort_source'] ?? 'fallback'),
];
}
/**
* @param list<int> $manageableGroupIds
* @return list<array<string, mixed>>
*/
private function similarityRankedOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit): array
{
try {
$matches = $this->vectors->similarToArtwork($sourceArtwork, min(120, max($limit * 4, 48)));
} catch (\Throwable) {
return [];
}
$orderedIds = [];
$scores = [];
foreach ($matches as $match) {
$candidateId = (int) ($match['id'] ?? 0);
if ($candidateId <= 0 || isset($scores[$candidateId])) {
continue;
}
$orderedIds[] = $candidateId;
$scores[$candidateId] = (float) ($match['score'] ?? 0.0);
}
if ($orderedIds === []) {
return [];
}
$candidates = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term)
->whereIn('id', $orderedIds)
->get()
->keyBy('id');
$options = [];
foreach ($orderedIds as $candidateId) {
/** @var Artwork|null $candidate */
$candidate = $candidates->get($candidateId);
if (! $candidate) {
continue;
}
$options[] = $this->mapStudioOption($candidate, $actor, [
'similarity_score' => $scores[$candidateId] ?? null,
'sort_source' => 'vector_similarity',
]);
if (count($options) >= $limit) {
break;
}
}
return $options;
}
/**
* @param list<int> $manageableGroupIds
* @param list<int> $excludeIds
* @return list<array<string, mixed>>
*/
private function fallbackSearchOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit, array $excludeIds = []): array
{
$query = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term);
if ($excludeIds !== []) {
$query->whereNotIn('id', $excludeIds);
}
return $query
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
->orderByDesc('published_at')
->limit(max($limit * 2, 36))
->get()
->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
->values()
->all();
}
/**
* @param list<int> $manageableGroupIds
*/
private function manageableCandidatesQuery(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term): Builder
{
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->whereKeyNot((int) $sourceArtwork->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where(function ($builder) use ($actor, $manageableGroupIds): void {
$builder->where('user_id', (int) $actor->id);
if ($manageableGroupIds !== []) {
$builder->orWhereIn('group_id', $manageableGroupIds);
}
});
$referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
if ($referenceTimestamp !== null) {
$query->where('published_at', '<=', $referenceTimestamp);
}
if ($term !== '') {
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
->orWhereHas('user', fn ($userQuery) => $userQuery
->where('name', 'like', $like)
->orWhere('username', 'like', $like));
});
}
return $query;
}
/**
* @return array<string, mixed>|null
*/
private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$beforeYear = $before['year'] ?? null;
$afterYear = $after['year'] ?? null;
$yearsApart = $this->yearsApart($beforeYear, $afterYear);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Then & Now',
'summary' => $this->primarySummary($beforeYear, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Then & Now comparison',
],
];
}
/**
* @return array<string, mixed>|null
*/
private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Updated Version',
'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Compare versions',
],
];
}
/**
* @return array<string, mixed>
*/
private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$md = ThumbnailPresenter::present($artwork, 'md');
$lg = ThumbnailPresenter::present($artwork, 'lg');
$xl = ThumbnailPresenter::present($artwork, 'xl');
$publishedAt = $artwork->published_at;
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => optional($publishedAt)->toIsoString(),
'year' => $publishedAt?->year ?: $artwork->created_at?->year,
'role_label' => $roleLabel,
'thumbnail' => $md['url'] ?? null,
'image_md' => $md['url'] ?? null,
'image_lg' => $lg['url'] ?? null,
'image_xl' => $xl['url'] ?? null,
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
], $artwork, $viewer);
}
/**
* @param array<string, mixed> $card
*/
private function shouldOmitForViewer(array $card): bool
{
return (bool) data_get($card, 'maturity.should_hide', false);
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
private function compareAvailable(array $before, array $after): bool
{
return ! empty($before['image_lg']) && ! empty($after['image_lg']);
}
private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
{
if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
return null;
}
return max(0, (int) $afterYear - (int) $beforeYear);
}
private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
{
if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
}
if (is_numeric($beforeYear)) {
return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
}
return 'This artwork revisits an earlier version from the creator archive.';
}
private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
{
if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
}
if (is_numeric($afterYear)) {
return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
}
return 'This artwork later received an updated version from the same creator.';
}
private function relationTypeLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
default => 'Remake of',
};
}
private function relationTypeShortLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
ArtworkRelation::TYPE_REVISION_OF => 'Update',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
default => 'Remake',
};
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
final class ArtworkMedalService
{
public function __construct(private readonly HomepageService $homepage)
{
}
public function upsert(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$existing = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
return $existing
? $this->changeMedal($artwork, $user, $medal)
: $this->award($artwork, $user, $medal);
}
public function award(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$exists = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
if ($exists) {
throw ValidationException::withMessages([
'medal' => 'You have already awarded this artwork. Use change to update.',
]);
}
return ArtworkMedal::query()->create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
}
public function changeMedal(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if (! $award) {
throw ValidationException::withMessages([
'medal' => 'No existing medal found for this artwork.',
]);
}
$award->update([
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
return $award->fresh();
}
public function removeMedal(Artwork $artwork, User $user): void
{
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($award) {
$award->delete();
}
}
public function recalculateStats(int $artworkId): ArtworkMedalStat
{
$rows = ArtworkMedal::query()
->where('artwork_id', $artworkId)
->get(['medal_type', 'weight', 'updated_at']);
$cutoff7d = now()->subDays(7);
$cutoff30d = now()->subDays(30);
$goldCount = 0;
$silverCount = 0;
$bronzeCount = 0;
$scoreTotal = 0;
$score7d = 0;
$score30d = 0;
$lastMedaledAt = null;
foreach ($rows as $row) {
$medal = (string) $row->medal;
$weight = (int) ($row->weight ?? ArtworkAward::weightFor($medal));
$updatedAt = $row->updated_at instanceof Carbon ? $row->updated_at : Carbon::parse($row->updated_at);
if ($medal === 'gold') {
$goldCount++;
} elseif ($medal === 'silver') {
$silverCount++;
} elseif ($medal === 'bronze') {
$bronzeCount++;
}
$scoreTotal += $weight;
if ($updatedAt->greaterThanOrEqualTo($cutoff7d)) {
$score7d += $weight;
}
if ($updatedAt->greaterThanOrEqualTo($cutoff30d)) {
$score30d += $weight;
}
if ($lastMedaledAt === null || $updatedAt->greaterThan($lastMedaledAt)) {
$lastMedaledAt = $updatedAt;
}
}
$stat = ArtworkAwardStat::query()->updateOrCreate(
['artwork_id' => $artworkId],
[
'gold_count' => $goldCount,
'silver_count' => $silverCount,
'bronze_count' => $bronzeCount,
'score_total' => $scoreTotal,
'score_7d' => $score7d,
'score_30d' => $score30d,
'last_medaled_at' => $lastMedaledAt,
]
);
return ArtworkMedalStat::query()->findOrFail($stat->artwork_id);
}
public function refreshArtworkMedalState(int $artworkId): ArtworkMedalStat
{
$stat = $this->recalculateStats($artworkId);
$this->syncArtworkToSearch($artworkId);
$this->homepage->clearFeaturedAndMedalCaches();
return $stat;
}
public function syncArtworkToSearch(int $artworkId): void
{
IndexArtworkJob::dispatch($artworkId);
}
private function validateMedal(string $medal): void
{
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
throw ValidationException::withMessages([
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
]);
}
}
}

View File

@@ -7,9 +7,11 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* High-level search API powered by Meilisearch via Laravel Scout.
@@ -21,9 +23,12 @@ final class ArtworkSearchService
private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
private const SEARCH_CANDIDATE_POOL_MULTIPLIER = 4;
private const SEARCH_CANDIDATE_POOL_MAX = 240;
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ArtworkMaturityService $maturity,
) {}
/**
@@ -76,11 +81,38 @@ final class ArtworkSearchService
$options['sort'] = $sort;
}
$options = $this->viewerAwareOptions($options);
return Artwork::search($q ?: '')
->options($options)
->paginate($perPage);
}
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
{
$page = max(1, $page ?? (int) request()->get('page', 1));
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$results = Artwork::search('')
->options($this->viewerAwareOptions($options))
->paginate($candidateCount, 'page', 1);
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
$offset = max(0, ($page - 1) * $perPage);
$slice = $ordered->slice($offset, $perPage)->values();
return new PaginationLengthAwarePaginator(
$slice->all(),
(int) $results->total(),
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
}
/**
* Load artworks for a tag page, sorted by views + likes descending.
*/
@@ -92,12 +124,13 @@ final class ArtworkSearchService
}
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1);
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
@@ -132,14 +165,14 @@ final class ArtworkSearchService
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1);
$cacheKey = "search.cat.{$cat}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
])
]))
->paginate($perPage);
});
}
@@ -181,14 +214,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.{$categorySlug}.{$sort}.{$page}";
$cacheKey = "category.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
]))
->paginate($perPage);
});
}
@@ -204,14 +237,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$page}";
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
]))
->paginate($perPage);
});
}
@@ -230,7 +263,7 @@ final class ArtworkSearchService
return $this->popular($limit);
}
$cacheKey = "search.related.{$artwork->id}";
$cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
$tagFilters = implode(' OR ', array_map(
@@ -239,10 +272,10 @@ final class ArtworkSearchService
));
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
'sort' => ['views:desc', 'likes:desc'],
])
]))
->paginate($limit);
});
}
@@ -252,12 +285,12 @@ final class ArtworkSearchService
*/
public function popular(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'likes:desc'],
])
]))
->paginate($perPage);
});
}
@@ -267,12 +300,12 @@ final class ArtworkSearchService
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
])
]))
->paginate($perPage);
});
}
@@ -291,15 +324,13 @@ final class ArtworkSearchService
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
// Include window in cache key so adaptive expansions surface immediately
$cacheKey = "discover.trending.{$windowDays}d.{$page}";
$cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
])
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $perPage);
});
}
@@ -314,15 +345,13 @@ final class ArtworkSearchService
$page = (int) request()->get('page', 1);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
$cacheKey = "discover.rising.{$windowDays}d.{$page}";
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
])
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
], $perPage);
});
}
@@ -332,13 +361,11 @@ final class ArtworkSearchService
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
])
->paginate($perPage);
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
], $perPage);
});
}
@@ -348,13 +375,11 @@ final class ArtworkSearchService
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
])
->paginate($perPage);
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
], $perPage);
});
}
@@ -364,13 +389,11 @@ final class ArtworkSearchService
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
])
->paginate($perPage);
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
], $perPage);
});
}
@@ -391,18 +414,28 @@ final class ArtworkSearchService
array_slice($tagSlugs, 0, 5)
));
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs));
$cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate($limit);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
], $limit, true, 1);
});
}
private function viewerAwareOptions(array $options): array
{
$options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user());
return $options;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
/**
* Fresh artworks in given categories, sorted by publish timestamp desc.
* Used for personalized "Fresh in your favourite categories" section.
@@ -420,15 +453,13 @@ final class ArtworkSearchService
array_slice($categorySlugs, 0, 3)
));
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs));
$cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'],
])
->paginate($limit);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'],
], $limit, true, 1);
});
}
@@ -444,6 +475,52 @@ final class ArtworkSearchService
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
}
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items->values();
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items->values();
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
if ($excludeMissing) {
return $healthy->values();
}
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
{
return min(
self::SEARCH_CANDIDATE_POOL_MAX,
max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2))
);
}
private function emptyPaginator(int $perPage): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);

View File

@@ -4,6 +4,9 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -11,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
/**
* ArtworkService
@@ -23,6 +27,30 @@ class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
public function __construct(
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
)
{
}
/**
* Relations used by the featured artwork surfaces.
*
* @return array<int|string, mixed>
*/
private function featuredRelations(): array
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
];
}
/**
* Lightweight relations needed to render browse/list cards.
*
@@ -32,7 +60,7 @@ class ArtworkService
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_url',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -48,6 +76,7 @@ class ArtworkService
{
$query = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations());
$normalizedSort = strtolower(trim($sort));
@@ -122,6 +151,7 @@ class ArtworkService
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{
$query = Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations())
->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
@@ -141,6 +171,7 @@ class ArtworkService
public function getLatestArtworks(int $limit = 10): Collection
{
return Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->orderByDesc('published_at')
->limit($limit)
->get();
@@ -165,13 +196,7 @@ class ArtworkService
*/
public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator
{
$contentType = ContentType::where('slug', strtolower($slug))->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
$contentType = $this->resolveContentTypeOrFail($slug);
$query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($contentType) {
@@ -198,12 +223,7 @@ class ArtworkService
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = ContentType::where('slug', $contentTypeSlug)->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$contentTypeSlug]);
throw $e;
}
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
if (empty($parts)) {
$e = new ModelNotFoundException();
@@ -274,30 +294,102 @@ class ArtworkService
return $allIds;
}
private function resolveContentTypeOrFail(string $slug): ContentType
{
$resolution = $this->contentTypeResolver->resolve($slug);
if (! $resolution->found() || $resolution->contentType === null) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
return $resolution->contentType;
}
/**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters.
*/
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
private function featuredBaseQuery(?int $type): Builder
{
$query = Artwork::query()
return Artwork::query()
->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->public()
->published()
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->where('af.is_active', true)
->whereNull('af.deleted_at')
->where(function ($query): void {
$query->whereNull('af.expires_at')
->orWhere('af.expires_at', '>', now());
})
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
})
->with([
'user:id,name,username',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
])
});
}
private function applyFeaturedEligibilityFilters(Builder $query): void
{
$query->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails();
}
private function applyFeaturedOrdering(Builder $query): Builder
{
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->orderByDesc('af.force_hero');
}
return $query
->orderByDesc('af.priority')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('af.featured_at')
->orderByDesc('artworks.published_at');
}
return $query->paginate($perPage)->withQueryString();
private function featuredSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
$this->applyFeaturedEligibilityFilters($query);
return $this->applyFeaturedOrdering($query);
}
private function featuredHeroSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->where(function (Builder $selection): void {
$selection->where('af.force_hero', true)
->orWhere(function (Builder $eligible): void {
$this->applyFeaturedEligibilityFilters($eligible);
});
});
} else {
$this->applyFeaturedEligibilityFilters($query);
}
return $this->applyFeaturedOrdering($query);
}
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
{
return $this->featuredSelectionQuery($type)
->with($this->featuredRelations())
->paginate($perPage)
->withQueryString();
}
public function getFeaturedArtworkWinner(?int $type = null): ?Artwork
{
$artwork = $this->featuredHeroSelectionQuery($type)
->with($this->featuredRelations())
->first();
return $artwork instanceof Artwork ? $artwork : null;
}
/**
@@ -310,11 +402,13 @@ class ArtworkService
* @param int $perPage
* @return CursorPaginator
*/
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): CursorPaginator
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24, ?User $viewer = null): CursorPaginator
{
$query = Artwork::where('user_id', $userId)
->with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -326,6 +420,7 @@ class ArtworkService
if (! $isOwner) {
// Apply public visibility constraints for non-owners
$query->public()->published();
$this->maturity->applyViewerFilter($query, $viewer);
} else {
// Owner: include all non-deleted items (do not force published/approved)
$query->whereNull('deleted_at');

View File

@@ -6,6 +6,7 @@ use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
@@ -91,6 +92,55 @@ class ArtworkStatsService
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
/**
* Recompute denormalized engagement counters from their source tables.
*
* This keeps single-artwork analytics fresh after favourites, likes,
* comments, and shares without waiting for scheduled ranking jobs.
*/
public function syncEngagementCounts(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
try {
$payload = [
'favorites' => Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0,
'rating_count' => Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0,
];
if (Schema::hasColumn('artwork_stats', 'comments_count')) {
$payload['comments_count'] = Schema::hasTable('artwork_comments')
? (int) DB::table('artwork_comments')
->where('artwork_id', $artworkId)
->whereNull('deleted_at')
->count()
: 0;
}
if (Schema::hasColumn('artwork_stats', 'shares_count')) {
$payload['shares_count'] = Schema::hasTable('artwork_shares')
? (int) DB::table('artwork_shares')->where('artwork_id', $artworkId)->count()
: 0;
}
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
$payload
);
} catch (Throwable $e) {
Log::warning('Failed to sync artwork engagement counts', [
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
}
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* After updating artwork-level stats, forwards view/download counts to

View File

@@ -17,6 +17,7 @@ use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -33,6 +34,7 @@ class CollectionService
private readonly SmartCollectionService $smartCollections,
private readonly CollectionCollaborationService $collaborators,
private readonly GroupMembershipService $groupMembers,
private readonly ArtworkMaturityService $maturity,
) {
}
@@ -492,12 +494,14 @@ class CollectionService
return $query->get();
}
public function getCollectionDetailArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator
public function getCollectionDetailArtworks(Collection $collection, bool $ownerView, int $perPage = 24, ?User $viewer = null): LengthAwarePaginator
{
if ($collection->isSmart()) {
return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage);
}
$viewer ??= $ownerView ? null : request()->user();
$query = $collection->artworks()
->with([
'user:id,name,username',
@@ -515,12 +519,21 @@ class CollectionService
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now());
if ($this->viewerShouldHideMature($viewer)) {
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
}
}
$query = match ($collection->sort_mode) {
Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'),
Collection::SORT_OLDEST => $query->orderBy('artworks.published_at'),
Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByPivot('order_num'),
Collection::SORT_POPULAR => $query
->leftJoin('artwork_stats as artwork_stats_sort', 'artwork_stats_sort.artwork_id', '=', 'artworks.id')
->reorder()
->orderByRaw('COALESCE(artwork_stats_sort.views, 0) DESC')
->orderBy('collection_artwork.order_num'),
default => $query->orderByPivot('order_num'),
};
@@ -843,15 +856,18 @@ class CollectionService
public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array
{
$viewer ??= $ownerView ? null : request()->user();
$collectionList = $collections instanceof EloquentCollection
? $collections
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
$firstArtworkMap = $this->firstArtworkMapForCollections(
$collectionIds,
! $ownerView
! $ownerView,
$hideMatureCovers,
);
$savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== []
@@ -866,9 +882,11 @@ class CollectionService
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
$resolvedCover = $collection->isSmart()
? $this->smartCollections->firstArtwork($collection, $ownerView)
: $collection->resolvedCoverArtwork(! $ownerView);
: $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
$fallbackCover = $firstArtworkMap->get((int) $collection->id);
$cover = $resolvedCover ?? $fallbackCover;
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer))
? $resolvedCover
: $fallbackCover;
$summary = $collection->summary ?? $collection->description;
$isSaved = in_array((int) $collection->id, $savedCollectionIds, true);
$canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer);
@@ -958,6 +976,7 @@ class CollectionService
'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(),
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null,
'cover_artwork_id' => $cover?->id,
'saved' => $isSaved,
'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null,
@@ -976,11 +995,18 @@ class CollectionService
})->all();
}
public function mapCollectionDetailPayload(Collection $collection, bool $ownerView = false): array
public function mapCollectionDetailPayload(Collection $collection, bool $ownerView = false, ?User $viewer = null): array
{
$viewer ??= $ownerView ? null : request()->user();
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
$cover = $collection->isSmart()
? $this->smartCollections->firstArtwork($collection, $ownerView)
: $collection->resolvedCoverArtwork(! $ownerView);
: $collection->resolvedCoverArtwork(! $ownerView, $hideMatureCovers);
if (! $this->eligibleCoverArtwork($cover, ! $ownerView, $hideMatureCovers)) {
$cover = $this->firstArtworkMapForCollections([(int) $collection->id], ! $ownerView, $hideMatureCovers)
->get((int) $collection->id);
}
return [
'id' => $collection->id,
@@ -1074,7 +1100,8 @@ class CollectionService
'expired_at' => optional($collection->expired_at)?->toISOString(),
'history_count' => (int) $collection->history_count,
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
'cover_artwork_id' => $collection->cover_artwork_id,
'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null,
'cover_artwork_id' => $cover?->id,
'smart_rules_json' => $collection->smart_rules_json,
'layout_modules' => $this->normalizeLayoutModules($collection->layout_modules_json, $collection->type, (bool) $collection->allow_comments, (bool) $collection->allow_submissions),
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
@@ -1194,7 +1221,7 @@ class CollectionService
* @param array<int, int> $collectionIds
* @return SupportCollection<int, Artwork>
*/
private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly): SupportCollection
private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly, bool $hideMature = false): SupportCollection
{
if ($collectionIds === []) {
return collect();
@@ -1210,6 +1237,10 @@ class CollectionService
->whereNotNull('a.published_at')
->where('a.published_at', '<=', now());
})
->when($hideMature, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", ['suspected']);
})
->orderBy('ca.collection_id')
->orderBy('ca.order_num')
->select(['ca.collection_id', 'a.id'])
@@ -1237,7 +1268,7 @@ class CollectionService
$contentType = $category?->contentType;
$stats = $artwork->stats;
return array_merge([
return $this->maturity->decoratePayload(array_merge([
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
@@ -1261,7 +1292,7 @@ class CollectionService
'username' => $artwork->user->username,
'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]),
] : null,
], $extra);
], $extra), $artwork, request()->user());
}
private function normalizeLayoutModules(?array $modules, string $type, bool $allowComments, bool $allowSubmissions, bool $includePresentation = true): array
@@ -1458,6 +1489,29 @@ class CollectionService
return $presented['url'] ?? $artwork->thumbUrl('md');
}
private function eligibleCoverArtwork(?Artwork $artwork, bool $publicOnly, bool $hideMature): bool
{
if (! $artwork) {
return false;
}
if ($publicOnly && (! (bool) $artwork->is_public || ! (bool) $artwork->is_approved || $artwork->published_at === null || $artwork->published_at->gt(now()))) {
return false;
}
if (! $hideMature) {
return true;
}
return ! (bool) $artwork->is_mature
&& (string) ($artwork->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR) !== ArtworkMaturityService::STATUS_SUSPECTED;
}
private function viewerShouldHideMature(?User $viewer): bool
{
return $this->maturity->viewerPreferences($viewer)['visibility'] === ArtworkMaturityService::VIEW_HIDE;
}
private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null, ?Group $group = null): bool
{
return Collection::query()

View File

@@ -206,12 +206,18 @@ class ContentSanitizer
*/
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
{
$encodedHtml = mb_encode_numericentity(
$html,
[0x80, 0x10FFFF, 0, 0xFFFFFF],
'UTF-8'
);
// Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8');
// Suppress warnings from malformed fragments
libxml_use_internal_errors(true);
$doc->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>',
'<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
@@ -226,7 +232,7 @@ class ContentSanitizer
}
// Fix self-closing <a></a> etc.
return trim($inner);
return trim(html_entity_decode($inner, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/**

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ContentType;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
final class ContentTypeAssetService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public function storeUploadedAsset(ContentType $contentType, UploadedFile $file, string $kind): string
{
$mime = strtolower((string) ($file->getMimeType() ?: ''));
$extension = $this->safeExtension($file, $mime);
$path = sprintf(
'content-types/%d/%s-%s.%s',
(int) $contentType->id,
trim($kind) !== '' ? trim($kind) : 'asset',
(string) Str::uuid(),
$extension,
);
$stream = fopen((string) ($file->getRealPath() ?: $file->getPathname()), 'rb');
if ($stream === false) {
throw new RuntimeException('Unable to open uploaded content type asset.');
}
try {
$written = Storage::disk($this->diskName())->put($path, $stream, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime !== '' ? $mime : $this->mimeTypeForExtension($extension),
]);
} finally {
fclose($stream);
}
if ($written !== true) {
throw new RuntimeException('Unable to store content type asset.');
}
return $path;
}
public function deleteIfManaged(?string $path): void
{
$trimmed = trim((string) $path);
if ($trimmed === '' || str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://') || str_starts_with($trimmed, '/')) {
return;
}
if (! str_starts_with($trimmed, 'content-types/')) {
return;
}
Storage::disk($this->diskName())->delete($trimmed);
}
private function diskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function safeExtension(UploadedFile $file, string $mime): string
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported content type asset upload type.');
}
return match ($extension) {
'jpg', 'jpeg' => 'jpg',
'png' => 'png',
default => 'webp',
};
}
private function mimeTypeForExtension(string $extension): string
{
return match ($extension) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
class ContentTypeSlugResolution
{
public function __construct(
public readonly string $requestedSlug,
public readonly ?ContentType $contentType = null,
public readonly ?string $redirectSlug = null,
public readonly bool $isVirtual = false,
public readonly ?string $virtualType = null,
) {
}
public function found(): bool
{
return $this->contentType !== null || $this->isVirtual;
}
public function requiresRedirect(): bool
{
return $this->redirectSlug !== null && $this->redirectSlug !== '' && $this->redirectSlug !== $this->requestedSlug;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
use App\Models\ContentTypeSlugHistory;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class ContentTypeSlugResolver
{
public function publicContentTypes(): Collection
{
return Cache::rememberForever($this->publicListCacheKey(), function () {
return ContentType::query()
->ordered()
->get(['id', 'name', 'slug', 'description', 'order', 'hide_from_menu']);
});
}
public function toolbarContentTypes(): Collection
{
return $this->publicContentTypes()
->reject(static fn (ContentType $contentType): bool => (bool) $contentType->hide_from_menu)
->values();
}
public function resolve(string $slug, bool $allowVirtual = false): ContentTypeSlugResolution
{
$normalizedSlug = strtolower(trim($slug));
if ($allowVirtual && $this->isVirtualSlug($normalizedSlug)) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
isVirtual: true,
virtualType: $normalizedSlug,
);
}
$slugMap = $this->currentSlugMap();
if (isset($slugMap[$normalizedSlug])) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $this->publicContentTypes()->firstWhere('id', $slugMap[$normalizedSlug]),
);
}
$historyMap = $this->historySlugMap();
$redirectSlug = $historyMap[$normalizedSlug] ?? null;
if ($redirectSlug !== null) {
$contentTypeId = $slugMap[$redirectSlug] ?? null;
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $contentTypeId !== null ? $this->publicContentTypes()->firstWhere('id', $contentTypeId) : null,
redirectSlug: $redirectSlug,
);
}
return new ContentTypeSlugResolution(requestedSlug: $normalizedSlug);
}
public function reservedSlugs(): array
{
return array_values(array_unique(array_map(
static fn (string $slug): string => strtolower(trim($slug)),
(array) config('content_types.reserved_slugs', [])
)));
}
public function isReservedSlug(string $slug): bool
{
return in_array(strtolower(trim($slug)), $this->reservedSlugs(), true);
}
public function historicalSlugExists(string $slug, ?int $ignoreContentTypeId = null): bool
{
$query = ContentTypeSlugHistory::query()->where('old_slug', strtolower(trim($slug)));
if ($ignoreContentTypeId !== null) {
$query->where('content_type_id', '!=', $ignoreContentTypeId);
}
return $query->exists();
}
public function flushCaches(): void
{
Cache::forget($this->publicListCacheKey());
Cache::forget($this->slugMapCacheKey());
Cache::forget($this->historyMapCacheKey());
}
public function dynamicSitemapContentTypes(): Collection
{
return $this->publicContentTypes();
}
private function currentSlugMap(): array
{
return Cache::rememberForever($this->slugMapCacheKey(), function () {
return ContentType::query()
->ordered()
->pluck('id', 'slug')
->mapWithKeys(static fn ($id, $slug) => [strtolower((string) $slug) => (int) $id])
->all();
});
}
private function historySlugMap(): array
{
return Cache::rememberForever($this->historyMapCacheKey(), function () {
$currentSlugById = ContentType::query()
->pluck('slug', 'id')
->mapWithKeys(static fn ($slug, $id) => [(int) $id => strtolower((string) $slug)])
->all();
return ContentTypeSlugHistory::query()
->orderByDesc('id')
->get(['content_type_id', 'old_slug'])
->mapWithKeys(function (ContentTypeSlugHistory $history) use ($currentSlugById) {
$currentSlug = $currentSlugById[(int) $history->content_type_id] ?? null;
return $currentSlug !== null
? [strtolower((string) $history->old_slug) => $currentSlug]
: [];
})
->all();
});
}
private function isVirtualSlug(string $slug): bool
{
return array_key_exists($slug, (array) config('content_types.virtual_types', []));
}
private function publicListCacheKey(): string
{
return (string) config('content_types.cache.public_list_key', 'content-types.public-list');
}
private function slugMapCacheKey(): string
{
return (string) config('content_types.cache.slug_map_key', 'content-types.slug-map');
}
private function historyMapCacheKey(): string
{
return (string) config('content_types.cache.history_map_key', 'content-types.slug-history-map');
}
}

View File

@@ -112,6 +112,7 @@ final class GridFiller
return Artwork::query()
->public()
->published()
->withoutMissingThumbnails()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',

View File

@@ -147,6 +147,7 @@ final class ErrorSuggestionService
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => $md['srcset'] ?? null,
];
}

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class FeaturedArtworkAdminService
{
public function __construct(private readonly ArtworkService $artworks)
{
}
/**
* @return array<string, mixed>
*/
public function pageProps(): array
{
$now = Carbon::now();
$features = ArtworkFeature::query()
->with([
'artwork' => fn ($query) => $query->withTrashed()->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]),
])
->orderByDesc('priority')
->orderByDesc('featured_at')
->orderByDesc('id')
->get();
$duplicateCounts = $features->countBy(fn (ArtworkFeature $feature): int => (int) $feature->artwork_id);
$entries = $features
->map(fn (ArtworkFeature $feature): array => $this->mapFeature($feature, $duplicateCounts, $now))
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['eligibility']['is_eligible'] <=> (int) $left['eligibility']['is_eligible'],
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['is_active'] <=> (int) $left['is_active'],
(int) $left['is_expired'] <=> (int) $right['is_expired'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
$eligibleEntries = $this->sortForHeroSelection(
$entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->values()
);
$sharedWinnerArtworkId = $this->artworks->getFeaturedArtworkWinner()?->id;
$winner = $sharedWinnerArtworkId
? $entries->first(fn (array $entry): bool => (int) $entry['artwork_id'] === (int) $sharedWinnerArtworkId)
: null;
if (! is_array($winner) && $eligibleEntries->isNotEmpty()) {
$winner = $eligibleEntries->first();
}
$winnerReason = is_array($winner) ? $this->buildWinnerReason($winner, $eligibleEntries) : null;
$winnerId = is_array($winner) ? (int) $winner['id'] : null;
$entries = $entries
->map(function (array $entry) use ($winnerId, $winnerReason): array {
$isWinner = $winnerId !== null && (int) $entry['id'] === $winnerId;
if ($isWinner) {
array_unshift($entry['status_badges'], [
'label' => 'Winner',
'tone' => 'amber',
]);
}
$entry['is_winner'] = $isWinner;
$entry['winner_reason'] = $isWinner ? $winnerReason : null;
return $entry;
})
->values();
$winner = is_array($winner)
? array_merge($winner, ['selection_reason' => $winnerReason])
: null;
return [
'entries' => $entries->all(),
'winner' => $winner,
'stats' => [
'total' => $entries->count(),
'active' => $entries->where('is_active', true)->count(),
'inactive' => $entries->where('is_active', false)->count(),
'expired' => $entries->where('is_expired', true)->count(),
'eligible' => $entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->count(),
'ineligible' => $entries->filter(fn (array $entry): bool => ! $entry['eligibility']['is_eligible'])->count(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchArtworks(string $term, int $limit = 12): array
{
$term = trim($term);
if ($term === '') {
return [];
}
$now = Carbon::now();
$artworks = Artwork::query()
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
])
->where(function ($query) use ($term): void {
if (ctype_digit($term)) {
$query->where('artworks.id', (int) $term);
}
$query->orWhere('artworks.title', 'like', '%' . $term . '%')
->orWhere('artworks.slug', 'like', '%' . $term . '%')
->orWhereHas('user', function ($userQuery) use ($term): void {
$userQuery->where('username', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%');
})
->orWhereHas('group', function ($groupQuery) use ($term): void {
$groupQuery->where('name', 'like', '%' . $term . '%')
->orWhere('slug', 'like', '%' . $term . '%');
});
});
if (ctype_digit($term)) {
$artworks->orderByRaw('CASE WHEN artworks.id = ? THEN 0 ELSE 1 END', [(int) $term]);
}
$artworks = $artworks
->orderByDesc('published_at')
->limit($limit)
->get();
$featureCounts = ArtworkFeature::query()
->whereNull('deleted_at')
->whereIn('artwork_id', $artworks->pluck('id'))
->selectRaw('artwork_id, COUNT(*) as aggregate')
->groupBy('artwork_id')
->pluck('aggregate', 'artwork_id');
return $artworks
->map(fn (Artwork $artwork): array => $this->mapArtworkCandidate($artwork, (int) ($featureCounts[(int) $artwork->id] ?? 0), $now))
->values()
->all();
}
/**
* @param SupportCollection<int, int> $duplicateCounts
* @return array<string, mixed>
*/
private function mapFeature(ArtworkFeature $feature, SupportCollection $duplicateCounts, Carbon $now): array
{
$context = $this->mapArtworkContext($feature->artwork, $now);
$isExpired = $feature->expires_at !== null && $feature->expires_at->lte($now);
$isEligible = (bool) $feature->is_active && ! $isExpired && (bool) $context['eligibility']['is_eligible'];
$eligibilityReasons = $context['eligibility']['reasons'];
if (! $feature->is_active) {
$eligibilityReasons[] = 'Inactive';
}
if ($isExpired) {
$eligibilityReasons[] = 'Expired';
}
$statusBadges = [];
if ($feature->is_active && ! $isExpired) {
$statusBadges[] = ['label' => 'Active', 'tone' => 'emerald'];
}
if ((bool) $feature->force_hero) {
$statusBadges[] = ['label' => 'Force Hero', 'tone' => 'amber'];
}
if (! $feature->is_active) {
$statusBadges[] = ['label' => 'Inactive', 'tone' => 'slate'];
}
if ($isExpired) {
$statusBadges[] = ['label' => 'Expired', 'tone' => 'amber'];
}
$statusBadges[] = $isEligible
? ['label' => 'Eligible', 'tone' => 'sky']
: ['label' => 'Not eligible', 'tone' => 'rose'];
if ((bool) $context['flags']['is_private']) {
$statusBadges[] = ['label' => 'Private', 'tone' => 'slate'];
}
if ((bool) $context['flags']['is_unpublished']) {
$statusBadges[] = ['label' => 'Unpublished', 'tone' => 'slate'];
}
if ((bool) $context['flags']['missing_preview']) {
$statusBadges[] = ['label' => 'Missing preview', 'tone' => 'rose'];
}
if ((bool) $context['flags']['is_deleted']) {
$statusBadges[] = ['label' => 'Deleted', 'tone' => 'slate'];
}
if ((int) $duplicateCounts->get((int) $feature->artwork_id, 0) > 1) {
$statusBadges[] = ['label' => 'Duplicate', 'tone' => 'sky'];
}
return [
'id' => (int) $feature->id,
'artwork_id' => (int) $feature->artwork_id,
'priority' => (int) $feature->priority,
'featured_at' => $feature->featured_at?->toIsoString(),
'expires_at' => $feature->expires_at?->toIsoString(),
'created_at' => $feature->created_at?->toIsoString(),
'updated_at' => $feature->updated_at?->toIsoString(),
'is_active' => (bool) $feature->is_active,
'is_force_hero' => (bool) $feature->force_hero,
'is_expired' => $isExpired,
'duplicate_count' => (int) $duplicateCounts->get((int) $feature->artwork_id, 0),
'artwork' => $context['artwork'],
'medals' => $context['medals'],
'eligibility' => [
'is_eligible' => $isEligible,
'reasons' => array_values(array_unique($eligibilityReasons)),
],
'status_badges' => $statusBadges,
'is_winner' => false,
'winner_reason' => null,
];
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCandidate(Artwork $artwork, int $existingFeatureCount, Carbon $now): array
{
$context = $this->mapArtworkContext($artwork, $now);
return array_merge($context['artwork'], [
'medals' => $context['medals'],
'eligibility' => $context['eligibility'],
'existing_feature_count' => $existingFeatureCount,
'already_featured' => $existingFeatureCount > 0,
]);
}
/**
* @return array<string, mixed>
*/
private function mapArtworkContext(?Artwork $artwork, Carbon $now): array
{
if (! $artwork instanceof Artwork) {
return [
'artwork' => [
'id' => null,
'title' => 'Missing artwork',
'slug' => null,
'canonical_url' => null,
'thumbnail' => ThumbnailPresenter::present(['id' => null, 'name' => 'Missing artwork'], 'sm'),
'published_at' => null,
'visibility' => null,
'is_public' => false,
'is_approved' => false,
'has_missing_preview' => true,
'is_deleted' => true,
'owner' => null,
],
'medals' => [
'score_30d' => 0,
],
'eligibility' => [
'is_eligible' => false,
'reasons' => ['Deleted'],
],
'flags' => [
'is_private' => false,
'is_unpublished' => true,
'missing_preview' => true,
'is_deleted' => true,
],
];
}
$isDeleted = $artwork->deleted_at !== null;
$isPublic = ! $isDeleted && (bool) $artwork->is_public;
$isApproved = ! $isDeleted && (bool) $artwork->is_approved;
$isPublished = ! $isDeleted && $artwork->published_at !== null && $artwork->published_at->lte($now);
$hasPreview = ! (bool) $artwork->has_missing_thumbnails;
$owner = $this->mapOwner($artwork);
$reasons = [];
if ($isDeleted) {
$reasons[] = 'Deleted';
}
if (! $isPublic) {
$reasons[] = 'Private';
}
if (! $isApproved) {
$reasons[] = 'Not approved';
}
if (! $isPublished) {
$reasons[] = 'Unpublished';
}
if (! $hasPreview) {
$reasons[] = 'Missing preview';
}
return [
'artwork' => [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'slug' => (string) $artwork->slug,
'canonical_url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $this->artworkSlug($artwork)]),
'thumbnail' => ThumbnailPresenter::present($artwork, 'sm'),
'published_at' => $artwork->published_at?->toIsoString(),
'visibility' => (string) ($artwork->visibility ?? ''),
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'has_missing_preview' => (bool) $artwork->has_missing_thumbnails,
'is_deleted' => $isDeleted,
'owner' => $owner,
],
'medals' => [
'score_30d' => (int) ($artwork->awardStat?->score_30d ?? 0),
],
'eligibility' => [
'is_eligible' => $isPublic && $isApproved && $isPublished && $hasPreview,
'reasons' => $reasons,
],
'flags' => [
'is_private' => ! $isPublic,
'is_unpublished' => ! $isPublished,
'missing_preview' => ! $hasPreview,
'is_deleted' => $isDeleted,
],
];
}
/**
* @param SupportCollection<int, array<string, mixed>> $entries
* @return SupportCollection<int, array<string, mixed>>
*/
private function sortForHeroSelection(SupportCollection $entries): SupportCollection
{
return $entries
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
}
/**
* @param array<string, mixed> $winner
* @param SupportCollection<int, array<string, mixed>> $eligibleEntries
*/
private function buildWinnerReason(array $winner, SupportCollection $eligibleEntries): string
{
if ((bool) ($winner['is_force_hero'] ?? false)) {
return 'Forced hero override is enabled for this featured artwork.';
}
$runnerUp = $eligibleEntries->skip(1)->first();
if (! is_array($runnerUp)) {
return 'Only eligible featured artwork right now.';
}
if ((int) $winner['priority'] > (int) $runnerUp['priority']) {
return 'Highest priority among active, eligible featured artworks.';
}
if ((int) $winner['medals']['score_30d'] > (int) $runnerUp['medals']['score_30d']) {
return 'Tied on priority, won on higher 30-day medal score.';
}
if ($this->timestamp($winner['featured_at']) > $this->timestamp($runnerUp['featured_at'])) {
return 'Tied on priority and medal score, won on newer featured date.';
}
if ($this->timestamp($winner['artwork']['published_at']) > $this->timestamp($runnerUp['artwork']['published_at'])) {
return 'Tied on priority, medal score, and featured date, won on newer published date.';
}
return 'Selected by the shared homepage hero ordering.';
}
/**
* @return array<string, mixed>|null
*/
private function mapOwner(Artwork $artwork): ?array
{
if ($artwork->group) {
return [
'type' => 'group',
'display_name' => (string) $artwork->group->name,
'username' => (string) $artwork->group->slug,
'profile_url' => $artwork->group->publicUrl(),
];
}
if (! $artwork->user) {
return null;
}
return [
'type' => 'user',
'display_name' => (string) ($artwork->user->name ?: '@' . $artwork->user->username),
'username' => (string) $artwork->user->username,
'profile_url' => $artwork->user->username !== '' ? '/@' . $artwork->user->username : null,
];
}
private function artworkSlug(Artwork $artwork): string
{
$slug = trim((string) $artwork->slug);
if ($slug !== '') {
return $slug;
}
$titleSlug = Str::slug((string) $artwork->title);
return $titleSlug !== '' ? $titleSlug : (string) $artwork->id;
}
private function timestamp(?string $value): int
{
if (! is_string($value) || trim($value) === '') {
return 0;
}
return (int) (strtotime($value) ?: 0);
}
}

View File

@@ -8,6 +8,7 @@ use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -32,6 +33,7 @@ class GroupService
private readonly GroupActivityService $activity,
private readonly GroupHistoryService $history,
private readonly GroupReputationService $reputation,
private readonly ArtworkMaturityService $maturity,
) {
}
@@ -372,6 +374,8 @@ class GroupService
->where('is_approved', true)
->whereNotNull('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
if ((int) ($group->featured_artwork_id ?? 0) > 0) {
$featuredArtwork = (clone $query)
->where('id', (int) $group->featured_artwork_id)
@@ -461,14 +465,18 @@ class GroupService
public function publicArtworkCards(Group $group, int $limit = 18): array
{
return Artwork::query()
$query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->latest('published_at')
->latest('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
return $query
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
@@ -493,7 +501,7 @@ class GroupService
private function mapPublicArtworkCard(Artwork $artwork): array
{
return [
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
@@ -501,7 +509,7 @@ class GroupService
'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => $artwork->published_at?->toISOString(),
];
], $artwork, request()->user());
}
public function studioDashboardSummary(Group $group): array

View File

@@ -14,6 +14,7 @@ use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException;
use cPad\Plugins\News\Models\NewsArticle;
use App\Services\Maturity\ArtworkMaturityService;
/**
* HomepageService
@@ -32,9 +34,11 @@ use cPad\Plugins\News\Models\NewsArticle;
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug',
];
@@ -42,6 +46,7 @@ final class HomepageService
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly UserPreferenceService $prefs,
private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller,
@@ -60,9 +65,60 @@ final class HomepageService
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return $this->guestPayloadCache()->remember(
$this->guestPayloadCacheKey(),
$this->guestPayloadCacheTtl(),
fn (): array => $this->buildGuestPayload(),
);
}
public function warmGuestPayloadCache(): array
{
$payload = $this->buildGuestPayload();
$this->guestPayloadCache()->put(
$this->guestPayloadCacheKey(),
$payload,
$this->guestPayloadCacheTtl(),
);
return $payload;
}
public function clearGuestPayloadCache(): void
{
$this->guestPayloadCache()->forget($this->guestPayloadCacheKey());
}
public function clearFeaturedAndMedalCaches(): void
{
$this->clearGuestPayloadCache();
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
Cache::forget("homepage.hero.{$segment}");
Cache::forget("homepage.community-favorites.8.{$segment}");
Cache::forget("homepage.hall-of-fame.8.{$segment}");
}
}
public function guestPayloadCacheStoreName(): string
{
$configuredStore = (string) config('homepage.cache_store', 'homepage');
if (is_array(config('cache.stores.' . $configuredStore))) {
return $configuredStore;
}
return (string) config('cache.default', 'database');
}
private function buildGuestPayload(): array
{
return [
'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
@@ -77,6 +133,21 @@ final class HomepageService
];
}
private function guestPayloadCache(): CacheRepository
{
return Cache::store($this->guestPayloadCacheStoreName());
}
private function guestPayloadCacheKey(): string
{
return (string) config('homepage.guest_payload_key', 'homepage.payload.guest');
}
private function guestPayloadCacheTtl(): int
{
return max(60, (int) config('homepage.guest_payload_ttl_seconds', 1800));
}
/**
* Personalized homepage data for an authenticated user.
*
@@ -97,6 +168,8 @@ final class HomepageService
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'rising' => $this->getRising(),
@@ -127,52 +200,56 @@ final class HomepageService
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
$feed = $this->feedResolver->getFeed((int) $user->id, $limit);
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
$discoveryEndpoint = route('api.discovery.events.store');
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
return $this->filterMissingThumbnailPayloadItems(collect($feed['data'] ?? []))
->take($limit)
->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
return [
'id' => (int) ($item['id'] ?? 0),
'title' => (string) ($item['title'] ?? 'Untitled'),
'name' => (string) ($item['title'] ?? 'Untitled'),
'slug' => (string) ($item['slug'] ?? ''),
'author' => (string) ($item['author'] ?? 'Artist'),
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null,
'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
'width' => isset($item['width']) ? (int) $item['width'] : null,
'height' => isset($item['height']) ? (int) $item['height'] : null,
'published_at' => $item['published_at'] ?? null,
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => $reason,
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
'recommendation_surface' => 'homepage-for-you',
'discovery_endpoint' => $discoveryEndpoint,
'hide_artwork_endpoint' => $hideArtworkEndpoint,
'dislike_tag_endpoint' => $dislikeTagEndpoint,
'metric_badge' => [
'label' => $reason,
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
],
];
})->values()->all();
return [
'id' => (int) ($item['id'] ?? 0),
'title' => (string) ($item['title'] ?? 'Untitled'),
'name' => (string) ($item['title'] ?? 'Untitled'),
'slug' => (string) ($item['slug'] ?? ''),
'author' => (string) ($item['author'] ?? 'Artist'),
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null,
'published_as_type' => (string) ($item['published_as_type'] ?? ''),
'publisher' => is_array($item['publisher'] ?? null) ? $item['publisher'] : null,
'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
'width' => isset($item['width']) ? (int) $item['width'] : null,
'height' => isset($item['height']) ? (int) $item['height'] : null,
'published_at' => $item['published_at'] ?? null,
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => $reason,
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
'recommendation_surface' => 'homepage-for-you',
'discovery_endpoint' => $discoveryEndpoint,
'hide_artwork_endpoint' => $hideArtworkEndpoint,
'dislike_tag_endpoint' => $dislikeTagEndpoint,
'metric_badge' => [
'label' => $reason,
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
],
];
})->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
@@ -276,18 +353,18 @@ final class HomepageService
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1);
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
$artwork = $this->artworks->getFeaturedArtworkWinner();
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$artwork = $result->getCollection()->first();
} elseif ($result instanceof \Illuminate\Support\Collection) {
$artwork = $result->first();
} elseif (is_array($result)) {
$artwork = $result[0] ?? null;
} else {
$artwork = null;
if (! $artwork instanceof Artwork) {
$artwork = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->latest('published_at')
->first();
}
if ($artwork instanceof Artwork) {
@@ -298,6 +375,70 @@ final class HomepageService
});
}
public function getCommunityFavorites(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.community-favorites.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_30d, 0) > 0')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('artworks.published_at')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'community_favorites'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getCommunityFavorites failed', ['error' => $e->getMessage()]);
return [];
}
});
}
public function getHallOfFame(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.hall-of-fame.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_total, 0) > 0')
->orderByRaw('COALESCE(aas.score_total, 0) DESC')
->orderByRaw('COALESCE(aas.last_medaled_at, artworks.published_at) DESC')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'hall_of_fame'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getHallOfFame failed', ['error' => $e->getMessage()]);
return [];
}
});
}
/**
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
*
@@ -308,14 +449,12 @@ final class HomepageService
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
])
->paginate($limit, 'page', 1);
$results = $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
], $limit, true, 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
@@ -327,7 +466,7 @@ final class HomepageService
return $this->getRisingLowSignalFromDb($limit);
}
return $items
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -346,8 +485,10 @@ final class HomepageService
*/
private function getRisingFromDb(int $limit): array
{
return Artwork::public()
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
@@ -355,7 +496,9 @@ final class HomepageService
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -363,8 +506,10 @@ final class HomepageService
private function getRisingLowSignalFromDb(int $limit): array
{
return Artwork::public()
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
@@ -375,7 +520,9 @@ final class HomepageService
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -392,14 +539,12 @@ final class HomepageService
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
])
->paginate($limit, 'page', 1);
$results = $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $limit, true, 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
@@ -407,7 +552,7 @@ final class HomepageService
return $this->getTrendingFromDb($limit);
}
return $items
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -427,8 +572,10 @@ final class HomepageService
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
@@ -436,7 +583,9 @@ final class HomepageService
->orderByDesc('artwork_stats.ranking_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -450,11 +599,13 @@ final class HomepageService
{
// Include EGS mode in cache key so toggling EGS updates the section within TTL
$egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std';
$cacheKey = "homepage.fresh.{$limit}.{$egsKey}";
$cacheKey = "homepage.fresh.{$limit}.{$egsKey}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->orderByDesc('published_at')
->limit($limit)
@@ -541,6 +692,7 @@ final class HomepageService
$latestArtworkIds = Artwork::public()
->published()
->withoutMissingThumbnails()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
@@ -698,7 +850,7 @@ final class HomepageService
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'),
DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
@@ -738,11 +890,13 @@ final class HomepageService
}
return Cache::remember(
"homepage.following.{$user->id}",
"homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
60, // short TTL personal data
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
@@ -766,7 +920,13 @@ final class HomepageService
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->searchResultCollection($results);
$items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('tags', function ($tagQuery) use ($tagSlugs): void {
$tagQuery->whereIn('slug', array_slice($tagSlugs, 0, 5));
}),
);
return $items
->map(fn ($a) => $this->serializeArtwork($a))
@@ -790,7 +950,13 @@ final class HomepageService
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->searchResultCollection($results);
$items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('categories', function ($categoryQuery) use ($categorySlugs): void {
$categoryQuery->whereIn('slug', array_slice($categorySlugs, 0, 3));
}),
);
return $items
->map(fn ($a) => $this->serializeArtwork($a))
@@ -839,7 +1005,87 @@ final class HomepageService
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks;
return $artworks
->reject(fn ($artwork) => (bool) ($artwork->has_missing_thumbnails ?? false))
->values();
}
/**
* Backfill sparse homepage rails with recent archive artworks while preserving lead ordering.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection
{
$artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values();
if ($artworks->count() >= $limit) {
return $artworks;
}
$needed = $limit - $artworks->count();
$excludeIds = $artworks
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values()
->all();
$fallback = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->when($fallbackConstraint !== null, fn ($query) => $fallbackConstraint($query))
->when(! empty($excludeIds), fn ($query) => $query->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($needed)
->get();
return $artworks
->concat($fallback)
->unique('id')
->take($limit)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $items
* @return Collection<int, array<string, mixed>>
*/
private function filterMissingThumbnailPayloadItems(Collection $items): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items;
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items;
}
return $items
->reject(fn (array $item) => $missingIds->has((int) ($item['id'] ?? 0)))
->values();
}
private function collectionHasNoRisingMomentum(Collection $items): bool
@@ -875,18 +1121,32 @@ final class HomepageService
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$thumbSm = $artwork->thumbUrl('sm');
$thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg');
$thumbXl = $artwork->thumbUrl('xl');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$authorId = $artwork->user_id;
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
$thumbSrcset = collect([
$thumbSm ? $thumbSm . ' 320w' : null,
$thumbMd ? $thumbMd . ' 640w' : null,
$thumbLg ? $thumbLg . ' 1280w' : null,
$thumbXl ? $thumbXl . ' 1920w' : null,
])->filter()->implode(', ');
return [
$publisher = $this->mapArtworkPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$authorId = $artwork->user_id;
$authorName = $isGroupPublisher ? ((string) ($publisher['name'] ?? 'Skinbase Group')) : ($artwork->user?->name ?? 'Artist');
$authorUsername = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
return $this->maturity->decoratePayload([
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug,
@@ -894,9 +1154,14 @@ final class HomepageService
'author_id' => $authorId,
'author_username' => $authorUsername,
'author_avatar' => $authorAvatar,
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'thumb' => $thumb,
'thumb_sm' => $thumbSm,
'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg,
'thumb_xl' => $thumbXl,
'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -905,6 +1170,65 @@ final class HomepageService
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
'medals' => [
'gold' => (int) ($awardStat?->gold_count ?? 0),
'silver' => (int) ($awardStat?->silver_count ?? 0),
'bronze' => (int) ($awardStat?->bronze_count ?? 0),
'score' => (int) ($awardStat?->score_total ?? 0),
'score_7d' => (int) ($awardStat?->score_7d ?? 0),
'score_30d' => (int) ($awardStat?->score_30d ?? 0),
],
], $artwork, request()->user());
}
/**
* @return array<string, mixed>|null
*/
private function mapArtworkPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->relationLoaded('group') ? $artwork->group : $artwork->group()->first();
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
];
}
private function serializeArtworkWithMedalBadge(Artwork $artwork, string $surface): array
{
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$payload = $this->serializeArtwork($artwork);
$score = $surface === 'community_favorites'
? (int) ($awardStat?->score_30d ?? 0)
: (int) ($awardStat?->score_total ?? 0);
$payload['metric_badge'] = [
'label' => $surface === 'community_favorites'
? '30d medals: ' . $score
: 'All-time medals: ' . $score,
'className' => $surface === 'community_favorites'
? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
: 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30',
];
return $payload;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Services\Maturity;
use App\Models\Artwork;
use App\Models\ArtworkMaturityAuditFinding;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class ArtworkMaturityAuditService
{
public function eligibleArtworkQuery(bool $includeExistingOpenFindings = false): Builder
{
$query = Artwork::query()
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->whereRaw('TRIM(hash) != ?',[ '' ])
->whereRaw('TRIM(thumb_ext) != ?',[ '' ]);
$this->applyLegacyUnsetFilter($query);
if (! $includeExistingOpenFindings && Schema::hasTable('artwork_maturity_audit_findings')) {
$query->whereDoesntHave('maturityAuditFinding', function (Builder $finding): void {
$finding->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN);
});
}
return $query;
}
public function openFindingsQuery(): Builder
{
return ArtworkMaturityAuditFinding::query()
->with(['artwork.user.profile', 'artwork.group', 'artwork.categories.contentType'])
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->whereHas('artwork', function (Builder $query): void {
$this->applyLegacyUnsetFilter($query);
});
}
public function openFindingsCount(): int
{
if (! Schema::hasTable('artwork_maturity_audit_findings')) {
return 0;
}
return (int) $this->openFindingsQuery()->count();
}
public function isArtworkEligible(Artwork $artwork): bool
{
return ! (bool) $artwork->is_mature
&& in_array((string) ($artwork->maturity_level ?? ArtworkMaturityService::LEVEL_SAFE), ['', ArtworkMaturityService::LEVEL_SAFE], true)
&& in_array((string) ($artwork->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR), ['', ArtworkMaturityService::STATUS_CLEAR], true)
&& in_array((string) ($artwork->maturity_source ?? ArtworkMaturityService::SOURCE_LEGACY), ['', ArtworkMaturityService::SOURCE_LEGACY], true)
&& $artwork->maturity_declared_at === null
&& $artwork->maturity_reviewed_at === null;
}
/**
* @param array<string, mixed> $assessment
*/
public function shouldOpenFinding(array $assessment): bool
{
$status = Str::lower(trim((string) ($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED)));
if ($status !== ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
return false;
}
$actionHint = Str::lower(trim((string) ($assessment['action_hint'] ?? '')));
if (in_array($actionHint, [ArtworkMaturityService::AI_ACTION_REVIEW, ArtworkMaturityService::AI_ACTION_FLAG_HIGH], true)) {
return true;
}
$label = Str::lower(trim((string) ($assessment['maturity_label'] ?? '')));
$confidence = is_numeric($assessment['confidence'] ?? null) ? (float) $assessment['confidence'] : 0.0;
return $label === ArtworkMaturityService::LEVEL_MATURE
&& $confidence >= (float) config('maturity.ai.threshold', 0.68);
}
/**
* @param array<string, mixed> $assessment
*/
public function recordFinding(Artwork $artwork, array $assessment, string $thumbnailVariant): ArtworkMaturityAuditFinding
{
$finding = ArtworkMaturityAuditFinding::query()->updateOrCreate(
['artwork_id' => (int) $artwork->id],
[
'status' => ArtworkMaturityAuditFinding::STATUS_OPEN,
'thumbnail_variant' => $thumbnailVariant,
'ai_label' => $this->nullableLowerString($assessment['maturity_label'] ?? null),
'ai_confidence' => $this->nullableFloat($assessment['confidence'] ?? null),
'ai_score' => $this->nullableFloat($assessment['score'] ?? ($assessment['confidence'] ?? null)),
'ai_labels' => $this->normalizeLabels($assessment['labels'] ?? []),
'ai_model' => $this->nullableString($assessment['model'] ?? null),
'ai_threshold_used' => $this->nullableFloat($assessment['threshold_used'] ?? null),
'ai_analysis_time_ms' => is_numeric($assessment['analysis_time_ms'] ?? null) ? (int) $assessment['analysis_time_ms'] : null,
'ai_action_hint' => $this->nullableLowerString($assessment['action_hint'] ?? null),
'ai_status' => $this->nullableLowerString($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) ?? ArtworkMaturityService::AI_STATUS_FAILED,
'ai_advisory' => $this->nullableString($assessment['advisory'] ?? null),
'detected_at' => now(),
'last_scanned_at' => now(),
'resolution_action' => null,
'resolution_note' => null,
'resolved_by' => null,
'resolved_at' => null,
],
);
return $finding->fresh(['artwork']);
}
public function markFindingCleared(Artwork $artwork, ?string $note = null): void
{
ArtworkMaturityAuditFinding::query()
->where('artwork_id', (int) $artwork->id)
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->update([
'status' => ArtworkMaturityAuditFinding::STATUS_CLEARED,
'resolution_action' => 'auto_cleared',
'resolution_note' => $note,
'resolved_at' => now(),
'last_scanned_at' => now(),
]);
}
public function resolveFindingForReview(Artwork $artwork, Authenticatable $moderator, string $action, ?string $note = null): void
{
$moderatorId = (int) $moderator->getAuthIdentifier();
ArtworkMaturityAuditFinding::query()
->where('artwork_id', (int) $artwork->id)
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->update([
'status' => ArtworkMaturityAuditFinding::STATUS_REVIEWED,
'resolution_action' => Str::lower(trim($action)),
'resolution_note' => $note,
'resolved_by' => $moderatorId,
'resolved_at' => now(),
'last_scanned_at' => now(),
]);
}
private function applyLegacyUnsetFilter(Builder $query): Builder
{
return $query
->where(function (Builder $builder): void {
$builder->whereNull('maturity_declared_at')
->whereNull('maturity_reviewed_at')
->where(function (Builder $state): void {
$state->whereNull('maturity_source')
->orWhere('maturity_source', ArtworkMaturityService::SOURCE_LEGACY);
})
->where(function (Builder $state): void {
$state->whereNull('maturity_status')
->orWhere('maturity_status', ArtworkMaturityService::STATUS_CLEAR);
})
->where(function (Builder $state): void {
$state->whereNull('maturity_level')
->orWhere('maturity_level', ArtworkMaturityService::LEVEL_SAFE);
})
->where(function (Builder $state): void {
$state->whereNull('is_mature')
->orWhere('is_mature', false);
});
});
}
/**
* @param mixed $value
*/
private function nullableFloat(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
/**
* @param mixed $value
*/
private function nullableString(mixed $value): ?string
{
$resolved = trim((string) $value);
return $resolved !== '' ? $resolved : null;
}
/**
* @param mixed $value
*/
private function nullableLowerString(mixed $value): ?string
{
$resolved = $this->nullableString($value);
return $resolved !== null ? Str::lower($resolved) : null;
}
/**
* @param mixed $value
* @return list<string>|null
*/
private function normalizeLabels(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$labels = array_values(array_filter(array_map(
static fn (mixed $label): string => Str::lower(trim((string) $label)),
$value,
)));
return $labels !== [] ? array_values(array_unique($labels)) : null;
}
}

View File

@@ -0,0 +1,562 @@
<?php
declare(strict_types=1);
namespace App\Services\Maturity;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkSearchIndexer;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class ArtworkMaturityService
{
public const LEVEL_SAFE = 'safe';
public const LEVEL_MATURE = 'mature';
public const SOURCE_AI = 'ai';
public const SOURCE_LEGACY = 'legacy';
public const SOURCE_MODERATOR = 'moderator';
public const SOURCE_USER = 'user';
public const STATUS_CLEAR = 'clear';
public const STATUS_DECLARED = 'declared';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_SUSPECTED = 'suspected';
public const AI_ACTION_SAFE = 'safe';
public const AI_ACTION_ALLOW = self::AI_ACTION_SAFE;
public const AI_ACTION_REVIEW = 'review';
public const AI_ACTION_FLAG_HIGH = 'flag_high';
public const AI_STATUS_FAILED = 'failed';
public const AI_STATUS_NOT_REQUESTED = 'not_requested';
public const AI_STATUS_PENDING = 'pending';
public const AI_STATUS_SKIPPED = 'skipped';
public const AI_STATUS_SUCCEEDED = 'succeeded';
public const VIEW_BLUR = 'blur';
public const VIEW_HIDE = 'hide';
public const VIEW_SHOW = 'show';
/**
* @var array<int, array{visibility:string,warn_on_detail:bool,is_guest:bool}>
*/
private array $viewerPreferenceCache = [];
public function __construct(private readonly ArtworkSearchIndexer $searchIndexer)
{
}
/**
* @return array{visibility:string,warn_on_detail:bool,is_guest:bool}
*/
public function viewerPreferences(?User $viewer): array
{
$defaultMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.default_mode', self::VIEW_BLUR));
$defaultWarnOnDetail = (bool) config('maturity.viewer.default_warn_on_detail', true);
if (! $viewer) {
return [
'visibility' => $defaultMode,
'warn_on_detail' => $defaultWarnOnDetail,
'is_guest' => true,
];
}
$viewerId = (int) $viewer->id;
if (isset($this->viewerPreferenceCache[$viewerId])) {
return $this->viewerPreferenceCache[$viewerId];
}
$resolved = [
'visibility' => $defaultMode,
'warn_on_detail' => $defaultWarnOnDetail,
'is_guest' => false,
];
if (Schema::hasTable('user_profiles')) {
$row = DB::table('user_profiles')
->where('user_id', $viewerId)
->first(['mature_content_visibility', 'mature_content_warning_enabled']);
if ($row !== null) {
$resolved['visibility'] = $this->normalizeVisibilityPreference((string) ($row->mature_content_visibility ?? $defaultMode));
$resolved['warn_on_detail'] = array_key_exists('mature_content_warning_enabled', (array) $row)
? (bool) $row->mature_content_warning_enabled
: $defaultWarnOnDetail;
}
}
return $this->viewerPreferenceCache[$viewerId] = $resolved;
}
public function applyViewerFilter(Builder $query, ?User $viewer): Builder
{
if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) {
return $query;
}
$table = $query->getModel()->getTable();
return $query
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
->whereRaw("COALESCE(" . $table . ".maturity_status, '" . self::STATUS_CLEAR . "') != ?", [self::STATUS_SUSPECTED]);
}
public function appendSearchFilter(string $filter, ?User $viewer): string
{
$filter = trim($filter);
if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) {
return $filter;
}
$hideClause = 'is_mature_effective = false';
if ($filter === '') {
return $hideClause;
}
return $filter . ' AND ' . $hideClause;
}
public function effectiveIsMature(mixed $artwork): bool
{
$level = Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE));
$status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR));
$isMature = (bool) $this->value($artwork, 'is_mature', false);
return $isMature || $level === self::LEVEL_MATURE || $status === self::STATUS_SUSPECTED;
}
/**
* @return array<string, mixed>
*/
public function presentation(mixed $artwork, ?User $viewer): array
{
$preferences = $this->viewerPreferences($viewer);
$effectiveIsMature = $this->effectiveIsMature($artwork);
$visibilityMode = $preferences['visibility'];
$shouldHide = $effectiveIsMature && $visibilityMode === self::VIEW_HIDE;
$shouldBlur = $effectiveIsMature && ! $shouldHide && $visibilityMode !== self::VIEW_SHOW;
$requiresInterstitial = $effectiveIsMature && (bool) $preferences['warn_on_detail'];
$status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR));
$labels = $this->normalizeLabels($this->value($artwork, 'maturity_ai_labels', []));
return [
'effective_level' => $effectiveIsMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'level' => Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE)),
'source' => Str::lower((string) $this->value($artwork, 'maturity_source', self::SOURCE_LEGACY)),
'status' => $status,
'is_mature' => (bool) $this->value($artwork, 'is_mature', false),
'is_mature_effective' => $effectiveIsMature,
'ai_score' => $this->normalizeScore($this->value($artwork, 'maturity_ai_score')),
'ai_confidence' => $this->normalizeScore($this->value($artwork, 'maturity_ai_confidence', $this->value($artwork, 'maturity_ai_score'))),
'ai_label' => Str::lower((string) $this->value($artwork, 'maturity_ai_label', '')) ?: null,
'ai_labels' => $labels,
'ai_status' => Str::lower((string) $this->value($artwork, 'maturity_ai_status', self::AI_STATUS_NOT_REQUESTED)),
'ai_action_hint' => Str::lower((string) $this->value($artwork, 'maturity_ai_action_hint', '')) ?: null,
'ai_model' => $this->value($artwork, 'maturity_ai_model'),
'ai_threshold_used' => $this->normalizeScore($this->value($artwork, 'maturity_ai_threshold_used')),
'ai_analysis_time_ms' => is_numeric($this->value($artwork, 'maturity_ai_analysis_time_ms')) ? (int) $this->value($artwork, 'maturity_ai_analysis_time_ms') : null,
'ai_advisory' => $this->value($artwork, 'maturity_ai_advisory'),
'flag_reason' => $this->value($artwork, 'maturity_flag_reason'),
'is_flagged' => $status === self::STATUS_SUSPECTED,
'should_hide' => $shouldHide,
'should_blur' => $shouldBlur,
'requires_interstitial' => $requiresInterstitial,
'viewer_preference' => $visibilityMode,
'warning_title' => $effectiveIsMature ? 'Mature content warning' : null,
'warning_message' => $effectiveIsMature
? 'This artwork may contain mature material. Continue only if you want to view it.'
: null,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function decoratePayload(array $payload, mixed $artwork, ?User $viewer): array
{
$payload['maturity'] = $this->presentation($artwork, $viewer);
return $payload;
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
public function filterPayloadItems(array $items, ?User $viewer): array
{
return array_values(array_filter($items, function (array $item) use ($viewer): bool {
$maturity = Arr::get($item, 'maturity');
if (! is_array($maturity)) {
return true;
}
return ! (bool) ($maturity['should_hide'] ?? false);
}));
}
public function applyUploaderDeclaration(Artwork $artwork, bool $isMature): Artwork
{
$artwork->forceFill([
'is_mature' => $isMature,
'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'maturity_source' => self::SOURCE_USER,
'maturity_status' => $isMature ? self::STATUS_DECLARED : self::STATUS_CLEAR,
'maturity_declared_at' => now(),
'maturity_flagged_at' => $isMature ? $artwork->maturity_flagged_at : null,
'maturity_flag_reason' => $isMature ? $artwork->maturity_flag_reason : null,
])->saveQuietly();
$this->searchIndexer->update($artwork);
return $artwork;
}
/**
* @param array<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,flagged:bool}
*/
public function applyAiAssessment(Artwork $artwork, array $analysis): array
{
$assessment = $this->normalizeAiAssessment($analysis);
$labels = $assessment['labels'];
$aiStatus = $assessment['status'];
$flagged = $this->shouldFlagAssessment($artwork, $assessment);
$existingMismatchCount = (int) ($artwork->maturity_mismatch_count ?? 0);
$payload = [
'maturity_ai_score' => $assessment['confidence'],
'maturity_ai_labels' => $labels === [] ? null : $labels,
'maturity_ai_label' => $assessment['maturity_label'],
'maturity_ai_confidence' => $assessment['confidence'],
'maturity_ai_model' => $assessment['model'],
'maturity_ai_threshold_used' => $assessment['threshold_used'],
'maturity_ai_analysis_time_ms' => $assessment['analysis_time_ms'],
'maturity_ai_action_hint' => $assessment['action_hint'],
'maturity_ai_advisory' => $assessment['advisory'],
'maturity_ai_status' => $aiStatus,
'maturity_ai_detected_at' => now(),
];
if ($aiStatus !== self::AI_STATUS_SUCCEEDED) {
$artwork->forceFill($payload)->saveQuietly();
$this->searchIndexer->update($artwork);
return $assessment;
}
$artwork->forceFill(array_merge($payload, [
'maturity_status' => $flagged ? self::STATUS_SUSPECTED : ($artwork->is_mature ? self::STATUS_DECLARED : ($artwork->maturity_status ?: self::STATUS_CLEAR)),
'maturity_flagged_at' => $flagged ? now() : $artwork->maturity_flagged_at,
'maturity_flag_reason' => $flagged
? $this->buildAiFlagReason($assessment)
: $artwork->maturity_flag_reason,
'maturity_mismatch_count' => $flagged ? $existingMismatchCount + 1 : $existingMismatchCount,
]))->saveQuietly();
$this->searchIndexer->update($artwork);
return $assessment;
}
public function review(Artwork $artwork, string $action, Authenticatable $moderator, ?string $note = null): Artwork
{
$normalizedAction = Str::lower(trim($action));
$isMature = $this->effectiveIsMature($artwork);
$moderatorId = (int) $moderator->getAuthIdentifier();
if ($normalizedAction === 'mark_safe') {
$isMature = false;
}
if (in_array($normalizedAction, ['mark_mature', 'confirm'], true)) {
$isMature = true;
}
if ($normalizedAction === 'confirm_current') {
$isMature = $this->effectiveIsMature($artwork);
}
$artwork->forceFill([
'is_mature' => $isMature,
'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'maturity_source' => self::SOURCE_MODERATOR,
'maturity_status' => self::STATUS_REVIEWED,
'maturity_declared_at' => $isMature ? ($artwork->maturity_declared_at ?: now()) : $artwork->maturity_declared_at,
'maturity_reviewed_by' => $moderatorId,
'maturity_reviewed_at' => now(),
'maturity_reviewer_note' => $note,
'maturity_flag_reason' => $note ?: $artwork->maturity_flag_reason,
])->saveQuietly();
$this->searchIndexer->update($artwork);
return $artwork->fresh();
}
/**
* @param array<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,flagged:bool}
*/
public function assessAnalysis(array $analysis): array
{
$labels = [];
$score = 0.0;
$strong = collect((array) config('maturity.ai.strong_keywords', []))
->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword)))
->filter()
->values()
->all();
$medium = collect((array) config('maturity.ai.medium_keywords', []))
->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword)))
->filter()
->values()
->all();
$fragments = collect(array_merge(
$this->analysisTextFragments($analysis['clip_tags'] ?? []),
$this->analysisTextFragments($analysis['yolo_objects'] ?? []),
[Str::lower(trim((string) ($analysis['blip_caption'] ?? '')))]
))
->filter()
->unique()
->values();
foreach ($fragments as $fragment) {
foreach ($strong as $keyword) {
if (Str::contains($fragment, $keyword)) {
$score += 0.42;
$labels[] = $keyword;
}
}
foreach ($medium as $keyword) {
if (Str::contains($fragment, $keyword)) {
$score += 0.18;
$labels[] = $keyword;
}
}
}
$labels = array_values(array_unique($labels));
$score = min(1.0, round($score, 4));
return [
'confidence' => $score,
'score' => $score,
'labels' => $labels,
'flagged' => $score >= (float) config('maturity.ai.threshold', 0.68),
'status' => self::AI_STATUS_SUCCEEDED,
'maturity_label' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'action_hint' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::AI_ACTION_REVIEW : self::AI_ACTION_SAFE,
'model' => null,
'threshold_used' => (float) config('maturity.ai.threshold', 0.68),
'analysis_time_ms' => null,
'advisory' => null,
];
}
/**
* @param array<string, mixed> $analysis
* @return array{status:string,maturity_label:?string,confidence:?float,labels:array<int,string>,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float}
*/
private function normalizeAiAssessment(array $analysis): array
{
if (! $this->looksLikeNormalizedAssessment($analysis)) {
/** @var array{status:string,maturity_label:?string,confidence:?float,labels:array<int,string>,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float} $legacy */
$legacy = $this->assessAnalysis($analysis);
return $legacy;
}
$status = $this->normalizeAiStatus($analysis['status'] ?? null);
$label = $this->normalizeAiLabel($analysis['maturity_label'] ?? ($analysis['label'] ?? null));
$confidence = $this->normalizeScore($analysis['confidence'] ?? ($analysis['score'] ?? null));
$labels = $this->normalizeLabels($analysis['labels'] ?? ($analysis['maturity_ai_labels'] ?? []));
$actionHint = $this->normalizeAiActionHint($analysis['action_hint'] ?? null);
$model = is_scalar($analysis['model'] ?? null) ? trim((string) $analysis['model']) : null;
$thresholdUsed = $this->normalizeScore($analysis['threshold_used'] ?? null);
$analysisTime = is_numeric($analysis['analysis_time_ms'] ?? null) ? (int) $analysis['analysis_time_ms'] : null;
$advisory = is_scalar($analysis['advisory'] ?? null) ? trim((string) $analysis['advisory']) : null;
if ($labels === [] && $label !== null) {
$labels[] = $label;
}
$flagged = $status === self::AI_STATUS_SUCCEEDED
&& in_array($actionHint, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true);
return [
'status' => $status,
'maturity_label' => $label,
'confidence' => $confidence,
'labels' => $labels,
'action_hint' => $actionHint,
'model' => $model !== '' ? $model : null,
'threshold_used' => $thresholdUsed,
'analysis_time_ms' => $analysisTime,
'advisory' => $advisory !== '' ? $advisory : null,
'flagged' => $flagged,
'score' => $confidence,
];
}
/**
* @param array<string, mixed> $assessment
*/
private function shouldFlagAssessment(Artwork $artwork, array $assessment): bool
{
if (($assessment['status'] ?? self::AI_STATUS_FAILED) !== self::AI_STATUS_SUCCEEDED) {
return false;
}
if ((bool) $artwork->is_mature) {
return false;
}
return (bool) ($assessment['flagged'] ?? false)
|| in_array($assessment['action_hint'] ?? null, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true)
|| (($assessment['maturity_label'] ?? null) === self::LEVEL_MATURE);
}
/**
* @param array<string, mixed> $assessment
*/
private function buildAiFlagReason(array $assessment): string
{
$labels = array_slice($this->normalizeLabels($assessment['labels'] ?? []), 0, 5);
$action = $this->normalizeAiActionHint($assessment['action_hint'] ?? null);
$prefix = match ($action) {
self::AI_ACTION_FLAG_HIGH => 'AI flagged high-confidence mature content',
self::AI_ACTION_REVIEW => 'AI requested moderation review for mature content',
default => 'AI suspected mature content',
};
if ($labels === []) {
return $prefix . '.';
}
return $prefix . ' from: ' . implode(', ', $labels);
}
/**
* @param array<int, mixed> $rows
* @return array<int, string>
*/
private function analysisTextFragments(array $rows): array
{
return collect($rows)
->map(function (mixed $row): string {
if (is_array($row)) {
return Str::lower(trim((string) ($row['tag'] ?? $row['label'] ?? $row['name'] ?? '')));
}
return Str::lower(trim((string) $row));
})
->filter()
->values()
->all();
}
private function normalizeVisibilityPreference(string $value): string
{
return match (Str::lower(trim($value))) {
self::VIEW_HIDE => self::VIEW_HIDE,
self::VIEW_SHOW => self::VIEW_SHOW,
default => self::VIEW_BLUR,
};
}
/**
* @return array<int, string>
*/
private function normalizeLabels(mixed $labels): array
{
if (! is_array($labels)) {
return [];
}
return collect($labels)
->map(static fn (mixed $label): string => trim((string) $label))
->filter()
->values()
->all();
}
private function normalizeScore(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/**
* @param array<string, mixed> $analysis
*/
private function looksLikeNormalizedAssessment(array $analysis): bool
{
return array_key_exists('maturity_label', $analysis)
|| array_key_exists('action_hint', $analysis)
|| array_key_exists('status', $analysis)
|| array_key_exists('threshold_used', $analysis)
|| array_key_exists('analysis_time_ms', $analysis);
}
private function normalizeAiStatus(mixed $value): string
{
return match (Str::lower(trim((string) $value))) {
self::AI_STATUS_PENDING => self::AI_STATUS_PENDING,
self::AI_STATUS_SKIPPED => self::AI_STATUS_SKIPPED,
self::AI_STATUS_SUCCEEDED => self::AI_STATUS_SUCCEEDED,
self::AI_STATUS_NOT_REQUESTED => self::AI_STATUS_NOT_REQUESTED,
default => self::AI_STATUS_FAILED,
};
}
private function normalizeAiLabel(mixed $value): ?string
{
return match (Str::lower(trim((string) $value))) {
self::LEVEL_SAFE => self::LEVEL_SAFE,
self::LEVEL_MATURE => self::LEVEL_MATURE,
'adult', 'explicit', 'nsfw' => self::LEVEL_MATURE,
'clear', 'sfw' => self::LEVEL_SAFE,
default => null,
};
}
private function normalizeAiActionHint(mixed $value): ?string
{
return match (Str::lower(trim((string) $value))) {
self::AI_ACTION_SAFE, self::AI_ACTION_ALLOW, 'mark_safe', 'allow' => self::AI_ACTION_SAFE,
self::AI_ACTION_REVIEW, 'queue', 'suspect' => self::AI_ACTION_REVIEW,
self::AI_ACTION_FLAG_HIGH, 'block', 'mark_mature', 'mature' => self::AI_ACTION_FLAG_HIGH,
default => null,
};
}
private function value(mixed $artwork, string $key, mixed $default = null): mixed
{
if ($artwork instanceof Artwork) {
return $artwork->getAttribute($key) ?? $default;
}
if (is_array($artwork)) {
return $artwork[$key] ?? $default;
}
if (! is_object($artwork)) {
return $default;
}
return $artwork->{$key} ?? $default;
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Detects inactivity gaps in a creator's public artwork history and
* returns milestone rows for any comeback events.
*
* Thresholds:
* Minor: 180364 days gap
* Major: 3651094 days gap (13 years)
* Legendary: 1095+ days gap (3+ years)
*/
final class CreatorComebackService
{
private const MINOR_DAYS = 180;
private const MAJOR_DAYS = 365;
private const LEGENDARY_DAYS = 1095;
/**
* Given the ordered collection of public artwork rows (ascending by published_at),
* detect all comeback events and return milestone row arrays.
*
* @param Collection<int, object> $artworks rows from publicArtworkRows()
* @param int $userId
* @param CarbonInterface $computedAt
* @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow
* @return array<int, array<string, mixed>>
*/
public function calculateComebacks(
Collection $artworks,
int $userId,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->count() < 2) {
return [];
}
$sorted = $artworks
->filter(fn (object $row): bool => ! empty($row->published_at))
->sortBy([['published_at', 'asc'], ['id', 'asc']])
->values();
$milestones = [];
$prevDate = null;
foreach ($sorted as $artwork) {
$currentDate = $this->parseDate($artwork->published_at);
if ($prevDate !== null && $currentDate !== null) {
$gapDays = (int) $prevDate->diffInDays($currentDate);
$type = $this->comebackTypeForGap($gapDays);
if ($type !== null) {
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$currentDate,
$this->buildPayload($type, $gapDays, $prevDate, $artwork),
(int) $artwork->id,
$computedAt,
);
// Only record one comeback per gap: if we match legendary, skip major/minor for same gap.
// prevDate resets after each comeback so consecutive short-gap uploads won't double-count.
}
}
// Only advance prevDate when the gap did NOT trigger a comeback.
// After a comeback, the "chain" resets from the new return date.
$prevDate = $currentDate;
}
return $milestones;
}
private function comebackTypeForGap(int $gapDays): ?CreatorMilestoneType
{
if ($gapDays >= self::LEGENDARY_DAYS) {
return CreatorMilestoneType::ComebackLegendary;
}
if ($gapDays >= self::MAJOR_DAYS) {
return CreatorMilestoneType::ComebackMajor;
}
if ($gapDays >= self::MINOR_DAYS) {
return CreatorMilestoneType::ComebackMinor;
}
return null;
}
/**
* @return array<string, mixed>
*/
private function buildPayload(
CreatorMilestoneType $type,
int $gapDays,
CarbonInterface $previousUploadAt,
object $artwork,
): array {
$years = (int) round($gapDays / 365);
$months = (int) round($gapDays / 30);
$durationLabel = match (true) {
$years >= 3 => $years . ' years',
$years >= 1 => $years === 1 ? 'a year' : $years . ' years',
$months >= 2 => $months . ' months',
default => 'several months',
};
$summaryMap = [
CreatorMilestoneType::ComebackMinor->value => "Returned to Skinbase after {$durationLabel} away with a new public upload.",
CreatorMilestoneType::ComebackMajor->value => "Major comeback after {$durationLabel} away — new work published again on Skinbase.",
CreatorMilestoneType::ComebackLegendary->value => "Returned to Skinbase after {$durationLabel} away, picking up where the journey left off.",
];
$titleMap = [
CreatorMilestoneType::ComebackMinor->value => 'Comeback',
CreatorMilestoneType::ComebackMajor->value => 'Major comeback',
CreatorMilestoneType::ComebackLegendary->value => 'Legendary comeback',
];
return [
'title' => $titleMap[$type->value] ?? 'Comeback',
'headline' => (string) $artwork->title,
'summary' => $summaryMap[$type->value] ?? "Returned after {$durationLabel}.",
'value' => "After {$durationLabel}",
'artwork' => $this->artworkSnapshot($artwork),
'metadata' => [
'previous_upload_at' => $previousUploadAt->toIso8601String(),
'gap_days' => $gapDays,
'comeback_level' => $this->levelLabel($type),
],
];
}
private function levelLabel(CreatorMilestoneType $type): string
{
return match ($type) {
CreatorMilestoneType::ComebackMinor => 'minor',
CreatorMilestoneType::ComebackMajor => 'major',
CreatorMilestoneType::ComebackLegendary => 'legendary',
default => 'minor',
};
}
/**
* @return array<string, mixed>
*/
private function artworkSnapshot(object $artwork): array
{
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) ($artwork->slug ?? $artwork->id),
'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
];
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use App\Models\Artwork;
use App\Models\CreatorEra;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Generates deterministic creator eras from a creator's public artwork history.
*
* Era types (assigned in order):
* early_years from first upload until a breakthrough signal
* breakthrough starts at first featured artwork or first major download milestone
* experimental detected when a creator shows high category/tag diversity with lower volume
* comeback starts after a significant inactivity gap (180+ days) followed by new publishing
* current the latest ongoing active phase (always set for active creators)
*
* Rules:
* - Only public artworks are considered.
* - Era boundaries are determined by key events (features, comebacks).
* - At most one era of each non-current type is created per rebuild.
* - The "current" era is always the last active phase.
*/
final class CreatorEraService
{
private const COMEBACK_GAP_DAYS = 180;
/**
* Rebuild all eras for a user: delete existing rows and reinsert computed ones.
*
* @param Collection<int, object> $artworks public artwork rows (ascending by published_at)
*/
public function rebuildForUser(User $user, Collection $artworks): void
{
$eras = $this->computeEras($user, $artworks);
DB::transaction(function () use ($user, $eras): void {
CreatorEra::query()->where('user_id', (int) $user->id)->delete();
if ($eras !== []) {
DB::table('creator_eras')->insert($eras);
}
});
}
/**
* Return the public era payload for the journey API.
*
* @return list<array<string, mixed>>
*/
public function publicErasForUser(int $userId): array
{
return CreatorEra::query()
->where('user_id', $userId)
->orderBy('starts_at')
->get()
->map(fn (CreatorEra $era): array => $this->formatEra($era))
->values()
->all();
}
/**
* Compute milestone rows for era_started events.
*
* @param Collection<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
public function calculateEraMilestones(
User $user,
Collection $artworks,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->isEmpty()) {
return [];
}
$eras = $this->computeEras($user, $artworks);
$milestones = [];
foreach ($eras as $era) {
if (in_array($era['era_type'], ['early_years', 'current'], true)) {
continue; // Only notable era transitions get milestone rows
}
$occurredAt = Carbon::parse($era['starts_at']);
$milestones[] = $makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::EraStarted,
$occurredAt,
[
'title' => 'New era',
'headline' => $era['title'],
'summary' => $era['description'] ?? 'A new creative phase began.',
'value' => $era['title'],
'metadata' => ['era_type' => $era['era_type']],
],
null,
$computedAt,
);
}
return $milestones;
}
/**
* @param Collection<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
private function computeEras(User $user, Collection $artworks): array
{
if ($artworks->isEmpty()) {
return [];
}
$sorted = $artworks
->filter(fn (object $row): bool => ! empty($row->published_at))
->sortBy([['published_at', 'asc'], ['id', 'asc']])
->values();
if ($sorted->isEmpty()) {
return [];
}
$now = Carbon::now();
$userId = (int) $user->id;
$eras = [];
$firstArtwork = $sorted->first();
$firstDate = Carbon::parse($firstArtwork->published_at);
$lastArtwork = $sorted->last();
$lastDate = Carbon::parse($lastArtwork->published_at);
// Detect featured date (breakthrough signal)
$firstFeaturedAt = $this->firstFeaturedDate($userId);
$firstMajorDownloadAt = $this->firstMajorDownloadDate($sorted);
// Detect comeback gap
$comebackDate = $this->firstComebackDate($sorted);
// Phase boundaries
$breakthroughAt = match (true) {
$firstFeaturedAt !== null => $firstFeaturedAt,
$firstMajorDownloadAt !== null => $firstMajorDownloadAt,
default => null,
};
// ── Early Years ────────────────────────────────────────────────────
$earlyYearsEnds = $breakthroughAt?->copy()->subSecond()
?? $comebackDate?->copy()->subSecond()
?? null;
$eras[] = [
'user_id' => $userId,
'era_type' => 'early_years',
'title' => 'Early Years',
'description' => 'The beginning of the creative journey on Skinbase.',
'starts_at' => $firstDate->toDateTimeString(),
'ends_at' => $earlyYearsEnds?->toDateTimeString(),
'is_current' => false,
'metadata' => json_encode($this->eraMetadata($sorted, $firstDate, $earlyYearsEnds ?? $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
// ── Breakthrough Era ───────────────────────────────────────────────
if ($breakthroughAt !== null) {
$breakthroughEnds = $comebackDate?->copy()->subSecond() ?? null;
$eras[] = [
'user_id' => $userId,
'era_type' => 'breakthrough',
'title' => 'Breakthrough Era',
'description' => 'A period marked by first recognition — featured work, strong downloads, and growing visibility.',
'starts_at' => $breakthroughAt->toDateTimeString(),
'ends_at' => $breakthroughEnds?->toDateTimeString(),
'is_current' => false,
'metadata' => json_encode($this->eraMetadata($sorted, $breakthroughAt, $breakthroughEnds ?? $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
}
// ── Comeback Era ───────────────────────────────────────────────────
if ($comebackDate !== null) {
// Comeback era encompasses everything from the comeback to now (or next major event)
$eras[] = [
'user_id' => $userId,
'era_type' => 'comeback',
'title' => 'Comeback Era',
'description' => 'A return to creative work on Skinbase after a significant break.',
'starts_at' => $comebackDate->toDateTimeString(),
'ends_at' => null,
'is_current' => true,
'metadata' => json_encode($this->eraMetadata($sorted, $comebackDate, $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
} else {
// ── Current Era ───────────────────────────────────────────────
// Only set if there's been activity in the last 2 years
$twoYearsAgo = $now->copy()->subYears(2);
if ($lastDate->greaterThanOrEqualTo($twoYearsAgo)) {
$currentStart = $breakthroughAt ?? $firstDate;
// Don't double-stamp if breakthrough era is already current
if ($breakthroughAt === null || $currentStart->equalTo($firstDate)) {
$eras[] = [
'user_id' => $userId,
'era_type' => 'current',
'title' => 'Current Era',
'description' => 'The latest active creative phase on Skinbase.',
'starts_at' => $currentStart->toDateTimeString(),
'ends_at' => null,
'is_current' => true,
'metadata' => json_encode($this->eraMetadata($sorted, $currentStart, $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
} else {
// Mark breakthrough as current
$lastIdx = count($eras) - 1;
$eras[$lastIdx]['is_current'] = true;
$eras[$lastIdx]['ends_at'] = null;
}
}
}
// Deduplicate: ensure we don't have two is_current=true if an era was edited above
$currentCount = count(array_filter($eras, fn ($e) => $e['is_current']));
if ($currentCount > 1) {
// Only the last is_current one stays
$found = false;
for ($i = count($eras) - 1; $i >= 0; $i--) {
if ($eras[$i]['is_current']) {
if ($found) {
$eras[$i]['is_current'] = false;
} else {
$found = true;
}
}
}
}
return $eras;
}
/**
* @param Collection<int, object> $artworks
*/
private function eraMetadata(Collection $artworks, CarbonInterface $from, CarbonInterface $to): array
{
$inRange = $artworks->filter(function (object $artwork) use ($from, $to): bool {
$date = empty($artwork->published_at) ? null : Carbon::parse($artwork->published_at);
if ($date === null) {
return false;
}
return $date->greaterThanOrEqualTo($from) && $date->lessThanOrEqualTo($to);
});
$uploads = $inRange->count();
$downloads = $inRange->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0));
$topArtwork = $inRange->sortByDesc(fn ($a): float => (float) ($a->stat_downloads ?? 0))->first();
$years = $inRange
->map(fn ($a): int => (int) Carbon::parse($a->published_at)->year)
->unique()
->sort()
->values()
->all();
return [
'uploads_count' => $uploads,
'downloads' => $downloads,
'dominant_years' => $years,
'top_artwork_id' => $topArtwork ? (int) $topArtwork->id : null,
];
}
private function firstFeaturedDate(int $userId): ?CarbonInterface
{
$row = DB::table('artwork_features as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('af.deleted_at')
->where('af.is_active', true)
->orderBy('af.featured_at')
->first(['af.featured_at']);
return $row ? Carbon::parse($row->featured_at) : null;
}
/**
* @param Collection<int, object> $sorted
*/
private function firstMajorDownloadDate(Collection $sorted): ?CarbonInterface
{
// Threshold: artwork with 500+ downloads is considered a "major" milestone
$artwork = $sorted->first(fn ($a): bool => (int) ($a->stat_downloads ?? 0) >= 500);
return $artwork ? Carbon::parse($artwork->published_at) : null;
}
/**
* @param Collection<int, object> $sorted
*/
private function firstComebackDate(Collection $sorted): ?CarbonInterface
{
$prevDate = null;
foreach ($sorted as $artwork) {
$currentDate = Carbon::parse($artwork->published_at);
if ($prevDate !== null) {
$gapDays = (int) $prevDate->diffInDays($currentDate);
if ($gapDays >= self::COMEBACK_GAP_DAYS) {
return $currentDate;
}
}
$prevDate = $currentDate;
}
return null;
}
/**
* @return array<string, mixed>
*/
private function formatEra(CreatorEra $era): array
{
return [
'type' => $era->era_type,
'title' => $era->title,
'description' => $era->description,
'starts_at' => $era->starts_at->toIso8601String(),
'ends_at' => $era->ends_at?->toIso8601String(),
'is_current' => $era->is_current,
'stats' => $era->metadata ?? [],
];
}
}

View File

@@ -0,0 +1,986 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use App\Jobs\RebuildCreatorJourneyJob;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\CreatorMilestone;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Models\User;
use App\Services\Profile\CreatorComebackService;
use App\Services\Profile\CreatorEraService;
use App\Services\Profile\CreatorStreakService;
use App\Services\Ranking\ArtworkRankingService;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class CreatorJourneyService
{
private const PUBLIC_CACHE_TTL_SECONDS = 900;
private const REBUILD_DEBOUNCE_SECONDS = 300;
public function __construct(
private readonly ArtworkRankingService $ranking,
private readonly CreatorComebackService $comebacks,
private readonly CreatorStreakService $streaks,
private readonly CreatorEraService $eras,
) {
}
public function publicPayloadForUser(User|int $user): array
{
$resolvedUser = $this->resolveUser($user);
$userId = (int) $resolvedUser->id;
$version = $this->cacheVersion($userId);
return Cache::remember(
sprintf('creator_journey:public:%d:v%d', $userId, $version),
now()->addSeconds(self::PUBLIC_CACHE_TTL_SECONDS),
function () use ($resolvedUser, $userId): array {
$rows = CreatorMilestone::query()
->where('user_id', $userId)
->where('is_public', true)
->orderByDesc('occurred_at')
->orderByDesc('priority')
->orderByDesc('id')
->get();
if ($rows->isEmpty()) {
$this->rebuildForUser($resolvedUser);
$rows = CreatorMilestone::query()
->where('user_id', $userId)
->where('is_public', true)
->orderByDesc('occurred_at')
->orderByDesc('priority')
->orderByDesc('id')
->get();
}
// v2: gather eras, evolution, and streak stats
$eraData = Schema::hasTable('creator_eras') ? $this->eras->publicErasForUser($userId) : [];
$evolutionData = Schema::hasTable('artwork_relations') ? $this->evolutionPayloadForUser($userId) : [];
$artworks = $this->publicArtworkRows($userId);
$streakStats = $this->streaks->computeStreakStats($artworks);
return $this->formatPublicPayload($resolvedUser, $rows, $eraData, $evolutionData, $streakStats);
}
);
}
/**
* @return array{milestones_saved:int}
*/
public function rebuildForUser(User|int $user): array
{
$resolvedUser = $this->resolveUser($user);
$userId = (int) $resolvedUser->id;
$computedAt = now();
$rows = $this->calculateMilestones($resolvedUser, $computedAt);
DB::transaction(function () use ($userId, $rows): void {
CreatorMilestone::query()->where('user_id', $userId)->delete();
if ($rows !== []) {
DB::table('creator_milestones')->insert($rows);
}
});
// Rebuild eras in the same pass (separate table, transactional independently)
$artworks = $this->publicArtworkRows($userId);
$this->eras->rebuildForUser($resolvedUser, $artworks);
Cache::forget($this->rebuildDebounceKey($userId));
$this->bumpCacheVersion($userId);
return ['milestones_saved' => count($rows)];
}
public function requestRebuild(int $userId, bool $force = false): void
{
if ($userId <= 0) {
return;
}
if (! $force && ! Cache::add($this->rebuildDebounceKey($userId), true, now()->addSeconds(self::REBUILD_DEBOUNCE_SECONDS))) {
return;
}
RebuildCreatorJourneyJob::dispatch([$userId]);
}
public function invalidateUser(int $userId): void
{
if ($userId <= 0) {
return;
}
$this->bumpCacheVersion($userId);
}
/**
* @return array<int, array<string, mixed>>
*/
private function calculateMilestones(User $user, CarbonInterface $computedAt): array
{
$artworks = $this->publicArtworkRows((int) $user->id);
$milestones = [];
if ($firstUpload = $artworks->sortBy([['published_at', 'asc'], ['id', 'asc']])->first()) {
$occurredAt = $this->parseDate($firstUpload->published_at);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::FirstUpload,
$occurredAt,
[
'title' => 'First upload',
'headline' => (string) $firstUpload->title,
'summary' => 'Started the public journey with the first published work on Skinbase.',
'value' => $this->displayDate($occurredAt),
'artwork' => $this->artworkSnapshot($firstUpload),
],
(int) $firstUpload->id,
$computedAt,
);
}
if ($firstFeatured = $this->firstFeaturedArtwork((int) $user->id)) {
$occurredAt = $this->parseDate($firstFeatured->featured_at);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::FirstFeaturedArtwork,
$occurredAt,
[
'title' => 'First featured artwork',
'headline' => (string) $firstFeatured->title,
'summary' => 'Earned a first featured slot on the public artwork lineup.',
'value' => $this->displayDate($occurredAt),
'artwork' => $this->artworkSnapshot($firstFeatured),
],
(int) $firstFeatured->id,
$computedAt,
);
}
if ($firstGroupRelease = $this->firstGroupRelease((int) $user->id)) {
$occurredAt = $this->parseDate($firstGroupRelease->released_on);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::FirstGroupRelease,
$occurredAt,
[
'title' => 'First group release',
'headline' => (string) $firstGroupRelease->release_title,
'summary' => 'Joined the first public group release as a credited contributor.',
'value' => (string) $firstGroupRelease->group_name,
'release' => [
'id' => (int) $firstGroupRelease->release_id,
'title' => (string) $firstGroupRelease->release_title,
'group_name' => (string) $firstGroupRelease->group_name,
'url' => url('/groups/' . $firstGroupRelease->group_slug . '/releases/' . $firstGroupRelease->release_slug),
],
],
null,
$computedAt,
);
}
if ($bestSpike = $this->biggestDownloadSpike($artworks)) {
$occurredAt = $this->parseDate($bestSpike['occurred_at']);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::BiggestDownloadSpike,
$occurredAt,
[
'title' => 'Biggest download spike',
'headline' => (string) $bestSpike['artwork']->title,
'summary' => 'Captured the strongest one-hour download burst recorded for a public artwork.',
'value' => (int) $bestSpike['downloads_in_hour'] . ' downloads in 1 hour',
'artwork' => $this->artworkSnapshot($bestSpike['artwork']),
'metrics' => [
'downloads_in_hour' => (int) $bestSpike['downloads_in_hour'],
],
],
(int) $bestSpike['artwork']->id,
$computedAt,
);
}
if ($bestPerforming = $this->bestPerformingArtwork($artworks)) {
$occurredAt = $this->parseDate($bestPerforming->published_at);
$score = $this->basePerformanceScore($bestPerforming);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::BestPerformingWork,
$occurredAt,
[
'title' => 'Best-performing work',
'headline' => (string) $bestPerforming->title,
'summary' => 'Leads the public catalog on total engagement across views, downloads, favourites, comments, and shares.',
'value' => number_format($score, 1) . ' performance points',
'artwork' => $this->artworkSnapshot($bestPerforming),
'metrics' => $this->artworkMetricSnapshot($bestPerforming) + ['performance_score' => round($score, 2)],
],
(int) $bestPerforming->id,
$computedAt,
);
}
if ($mostProductiveYear = $this->mostProductiveYear($artworks)) {
$occurredAt = $this->parseDate($mostProductiveYear['last_published_at']);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::MostProductiveYear,
$occurredAt,
[
'title' => 'Most productive year',
'headline' => (string) $mostProductiveYear['year'],
'summary' => 'Published the highest number of public artworks in a single year.',
'value' => (int) $mostProductiveYear['uploads_count'] . ' public uploads',
'metrics' => [
'year' => (int) $mostProductiveYear['year'],
'uploads_count' => (int) $mostProductiveYear['uploads_count'],
],
],
null,
$computedAt,
);
}
// ── v2: Comeback milestones ────────────────────────────────────────
foreach ($this->comebacks->calculateComebacks($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
$milestones[] = $row;
}
// ── v2: Streak milestones ─────────────────────────────────────────
foreach ($this->streaks->calculateStreakMilestones($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
$milestones[] = $row;
}
// ── v2: Era milestones ────────────────────────────────────────────
foreach ($this->eras->calculateEraMilestones($user, $artworks, $computedAt, $this->makeMilestoneRow(...)) as $row) {
$milestones[] = $row;
}
// ── v2: Evolution / Before-Now milestones ─────────────────────────
foreach ($this->evolutionMilestonesForUser((int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
$milestones[] = $row;
}
foreach ($this->yearlyRecaps($artworks) as $recap) {
$occurredAt = $this->parseDate($recap['last_published_at']);
$milestones[] = $this->makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::YearlyRecap,
$occurredAt,
[
'title' => $recap['year'] . ' recap',
'headline' => $recap['uploads_count'] . ' public uploads',
'summary' => $recap['downloads'] . ' downloads, ' . number_format((int) $recap['views']) . ' views, and ' . $recap['favorites'] . ' favourites across the year.',
'value' => (string) $recap['year'],
'artwork' => $recap['top_artwork'] !== null ? $this->artworkSnapshot($recap['top_artwork']) : null,
'metrics' => [
'year' => (int) $recap['year'],
'uploads_count' => (int) $recap['uploads_count'],
'views' => (int) $recap['views'],
'downloads' => (int) $recap['downloads'],
'favorites' => (int) $recap['favorites'],
'comments_count' => (int) $recap['comments_count'],
'shares_count' => (int) $recap['shares_count'],
'featured_count' => (int) $recap['featured_count'],
'performance_score' => round((float) $recap['performance_score'], 2),
'top_category' => $recap['top_category'] ?? null,
'best_month' => $recap['best_month'] ?? null,
'year_status' => $recap['year_status'] ?? 'steady',
],
'shareable_recap' => [
'type' => 'yearly_recap',
'year' => (int) $recap['year'],
'title' => 'My ' . $recap['year'] . ' on Skinbase',
'stats' => [
'uploads' => (int) $recap['uploads_count'],
'downloads' => (int) $recap['downloads'],
'featured' => (int) $recap['featured_count'],
],
'top_artwork' => $recap['top_artwork'] !== null ? [
'id' => (int) $recap['top_artwork']->id,
'title' => (string) $recap['top_artwork']->title,
] : null,
],
],
$recap['top_artwork'] !== null ? (int) $recap['top_artwork']->id : null,
$computedAt,
);
}
return collect($milestones)
->sortBy([
['occurred_at', 'desc'],
['priority', 'desc'],
])
->values()
->all();
}
private function publicArtworkRows(int $userId): Collection
{
return DB::table('artworks as a')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'a.id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->where(function ($query): void {
$query->whereNull('a.visibility')
->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
})
->whereNotNull('a.published_at')
->where('a.published_at', '<=', now())
->orderBy('a.published_at')
->orderBy('a.id')
->get([
'a.id',
'a.title',
'a.slug',
'a.published_at',
'a.created_at',
's.views as stat_views',
's.downloads as stat_downloads',
's.favorites as stat_favorites',
's.comments_count as stat_comments_count',
's.shares_count as stat_shares_count',
's.downloads_1h as stat_downloads_1h',
's.heat_score_updated_at as stat_heat_score_updated_at',
]);
}
private function firstFeaturedArtwork(int $userId): ?object
{
return DB::table('artwork_features as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->where(function ($query): void {
$query->whereNull('a.visibility')
->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
})
->whereNotNull('a.published_at')
->whereNull('af.deleted_at')
->where('af.is_active', true)
->orderBy('af.featured_at')
->orderBy('a.id')
->first([
'a.id',
'a.title',
'a.slug',
'a.published_at',
'af.featured_at',
]);
}
private function firstGroupRelease(int $userId): ?object
{
return DB::table('group_release_contributors as grc')
->join('group_releases as gr', 'gr.id', '=', 'grc.group_release_id')
->join('groups as g', 'g.id', '=', 'gr.group_id')
->where('grc.user_id', $userId)
->whereNull('gr.deleted_at')
->where('gr.visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('gr.status', GroupRelease::STATUS_RELEASED)
->where('g.visibility', Group::VISIBILITY_PUBLIC)
->where('g.status', Group::LIFECYCLE_ACTIVE)
->whereNotNull('gr.released_at')
->where('gr.released_at', '<=', now())
->orderBy('gr.released_at')
->orderBy('gr.id')
->first([
'gr.id as release_id',
'gr.title as release_title',
'gr.slug as release_slug',
'gr.released_at as released_on',
'g.name as group_name',
'g.slug as group_slug',
]);
}
/**
* @param Collection<int, object> $artworks
* @return array{artwork:object,downloads_in_hour:int,occurred_at:string}|null
*/
private function biggestDownloadSpike(Collection $artworks): ?array
{
$best = null;
$publicArtworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();
if ($publicArtworkIds !== [] && DB::getSchemaBuilder()->hasTable('artwork_metric_snapshots_hourly')) {
$snapshots = DB::table('artwork_metric_snapshots_hourly as ms')
->whereIn('ms.artwork_id', $publicArtworkIds)
->orderBy('ms.artwork_id')
->orderBy('ms.bucket_hour')
->get([
'ms.artwork_id',
'ms.bucket_hour',
'ms.downloads_count',
]);
$byArtwork = $artworks->keyBy('id');
$previous = [];
foreach ($snapshots as $snapshot) {
$artwork = $byArtwork->get((int) $snapshot->artwork_id);
if (! $artwork) {
continue;
}
$priorCount = $previous[(int) $snapshot->artwork_id] ?? null;
if ($priorCount !== null) {
$delta = max(0, (int) $snapshot->downloads_count - $priorCount['downloads_count']);
if ($delta > 0 && ($best === null || $delta > $best['downloads_in_hour'] || ($delta === $best['downloads_in_hour'] && $snapshot->bucket_hour > $best['occurred_at']))) {
$best = [
'artwork' => $artwork,
'downloads_in_hour' => $delta,
'occurred_at' => (string) $snapshot->bucket_hour,
];
}
}
$previous[(int) $snapshot->artwork_id] = [
'downloads_count' => (int) $snapshot->downloads_count,
];
}
}
if ($best !== null) {
return $best;
}
$fallback = $artworks
->filter(fn ($artwork): bool => (int) ($artwork->stat_downloads_1h ?? 0) > 0)
->sortBy([
fn ($artwork): int => -1 * (int) ($artwork->stat_downloads_1h ?? 0),
fn ($artwork): string => (string) ($artwork->stat_heat_score_updated_at ?? $artwork->published_at ?? ''),
])
->first();
if (! $fallback) {
return null;
}
return [
'artwork' => $fallback,
'downloads_in_hour' => (int) ($fallback->stat_downloads_1h ?? 0),
'occurred_at' => (string) ($fallback->stat_heat_score_updated_at ?? $fallback->published_at),
];
}
private function bestPerformingArtwork(Collection $artworks): ?object
{
return $artworks
->filter(fn ($artwork): bool => $this->basePerformanceScore($artwork) > 0)
->sortBy([
fn ($artwork): float => -1 * $this->basePerformanceScore($artwork),
fn ($artwork): string => (string) ($artwork->published_at ?? ''),
])
->first();
}
/**
* @param Collection<int, object> $artworks
* @return array{year:int,uploads_count:int,last_published_at:string}|null
*/
private function mostProductiveYear(Collection $artworks): ?array
{
return $artworks
->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
->map(function (Collection $items, int $year): array {
$lastPublishedAt = $items
->sortByDesc('published_at')
->first()?->published_at;
return [
'year' => $year,
'uploads_count' => $items->count(),
'last_published_at' => (string) $lastPublishedAt,
];
})
->sortBy([
fn (array $row): int => -1 * (int) $row['uploads_count'],
fn (array $row): int => -1 * (int) $row['year'],
])
->first();
}
/**
* @param Collection<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
private function yearlyRecaps(Collection $artworks): array
{
// Fetch featured counts per year once (keyed by year)
$featuredByYear = $this->featuredCountsByYear($artworks);
return $artworks
->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
->map(function (Collection $items, int $year) use ($featuredByYear): array {
$topArtwork = $items
->sortByDesc(fn ($artwork): float => $this->basePerformanceScore($artwork))
->first();
$downloads = $items->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0));
$uploads = $items->count();
$featured = (int) ($featuredByYear[$year] ?? 0);
$perfScore = $items->sum(fn ($a): float => $this->basePerformanceScore($a));
// Best month: which calendar month had the most uploads
$bestMonth = $items
->groupBy(fn ($a): string => date('Y-m', strtotime((string) $a->published_at)))
->map(fn (Collection $g): int => $g->count())
->sortDesc()
->keys()
->first();
// Top category from artwork pivot (best effort — requires subquery or separate call)
$topCategory = $this->topCategoryForYear($items);
// Year status label
$yearStatus = $this->classifyYear($uploads, $featured, $perfScore);
return [
'year' => $year,
'uploads_count' => $uploads,
'views' => $items->sum(fn ($a): int => (int) ($a->stat_views ?? 0)),
'downloads' => $downloads,
'favorites' => $items->sum(fn ($a): int => (int) ($a->stat_favorites ?? 0)),
'comments_count' => $items->sum(fn ($a): int => (int) ($a->stat_comments_count ?? 0)),
'shares_count' => $items->sum(fn ($a): int => (int) ($a->stat_shares_count ?? 0)),
'featured_count' => $featured,
'performance_score' => $perfScore,
'last_published_at' => (string) $items->sortByDesc('published_at')->first()?->published_at,
'top_artwork' => $topArtwork,
'best_month' => $bestMonth,
'top_category' => $topCategory,
'year_status' => $yearStatus,
];
})
->sortByDesc('year')
->values()
->all();
}
/**
* @param Collection<int, object> $artworks
* @return array<int, int> year => featured_count
*/
private function featuredCountsByYear(Collection $artworks): array
{
$artworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();
if ($artworkIds === [] || ! Schema::hasTable('artwork_features')) {
return [];
}
$yearExpr = DB::connection()->getDriverName() === 'sqlite'
? "CAST(strftime('%Y', af.featured_at) AS INTEGER)"
: 'YEAR(af.featured_at)';
return DB::table('artwork_features as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->whereIn('af.artwork_id', $artworkIds)
->whereNull('af.deleted_at')
->where('af.is_active', true)
->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
->groupBy('yr')
->pluck('cnt', 'yr')
->map(fn ($cnt): int => (int) $cnt)
->toArray();
}
/**
* @param Collection<int, object> $items
*/
private function topCategoryForYear(Collection $items): ?string
{
$artworkIds = $items->pluck('id')->map(fn ($id): int => (int) $id)->all();
if ($artworkIds === [] || ! Schema::hasTable('artwork_category')) {
return null;
}
$row = DB::table('artwork_category as ac')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->whereIn('ac.artwork_id', $artworkIds)
->selectRaw('c.name, COUNT(*) as cnt')
->groupBy('c.id', 'c.name')
->orderByDesc('cnt')
->first(['c.name']);
return $row ? (string) $row->name : null;
}
private function classifyYear(int $uploads, int $featured, float $perfScore): string
{
if ($uploads >= 10 && $featured >= 2) {
return 'breakout';
}
if ($featured >= 1 && $uploads >= 5) {
return 'steady';
}
if ($uploads >= 6 && $featured === 0) {
return 'experimental';
}
if ($uploads <= 2) {
return 'quiet';
}
return 'steady';
}
private function formatPublicPayload(
User $user,
Collection $rows,
array $eras = [],
array $evolution = [],
array $streakStats = [],
): array {
$items = $rows->map(function (CreatorMilestone $milestone): array {
$payload = $milestone->payload_json ?? [];
return [
'id' => (int) $milestone->id,
'type' => (string) $milestone->type,
'occurred_at' => $milestone->occurred_at?->toIso8601String(),
'occurred_year' => $milestone->occurred_year,
'priority' => (int) $milestone->priority,
'title' => (string) ($payload['title'] ?? Str::headline((string) $milestone->type)),
'headline' => $payload['headline'] ?? null,
'summary' => $payload['summary'] ?? null,
'value' => $payload['value'] ?? null,
'artwork' => $payload['artwork'] ?? null,
'release' => $payload['release'] ?? null,
'metrics' => $payload['metrics'] ?? [],
'metadata' => $payload['metadata'] ?? null,
'shareable_recap' => $payload['shareable_recap'] ?? null,
];
})->values();
$timeline = $items
->reject(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
->values()
->all();
$yearlyRecaps = $items
->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
->sortByDesc('occurred_year')
->values()
->all();
// Build shareable recap payloads from yearly recap milestone payloads
$shareableRecaps = $items
->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
->sortByDesc('occurred_year')
->map(fn (array $item): ?array => $item['shareable_recap'])
->filter()
->values()
->all();
$highlightTypes = [
CreatorMilestoneType::BestPerformingWork->value,
CreatorMilestoneType::BiggestDownloadSpike->value,
CreatorMilestoneType::MostProductiveYear->value,
CreatorMilestoneType::FirstFeaturedArtwork->value,
CreatorMilestoneType::ComebackLegendary->value,
CreatorMilestoneType::UploadStreak12->value,
CreatorMilestoneType::ActiveYearStreak5->value,
];
$highlights = $items
->filter(fn (array $item): bool => in_array($item['type'], $highlightTypes, true))
->sortByDesc('priority')
->values()
->take(4)
->all();
$latestMilestone = collect($timeline)->first();
// Streak summary for API
$streakSummary = [
'current_monthly_upload_streak' => (int) ($streakStats['current_monthly_streak'] ?? 0),
'best_monthly_upload_streak' => (int) ($streakStats['best_monthly_streak'] ?? 0),
'current_active_year_streak' => (int) ($streakStats['current_year_streak'] ?? 0),
'best_active_year_streak' => (int) ($streakStats['best_year_streak'] ?? 0),
];
return [
'summary' => [
'available' => $items->isNotEmpty(),
'member_since_year' => $user->created_at?->year,
'years_on_skinbase' => $user->created_at?->diffInYears(now()),
'milestone_count' => $items->count(),
'latest_milestone' => $latestMilestone,
'latest_yearly_recap' => $yearlyRecaps[0] ?? null,
'generated_at' => $rows->max(fn (CreatorMilestone $milestone) => $milestone->computed_at?->toIso8601String()),
],
'highlights' => $highlights,
'timeline' => $timeline,
'yearly_recaps' => $yearlyRecaps,
// ── v2 sections ────────────────────────────────────────────────
'eras' => $eras,
'evolution' => $evolution,
'streaks' => $streakSummary,
'shareable_recaps' => $shareableRecaps,
];
}
private function evolutionPayloadForUser(int $userId): array
{
// Fetch public artwork_relations where the source artwork belongs to this creator.
// Both source and target must be public for public display.
$rows = DB::table('artwork_relations as ar')
->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
->leftJoin('artwork_stats as ss', 'ss.artwork_id', '=', 'ar.source_artwork_id')
->leftJoin('artwork_stats as ts', 'ts.artwork_id', '=', 'ar.target_artwork_id')
->where('src.user_id', $userId)
->whereNull('src.deleted_at')
->whereNull('tgt.deleted_at')
->where('src.is_public', true)
->where('src.is_approved', true)
->where('tgt.is_public', true)
->where('tgt.is_approved', true)
->whereNotNull('src.published_at')
->whereNotNull('tgt.published_at')
->orderBy('ar.sort_order')
->orderBy('ar.id')
->get([
'ar.id',
'ar.relation_type',
'ar.note',
'src.id as src_id',
'src.title as src_title',
'src.slug as src_slug',
'src.published_at as src_published_at',
'tgt.id as tgt_id',
'tgt.title as tgt_title',
'tgt.slug as tgt_slug',
'tgt.published_at as tgt_published_at',
]);
return $rows->map(function (object $row): array {
$srcDate = Carbon::parse($row->src_published_at);
$tgtDate = Carbon::parse($row->tgt_published_at);
$yearsBetween = (int) abs($tgtDate->diffInYears($srcDate));
return [
'id' => (int) $row->id,
'relation_type' => (string) $row->relation_type,
'years_between' => $yearsBetween,
'note' => $row->note,
'source_artwork' => [
'id' => (int) $row->src_id,
'title' => (string) $row->src_title,
'slug' => (string) $row->src_slug,
'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
'published_at' => $srcDate->toIso8601String(),
],
'target_artwork' => [
'id' => (int) $row->tgt_id,
'title' => (string) $row->tgt_title,
'slug' => (string) $row->tgt_slug,
'url' => route('art.show', ['id' => (int) $row->tgt_id, 'slug' => $row->tgt_slug]),
'published_at' => $tgtDate->toIso8601String(),
],
];
})->values()->all();
}
private function evolutionMilestonesForUser(int $userId, CarbonInterface $computedAt, callable $makeMilestoneRow): array
{
if (! Schema::hasTable('artwork_relations')) {
return [];
}
$rows = DB::table('artwork_relations as ar')
->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
->where('src.user_id', $userId)
->whereNull('src.deleted_at')
->whereNull('tgt.deleted_at')
->where('src.is_public', true)
->where('src.is_approved', true)
->where('tgt.is_public', true)
->where('tgt.is_approved', true)
->whereNotNull('src.published_at')
->whereNotNull('tgt.published_at')
->get(['ar.id', 'ar.relation_type', 'ar.note', 'src.id as src_id', 'src.title as src_title', 'src.slug as src_slug', 'src.published_at as src_pub', 'tgt.id as tgt_id', 'tgt.title as tgt_title', 'tgt.published_at as tgt_pub']);
$milestones = [];
foreach ($rows as $row) {
$srcDate = Carbon::parse($row->src_pub);
$tgtDate = Carbon::parse($row->tgt_pub);
$years = max(0, (int) abs($tgtDate->diffInYears($srcDate)));
$yearStr = $years >= 1 ? "{$years} " . ($years === 1 ? 'year' : 'years') . ' later' : 'recently';
$milestones[] = $makeMilestoneRow(
$userId,
CreatorMilestoneType::BeforeNow,
$srcDate->max($tgtDate), // milestone at the newer artwork
[
'title' => 'Then & Now',
'headline' => (string) $row->src_title,
'summary' => "Revisited and {$row->relation_type} \"{$row->tgt_title}\"{$yearStr}.",
'value' => $yearStr,
'artwork' => [
'id' => (int) $row->src_id,
'title' => (string) $row->src_title,
'slug' => (string) $row->src_slug,
'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
],
'metadata' => [
'relation_type' => $row->relation_type,
'years_between' => $years,
'source_artwork_id' => (int) $row->src_id,
'target_artwork_id' => (int) $row->tgt_id,
],
],
(int) $row->src_id,
$computedAt,
);
}
return $milestones;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function makeMilestoneRow(
int $userId,
CreatorMilestoneType $type,
?CarbonInterface $occurredAt,
array $payload,
?int $relatedArtworkId,
CarbonInterface $computedAt,
): array {
$occurredAt = $occurredAt ?? $computedAt;
return [
'user_id' => $userId,
'type' => $type->value,
'occurred_at' => $occurredAt->toDateTimeString(),
'occurred_year' => (int) $occurredAt->year,
'related_artwork_id' => $relatedArtworkId,
'is_public' => true,
'priority' => $type->priority(),
'payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'computed_at' => $computedAt->toDateTimeString(),
'created_at' => $computedAt->toDateTimeString(),
'updated_at' => $computedAt->toDateTimeString(),
];
}
/**
* @return array<string, mixed>
*/
private function artworkSnapshot(object $artwork): array
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) $slug,
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
];
}
/**
* @return array<string, int>
*/
private function artworkMetricSnapshot(object $artwork): array
{
return [
'views' => (int) ($artwork->stat_views ?? 0),
'downloads' => (int) ($artwork->stat_downloads ?? 0),
'favorites' => (int) ($artwork->stat_favorites ?? 0),
'comments_count' => (int) ($artwork->stat_comments_count ?? 0),
'shares_count' => (int) ($artwork->stat_shares_count ?? 0),
];
}
private function basePerformanceScore(object $artwork): float
{
return $this->ranking->calculateBaseScore((object) [
'views_all' => (float) ($artwork->stat_views ?? 0),
'downloads_all' => (float) ($artwork->stat_downloads ?? 0),
'favourites_all' => (float) ($artwork->stat_favorites ?? 0),
'comments_count' => (float) ($artwork->stat_comments_count ?? 0),
'shares_count' => (float) ($artwork->stat_shares_count ?? 0),
]);
}
private function displayDate(?CarbonInterface $date): ?string
{
return $date?->format('M j, Y');
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return null;
}
return Carbon::parse($value);
}
private function resolveUser(User|int $user): User
{
return $user instanceof User
? $user
: User::query()->findOrFail($user);
}
private function cacheVersion(int $userId): int
{
return (int) Cache::get($this->cacheVersionKey($userId), 1);
}
private function bumpCacheVersion(int $userId): void
{
Cache::forever($this->cacheVersionKey($userId), $this->cacheVersion($userId) + 1);
}
private function cacheVersionKey(int $userId): string
{
return 'creator_journey:version:' . $userId;
}
private function rebuildDebounceKey(int $userId): string
{
return 'creator_journey:rebuild:debounce:' . $userId;
}
}

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Calculates upload streaks (consecutive calendar months with at least one public upload)
* and active-year streaks (consecutive years with at least one public upload).
*
* Returns:
* - milestone rows for notable streak achievements
* - a streaks summary array for the API payload
*/
final class CreatorStreakService
{
/**
* Compute streak milestones from a creator's public artwork collection.
*
* @param Collection<int, object> $artworks
* @param int $userId
* @param CarbonInterface $computedAt
* @param callable $makeMilestoneRow
* @return array<int, array<string, mixed>>
*/
public function calculateStreakMilestones(
Collection $artworks,
int $userId,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->isEmpty()) {
return [];
}
$milestones = [];
$stats = $this->computeStreakStats($artworks);
// Monthly upload streak milestones
foreach ([12, 6, 3] as $months) {
if ($stats['best_monthly_streak'] >= $months) {
$type = match ($months) {
12 => CreatorMilestoneType::UploadStreak12,
6 => CreatorMilestoneType::UploadStreak6,
3 => CreatorMilestoneType::UploadStreak3,
};
$occurredAt = $stats['best_monthly_streak_end'] ?? $computedAt;
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$occurredAt,
[
'title' => $months . '-month upload streak',
'headline' => "Published in {$months} consecutive months.",
'summary' => "Maintained a public upload in every calendar month for {$months} consecutive months.",
'value' => "{$months} months",
'metrics' => [
'months' => $months,
'best_monthly_streak' => $stats['best_monthly_streak'],
'current_monthly_streak' => $stats['current_monthly_streak'],
],
],
null,
$computedAt,
);
break; // Only insert the best monthly streak milestone (e.g. if best=12, skip 6 and 3)
}
}
// Active-year streak milestones
foreach ([5, 3] as $years) {
if ($stats['best_year_streak'] >= $years) {
$type = match ($years) {
5 => CreatorMilestoneType::ActiveYearStreak5,
3 => CreatorMilestoneType::ActiveYearStreak3,
};
$occurredAt = $stats['best_year_streak_end'] ?? $computedAt;
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$occurredAt,
[
'title' => "{$years}-year active streak",
'headline' => "Stayed active for {$years} consecutive years.",
'summary' => "Published at least one public artwork every year for {$years} consecutive years.",
'value' => "{$years} years",
'metrics' => [
'years' => $years,
'best_year_streak' => $stats['best_year_streak'],
'current_year_streak' => $stats['current_year_streak'],
],
],
null,
$computedAt,
);
break; // Only insert the best year streak milestone
}
}
return $milestones;
}
/**
* Compute raw streak statistics for use in the API streaks payload.
*
* @param Collection<int, object> $artworks
* @return array{
* current_monthly_streak: int,
* best_monthly_streak: int,
* best_monthly_streak_end: ?CarbonInterface,
* current_year_streak: int,
* best_year_streak: int,
* best_year_streak_end: ?CarbonInterface,
* }
*/
public function computeStreakStats(Collection $artworks): array
{
if ($artworks->isEmpty()) {
return $this->emptyStats();
}
// Build sets of active months (YYYY-MM) and active years
$activeMonths = [];
$activeYears = [];
foreach ($artworks as $artwork) {
$date = $this->parseDate($artwork->published_at);
if ($date === null) {
continue;
}
$activeMonths[$date->format('Y-m')] = $date;
$activeYears[(int) $date->format('Y')] = $date;
}
if ($activeMonths === []) {
return $this->emptyStats();
}
ksort($activeMonths);
ksort($activeYears);
return [
...$this->computeMonthlyStreaks($activeMonths),
...$this->computeYearlyStreaks($activeYears),
];
}
/**
* @param array<string, CarbonInterface> $activeMonths sorted ascending by key (YYYY-MM)
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: ?CarbonInterface}
*/
private function computeMonthlyStreaks(array $activeMonths): array
{
$now = Carbon::now();
$currentMonth = $now->format('Y-m');
$streak = 1;
$best = 1;
$bestEndDate = null;
$prevKey = null;
$lastKey = null;
foreach ($activeMonths as $key => $date) {
if ($prevKey !== null) {
$expected = Carbon::parse($prevKey . '-01')->addMonth()->format('Y-m');
if ($key === $expected) {
$streak++;
} else {
$streak = 1;
}
}
if ($streak > $best) {
$best = $streak;
$bestEndDate = $date;
}
$prevKey = $key;
$lastKey = $key;
}
// Current streak: walk backwards from current/last month
$currentStreak = 0;
$checkMonth = $lastKey !== null ? Carbon::parse($lastKey . '-01') : $now->startOfMonth();
// If the last active month is current or previous month, count the streak
$diff = $now->startOfMonth()->diffInMonths($checkMonth);
if ($diff <= 1) {
$currentStreak = 1;
$checkBack = $checkMonth->copy()->subMonth();
while (isset($activeMonths[$checkBack->format('Y-m')])) {
$currentStreak++;
$checkBack->subMonth();
}
}
return [
'current_monthly_streak' => $currentStreak,
'best_monthly_streak' => $best,
'best_monthly_streak_end' => $bestEndDate,
];
}
/**
* @param array<int, CarbonInterface> $activeYears sorted ascending by key (int year)
* @return array{current_year_streak: int, best_year_streak: int, best_year_streak_end: ?CarbonInterface}
*/
private function computeYearlyStreaks(array $activeYears): array
{
$currentYear = (int) Carbon::now()->year;
$streak = 1;
$best = 1;
$bestEndDate = null;
$prevYear = null;
$lastYear = null;
foreach ($activeYears as $year => $date) {
if ($prevYear !== null) {
if ($year === $prevYear + 1) {
$streak++;
} else {
$streak = 1;
}
}
if ($streak > $best) {
$best = $streak;
$bestEndDate = $date;
}
$prevYear = $year;
$lastYear = $year;
}
// Current year streak
$currentStreak = 0;
if ($lastYear !== null && ($lastYear === $currentYear || $lastYear === $currentYear - 1)) {
$currentStreak = 1;
$checkYear = $lastYear - 1;
while (isset($activeYears[$checkYear])) {
$currentStreak++;
$checkYear--;
}
}
return [
'current_year_streak' => $currentStreak,
'best_year_streak' => $best,
'best_year_streak_end' => $bestEndDate,
];
}
/**
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: null, current_year_streak: int, best_year_streak: int, best_year_streak_end: null}
*/
private function emptyStats(): array
{
return [
'current_monthly_streak' => 0,
'best_monthly_streak' => 0,
'best_monthly_streak_end' => null,
'current_year_streak' => 0,
'best_year_streak' => 0,
'best_year_streak_end' => null,
];
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -177,7 +177,7 @@ class UserPreferenceBuilder
*/
private function tagScoresFromAwards(int $userId, float $weight): array
{
$rows = DB::table('artwork_awards as aa')
$rows = DB::table('artwork_medals as aa')
->join('artwork_tag as at', 'at.artwork_id', '=', 'aa.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('aa.user_id', $userId)

View File

@@ -386,6 +386,7 @@ final class PersonalizedFeedService
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,name,slug',
'tags:id,name,slug',
@@ -407,6 +408,8 @@ final class PersonalizedFeedService
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$primaryTag = $artwork->tags->sortBy('name')->first();
$source = (string) ($item['source'] ?? 'mixed');
$publisher = $this->mapPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$responseItems[] = [
'id' => $artwork->id,
@@ -414,14 +417,18 @@ final class PersonalizedFeedService
'title' => $artwork->title,
'thumbnail_url' => $artwork->thumb_url,
'thumbnail_srcset' => $artwork->thumb_srcset,
'author' => $artwork->user?->name,
'username' => $artwork->user?->username,
'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name,
'username' => $isGroupPublisher ? null : $artwork->user?->username,
'author_id' => $artwork->user?->id,
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'avatar_url' => $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory?->name ?? '',
@@ -490,6 +497,32 @@ final class PersonalizedFeedService
};
}
/**
* @return array<string, mixed>|null
*/
private function mapPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->group;
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
];
}
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
{
if ($algoVersion !== null && $algoVersion !== '') {

View File

@@ -958,6 +958,7 @@ final class RecommendationServiceV2
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,name,slug',
'tags:id,name,slug',
@@ -991,20 +992,26 @@ final class RecommendationServiceV2
$rankingSignals = (array) ($item['ranking_signals'] ?? []);
$rankingSignals['local_embedding_present'] = $hasLocalEmbedding;
$rankingSignals['vector_indexed_at'] = $vectorIndexedAt;
$publisher = $this->mapPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$responseItems[] = [
'id' => $artwork->id,
'slug' => $artwork->slug,
'title' => $artwork->title,
'thumbnail_url' => $artwork->thumb_url,
'thumbnail_srcset' => $artwork->thumb_srcset,
'author' => $artwork->user?->name,
'username' => $artwork->user?->username,
'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name,
'username' => $isGroupPublisher ? null : $artwork->user?->username,
'author_id' => $artwork->user?->id,
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'avatar_url' => $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory?->name ?? '',
@@ -1323,6 +1330,32 @@ final class RecommendationServiceV2
return $typed;
}
/**
* @return array<string, mixed>|null
*/
private function mapPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->group;
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
];
}
private function v3Enabled(): bool
{
return (bool) config('discovery.v3.enabled', false);

View File

@@ -5,14 +5,17 @@ declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
public function __construct(
private readonly SitemapUrlBuilder $urls,
private readonly ContentTypeSlugResolver $contentTypeResolver,
)
{
}
@@ -25,19 +28,18 @@ final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
{
$items = [$this->urls->categoryDirectory()];
$contentTypes = ContentType::query()
->whereIn('slug', $this->contentTypeSlugs())
->ordered()
->get();
$contentTypes = $this->contentTypeResolver->dynamicSitemapContentTypes();
foreach ($contentTypes as $contentType) {
$items[] = $this->urls->contentType($contentType);
}
$contentTypeIds = $contentTypes->pluck('id')->filter()->values();
$categories = Category::query()
->with('contentType')
->active()
->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs()))
->when($contentTypeIds->isNotEmpty(), fn ($query) => $query->whereIn('content_type_id', $contentTypeIds->all()))
->orderBy('content_type_id')
->orderBy('parent_id')
->orderBy('sort_order')

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -15,6 +16,10 @@ use Illuminate\Validation\ValidationException;
class SmartCollectionService
{
public function __construct(private readonly ArtworkMaturityService $maturity)
{
}
public function sanitizeRules(?array $input): ?array
{
if ($input === null) {
@@ -278,6 +283,8 @@ class SmartCollectionService
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now());
$this->maturity->applyViewerFilter($query, request()->user());
}
$method = ($sanitized['match'] ?? 'all') === 'any' ? 'orWhere' : 'where';
@@ -382,7 +389,10 @@ class SmartCollectionService
{
return match ($sort) {
Collection::SORT_OLDEST => $query->orderBy('artworks.published_at')->orderBy('artworks.id'),
Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByDesc('artworks.id'),
Collection::SORT_POPULAR => $query
->leftJoin('artwork_stats as artwork_stats_sort', 'artwork_stats_sort.artwork_id', '=', 'artworks.id')
->orderByRaw('COALESCE(artwork_stats_sort.views, 0) DESC')
->orderByDesc('artworks.id'),
default => $query->orderByDesc('artworks.published_at')->orderByDesc('artworks.id'),
};
}

View File

@@ -320,7 +320,7 @@ final class TagService
*/
private function normalizeUserTags(array $tags): array
{
$max = (int) config('tags.max_user_tags', 15);
$max = (int) config('tags.max_user_tags', 30);
if (count($tags) > $max) {
throw ValidationException::withMessages([
'tags' => ["Too many tags (max {$max})."],

View File

@@ -7,6 +7,7 @@ namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadStoredFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
@@ -34,9 +35,10 @@ final class UploadStorageService
{
$path = $this->sectionPath($section);
if (! File::exists($path)) {
File::makeDirectory($path, 0755, true);
}
$this->ensureDirectoryExists($path, [
'section' => $section,
'storage_root' => rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR),
]);
return $path;
}
@@ -75,9 +77,11 @@ final class UploadStorageService
$segments = $this->hashSegments($hash);
$dir = $this->sectionPath($section) . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$this->ensureDirectoryExists($dir, [
'section' => $section,
'hash' => $hash,
'segments' => $segments,
]);
return $dir;
}
@@ -87,9 +91,12 @@ final class UploadStorageService
$segments = $this->hashSegments($hash);
$dir = $this->localOriginalsRoot() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$this->ensureDirectoryExists($dir, [
'section' => 'local_originals',
'hash' => $hash,
'segments' => $segments,
'local_originals_root' => $this->localOriginalsRoot(),
]);
return $dir;
}
@@ -219,6 +226,37 @@ final class UploadStorageService
return is_array($matches) && count($matches) > 0;
}
/**
* @param array<string, mixed> $context
*/
private function ensureDirectoryExists(string $path, array $context = []): void
{
if (File::exists($path)) {
return;
}
$parent = dirname($path);
try {
File::makeDirectory($path, 0755, true);
} catch (\Throwable $exception) {
Log::error('Upload storage directory creation failed.', array_merge($context, [
'path' => $path,
'parent' => $parent,
'path_exists' => File::exists($path),
'parent_exists' => File::exists($parent),
'parent_is_directory' => File::isDirectory($parent),
'parent_is_writable' => is_writable($parent),
'storage_root_config' => (string) config('uploads.storage_root'),
'local_originals_root_config' => (string) config('uploads.local_originals_root'),
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]));
throw $exception;
}
}
private function safeExtension(UploadedFile $file): string
{
$extension = (string) $file->guessExtension();

View File

@@ -178,7 +178,7 @@ final class UserStatsService
->whereNull('a.deleted_at')
->sum('s.views'),
'awards_received_count' => (int) DB::table('artwork_awards as aw')
'awards_received_count' => (int) DB::table('artwork_medals as aw')
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')

View File

@@ -12,6 +12,8 @@ use RuntimeException;
final class AiArtworkVectorSearchService
{
private const MAX_SIMILAR_RESULTS = 120;
public function __construct(
private readonly VectorGatewayClient $client,
private readonly ArtworkVisionImageUrl $imageUrl,
@@ -28,7 +30,7 @@ final class AiArtworkVectorSearchService
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit);
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
@@ -49,7 +51,7 @@ final class AiArtworkVectorSearchService
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$path = $file->store('ai-search/tmp', 'public');
if (! is_string($path) || $path === '') {

View File

@@ -21,9 +21,9 @@ final class VisionService
return (bool) config('vision.enabled', true);
}
public function buildImageUrl(string $hash): ?string
public function buildImageUrl(string $hash, ?string $variant = null): ?string
{
$variant = (string) config('vision.image_variant', 'md');
$variant = $variant ?? (string) config('vision.image_variant', 'md');
$variant = $variant !== '' ? $variant : 'md';
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
@@ -94,6 +94,45 @@ final class VisionService
];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
public function analyzeArtworkMaturityDetailed(Artwork $artwork, string $hash, ?string $variant = null): array
{
$imageUrl = $this->buildImageUrl($hash, $variant);
$ref = (string) Str::uuid();
if ($imageUrl === null) {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Artwork maturity analysis could not start because no image URL was available.',
],
'debug' => [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'image_url' => null,
'reason' => 'image_url_unavailable',
'calls' => [],
],
];
}
$call = $this->callMaturityDetailed($artwork, $imageUrl, $hash, $ref);
return [
'assessment' => $call['assessment'],
'debug' => [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'image_url' => $imageUrl,
'calls' => [$call['debug']],
],
];
}
/**
* @return array{tags: array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>, vision_enabled: bool, source?: string, reason?: string}
*/
@@ -658,6 +697,177 @@ final class VisionService
return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
private function callMaturityDetailed(Artwork $artwork, string $imageUrl, string $hash, string $ref): array
{
$base = trim((string) config('vision.maturity.base_url', ''));
if ($base === '') {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity endpoint is not configured.',
],
'debug' => [
'service' => 'maturity',
'enabled' => false,
'reason' => 'base_url_missing',
],
];
}
$endpoint = (string) config('vision.maturity.endpoint', '/analyze/maturity');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.maturity.timeout_seconds', 20);
$connectTimeout = (int) config('vision.maturity.connect_timeout_seconds', 3);
$retries = (int) config('vision.maturity.retries', 1);
$delay = (int) config('vision.maturity.retry_delay_ms', 200);
$requestPayload = [
'url' => $imageUrl,
'image_url' => $imageUrl,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
];
$debug = [
'service' => 'maturity',
'endpoint' => $url,
'request' => $requestPayload,
];
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('maturity', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, $requestPayload);
$debug['status'] = $response->status();
$debug['auth_header_sent'] = $this->visionApiKey('maturity') !== '';
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
Log::warning('Vision maturity request failed', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
$debug['error'] = $e->getMessage();
return [
'assessment' => [
'status' => 'failed',
'advisory' => $e->getMessage(),
],
'debug' => $debug,
];
}
if ($response->ok()) {
return [
'assessment' => $this->parseMaturityAssessment($response->json()),
'debug' => $debug,
];
}
Log::warning('Vision maturity non-ok response', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
$fallback = $this->callMaturityFileDetailed($artwork, $ref);
$debug['fallback_upload'] = $fallback['debug'];
if (($fallback['assessment']['status'] ?? null) === 'succeeded') {
return [
'assessment' => $fallback['assessment'],
'debug' => $debug,
];
}
return [
'assessment' => [
'status' => 'failed',
'advisory' => $this->buildFailureAdvisory($response->status(), $fallback['assessment']['advisory'] ?? null),
],
'debug' => $debug,
];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
private function callMaturityFileDetailed(Artwork $artwork, string $ref): array
{
$base = trim((string) config('vision.maturity.base_url', ''));
$endpoint = (string) config('vision.maturity.file_endpoint', '/analyze/maturity/file');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$debug = [
'endpoint' => $url,
];
if ($base === '') {
$debug['reason'] = 'base_url_missing';
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity upload endpoint is not configured.',
],
'debug' => $debug,
];
}
$file = $this->fetchStoredArtworkBinary((int) $artwork->id);
if ($file === null) {
$debug['reason'] = 'file_unavailable';
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Artwork maturity analysis could not fall back to the upload endpoint because the stored file was unavailable.',
],
'debug' => $debug,
];
}
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('maturity', $ref)
->attach('file', $file['contents'], $file['filename'])
->post($url, ['artwork_id' => (int) $artwork->id]);
$debug['status'] = $response->status();
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
$debug['error'] = $e->getMessage();
return [
'assessment' => [
'status' => 'failed',
'advisory' => $e->getMessage(),
],
'debug' => $debug,
];
}
if (! $response->ok()) {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity upload endpoint returned HTTP ' . $response->status() . '.',
],
'debug' => $debug,
];
}
return [
'assessment' => $this->parseMaturityAssessment($response->json()),
'debug' => $debug,
];
}
private function shouldRunYolo(Artwork $artwork): bool
{
if (! (bool) config('vision.yolo.enabled', true)) {
@@ -696,12 +906,237 @@ final class VisionService
{
return match ($service) {
'gateway' => trim((string) config('vision.gateway.api_key', '')),
'maturity' => trim((string) config('vision.maturity.api_key', '')),
'clip' => trim((string) config('vision.clip.api_key', '')),
'yolo' => trim((string) config('vision.yolo.api_key', '')),
default => '',
};
}
/**
* @param mixed $json
* @return array<string, mixed>
*/
private function parseMaturityAssessment(mixed $json): array
{
if (! is_array($json)) {
return [
'status' => 'failed',
'advisory' => 'Vision maturity endpoint returned an invalid response.',
];
}
$label = $this->normalizeMaturityLabel(
$json['maturity_label']
?? $json['label']
?? data_get($json, 'data.maturity_label')
?? data_get($json, 'result.maturity_label')
);
$confidence = $this->normalizeFloat(
$json['confidence']
?? $json['score']
?? data_get($json, 'data.confidence')
?? data_get($json, 'result.confidence')
);
$thresholdUsed = $this->normalizeFloat(
$json['threshold_used']
?? $json['threshold']
?? data_get($json, 'data.threshold_used')
?? data_get($json, 'result.threshold_used')
);
$actionHint = $this->normalizeActionHint(
$json['action_hint']
?? data_get($json, 'data.action_hint')
?? data_get($json, 'result.action_hint')
);
$advisory = $this->normalizeText(
$json['advisory']
?? $json['message']
?? data_get($json, 'data.advisory')
?? data_get($json, 'result.advisory')
);
$status = $this->normalizeAssessmentStatus(
$json['status']
?? data_get($json, 'data.status')
?? data_get($json, 'result.status')
?? ($label !== null || $actionHint !== null ? 'succeeded' : 'failed')
);
$model = $this->normalizeText(
$json['model']
?? data_get($json, 'meta.model')
?? data_get($json, 'result.model')
);
$analysisTimeMs = $this->normalizeInt(
$json['analysis_time_ms']
?? data_get($json, 'meta.analysis_time_ms')
?? data_get($json, 'result.analysis_time_ms')
);
if ($status === 'succeeded' && $label === null && $actionHint === null) {
$status = 'failed';
$advisory = $advisory ?: 'Vision maturity endpoint did not return a maturity label or action hint.';
}
$labels = $this->extractMaturityLabels($json, $label);
return array_filter([
'status' => $status,
'maturity_label' => $label,
'confidence' => $confidence,
'model' => $model,
'threshold_used' => $thresholdUsed,
'analysis_time_ms' => $analysisTimeMs,
'action_hint' => $actionHint,
'advisory' => $advisory,
'labels' => $labels,
], static fn (mixed $value): bool => $value !== null);
}
/**
* @param mixed $json
* @return array<int, string>
*/
private function extractMaturityLabels(mixed $json, ?string $fallbackLabel): array
{
if (! is_array($json)) {
return $fallbackLabel !== null ? [$fallbackLabel] : [];
}
$raw = $json['labels']
?? $json['matched_labels']
?? $json['matched_terms']
?? data_get($json, 'data.labels')
?? data_get($json, 'result.labels')
?? [];
$labels = collect(is_array($raw) ? $raw : [$raw])
->map(function (mixed $item): ?string {
if (is_string($item)) {
$label = trim($item);
return $label !== '' ? $label : null;
}
if (! is_array($item)) {
return null;
}
$label = trim((string) ($item['label'] ?? $item['tag'] ?? $item['name'] ?? ''));
return $label !== '' ? $label : null;
})
->filter()
->values()
->all();
if ($labels === [] && $fallbackLabel !== null) {
$labels[] = $fallbackLabel;
}
return array_values(array_unique($labels));
}
/**
* @return array{filename: string, contents: string}|null
*/
private function fetchStoredArtworkBinary(int $artworkId): ?array
{
try {
$variant = (string) config('vision.image_variant', 'md');
$row = DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', $variant)
->first();
if (! $row || empty($row->path)) {
return null;
}
$path = (string) $row->path;
$contents = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->get($path);
if (! is_string($contents) || $contents === '') {
return null;
}
return [
'filename' => basename($path),
'contents' => $contents,
];
} catch (\Throwable) {
return null;
}
}
private function buildFailureAdvisory(int $status, ?string $fallback): string
{
if (is_string($fallback) && trim($fallback) !== '') {
return trim($fallback);
}
return 'Vision maturity endpoint returned HTTP ' . $status . '.';
}
private function normalizeMaturityLabel(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
return match (Str::lower(trim((string) $value))) {
'safe', 'clear', 'sfw' => 'safe',
'mature', 'adult', 'nsfw', 'explicit' => 'mature',
default => null,
};
}
private function normalizeActionHint(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
return match (Str::lower(trim((string) $value))) {
'allow', 'mark_safe', 'safe' => 'safe',
'review', 'queue', 'suspect' => 'review',
'flag_high', 'block', 'mature', 'mark_mature' => 'flag_high',
default => null,
};
}
private function normalizeAssessmentStatus(mixed $value): string
{
if (! is_scalar($value)) {
return 'failed';
}
return match (Str::lower(trim((string) $value))) {
'ok', 'success', 'succeeded', 'complete', 'completed' => 'succeeded',
'pending', 'queued', 'processing' => 'pending',
'skipped', 'not_requested' => 'skipped',
default => 'failed',
};
}
private function normalizeFloat(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
private function normalizeInt(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function normalizeText(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
/**
* @param mixed $json
* @return array<int, array{tag: string, confidence?: float|int|null}>