feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
644
app/Services/ArtworkEvolutionService.php
Normal file
644
app/Services/ArtworkEvolutionService.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
176
app/Services/ArtworkMedalService.php
Normal file
176
app/Services/ArtworkMedalService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
98
app/Services/ContentTypeAssetService.php
Normal file
98
app/Services/ContentTypeAssetService.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
27
app/Services/ContentTypes/ContentTypeSlugResolution.php
Normal file
27
app/Services/ContentTypes/ContentTypeSlugResolution.php
Normal 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;
|
||||
}
|
||||
}
|
||||
151
app/Services/ContentTypes/ContentTypeSlugResolver.php
Normal file
151
app/Services/ContentTypes/ContentTypeSlugResolver.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ final class GridFiller
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->withoutMissingThumbnails()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
479
app/Services/FeaturedArtworkAdminService.php
Normal file
479
app/Services/FeaturedArtworkAdminService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
219
app/Services/Maturity/ArtworkMaturityAuditService.php
Normal file
219
app/Services/Maturity/ArtworkMaturityAuditService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
562
app/Services/Maturity/ArtworkMaturityService.php
Normal file
562
app/Services/Maturity/ArtworkMaturityService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
187
app/Services/Profile/CreatorComebackService.php
Normal file
187
app/Services/Profile/CreatorComebackService.php
Normal 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: 180–364 days gap
|
||||
* Major: 365–1094 days gap (1–3 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
359
app/Services/Profile/CreatorEraService.php
Normal file
359
app/Services/Profile/CreatorEraService.php
Normal 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 ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
986
app/Services/Profile/CreatorJourneyService.php
Normal file
986
app/Services/Profile/CreatorJourneyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
303
app/Services/Profile/CreatorStreakService.php
Normal file
303
app/Services/Profile/CreatorStreakService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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})."],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user