feat: ship creator journey v2 and profile updates
This commit is contained in:
156
app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php
Normal file
156
app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Maturity\ArtworkMaturityAuditService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
use App\Services\Vision\VisionService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AuditArtworkMaturityThumbnailsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:audit-thumbnail-maturity
|
||||||
|
{--id= : Audit only this artwork ID}
|
||||||
|
{--after-id=0 : Skip artworks with ID less than or equal to this value}
|
||||||
|
{--limit= : Stop after processing this many artworks}
|
||||||
|
{--chunk=25 : Number of artworks to scan per batch}
|
||||||
|
{--variant= : Thumbnail variant to analyze (defaults to vision.image_variant)}
|
||||||
|
{--refresh : Re-scan artworks that already have an open audit finding}
|
||||||
|
{--dry-run : Report candidates without writing audit findings}';
|
||||||
|
|
||||||
|
protected $description = 'Scan artwork thumbnails for possible mature content without mutating artwork maturity fields.';
|
||||||
|
|
||||||
|
public function handle(VisionService $vision, ArtworkMaturityAuditService $audit): int
|
||||||
|
{
|
||||||
|
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||||
|
$afterId = max(0, (int) $this->option('after-id'));
|
||||||
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||||
|
$chunkSize = max(1, min((int) $this->option('chunk'), 200));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$refresh = (bool) $this->option('refresh');
|
||||||
|
$variant = trim((string) ($this->option('variant') ?: config('vision.image_variant', 'md')));
|
||||||
|
|
||||||
|
if (! $vision->isEnabled()) {
|
||||||
|
$this->error('Vision maturity analysis is disabled.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && ! Schema::hasTable('artwork_maturity_audit_findings')) {
|
||||||
|
$this->error('Artwork maturity audit findings table is missing. Run the latest database migrations first.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Starting artwork maturity thumbnail audit. order=id_desc variant=%s chunk=%d limit=%s refresh=%s dry_run=%s',
|
||||||
|
$variant !== '' ? $variant : 'md',
|
||||||
|
$chunkSize,
|
||||||
|
$limit !== null ? (string) $limit : 'all',
|
||||||
|
$refresh ? 'yes' : 'no',
|
||||||
|
$dryRun ? 'yes' : 'no',
|
||||||
|
));
|
||||||
|
|
||||||
|
$query = $audit->eligibleArtworkQuery($refresh)
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$query->whereKey($artworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($afterId > 0) {
|
||||||
|
$query->where('id', '>', $afterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$flagged = 0;
|
||||||
|
$safe = 0;
|
||||||
|
$written = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
$query->chunkByIdDesc($chunkSize, function ($artworks) use ($vision, $audit, $variant, $limit, $dryRun, $refresh, &$processed, &$flagged, &$safe, &$written, &$failed) {
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
if ($limit !== null && $processed >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assessment = (array) ($vision->analyzeArtworkMaturityDetailed($artwork, (string) $artwork->hash, $variant)['assessment'] ?? []);
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
if ($audit->shouldOpenFinding($assessment)) {
|
||||||
|
$flagged++;
|
||||||
|
$message = sprintf(
|
||||||
|
'Artwork %d flagged for moderator review. action=%s confidence=%s label=%s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
(string) ($assessment['action_hint'] ?? 'unknown'),
|
||||||
|
is_numeric($assessment['confidence'] ?? null) ? number_format((float) $assessment['confidence'], 4, '.', '') : 'n/a',
|
||||||
|
(string) ($assessment['maturity_label'] ?? 'unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->warn($message);
|
||||||
|
Log::warning('artworks:audit-thumbnail-maturity candidate detected', [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'title' => (string) $artwork->title,
|
||||||
|
'assessment' => $assessment,
|
||||||
|
'variant' => $variant,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$audit->recordFinding($artwork, $assessment, $variant !== '' ? $variant : 'md');
|
||||||
|
$written++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) === ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
|
||||||
|
$safe++;
|
||||||
|
$this->line(sprintf('Artwork %d scanned safe for audit purposes.', (int) $artwork->id));
|
||||||
|
|
||||||
|
if (! $dryRun && $refresh) {
|
||||||
|
$audit->markFindingCleared($artwork, 'Thumbnail maturity rescan no longer indicates moderator review.');
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed++;
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Artwork %d maturity audit failed: %s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
(string) ($assessment['advisory'] ?? $assessment['status'] ?? 'unknown failure'),
|
||||||
|
));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$processed++;
|
||||||
|
$failed++;
|
||||||
|
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||||
|
Log::warning('artworks:audit-thumbnail-maturity failed', [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'title' => (string) $artwork->title,
|
||||||
|
'variant' => $variant,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Artwork maturity thumbnail audit complete. processed=%d flagged=%d safe=%d written=%d failed=%d',
|
||||||
|
$processed,
|
||||||
|
$flagged,
|
||||||
|
$safe,
|
||||||
|
$written,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/Console/Commands/AuditArtworkThumbnailsCommand.php
Normal file
203
app/Console/Commands/AuditArtworkThumbnailsCommand.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\Uploads\UploadStorageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AuditArtworkThumbnailsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:audit-thumbnails
|
||||||
|
{--id= : Audit only this artwork ID}
|
||||||
|
{--limit= : Stop after processing this many artworks}
|
||||||
|
{--chunk=200 : Number of artworks to scan per batch}
|
||||||
|
{--variant=* : Specific thumbnail variants to check (defaults to all configured derivatives)}
|
||||||
|
{--dry-run : Report missing thumbnails without updating the artworks table}';
|
||||||
|
|
||||||
|
protected $description = 'Check artwork thumbnails on the configured object storage disk and mark artworks with missing thumbnails.';
|
||||||
|
|
||||||
|
public function handle(UploadStorageService $storage): int
|
||||||
|
{
|
||||||
|
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||||
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||||
|
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$variants = $this->resolveVariants();
|
||||||
|
if ($variants === []) {
|
||||||
|
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && ! Schema::hasColumns('artworks', [
|
||||||
|
'has_missing_thumbnails',
|
||||||
|
'missing_thumbnail_variants_json',
|
||||||
|
'thumbnails_checked_at',
|
||||||
|
])) {
|
||||||
|
$this->error('Artwork thumbnail audit columns are missing. Run the latest database migrations first.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskName = $storage->objectDiskName();
|
||||||
|
$diskConfig = config("filesystems.disks.{$diskName}");
|
||||||
|
if (! is_array($diskConfig)) {
|
||||||
|
$this->error("Filesystem disk [{$diskName}] is not configured.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = Storage::disk($diskName);
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Starting thumbnail audit. disk=%s variants=%s chunk=%d limit=%s dry_run=%s',
|
||||||
|
$diskName,
|
||||||
|
implode(',', $variants),
|
||||||
|
$chunkSize,
|
||||||
|
$limit !== null ? (string) $limit : 'all',
|
||||||
|
$dryRun ? 'yes' : 'no',
|
||||||
|
));
|
||||||
|
|
||||||
|
$query = Artwork::query()
|
||||||
|
->select(['id', 'hash', 'thumb_ext'])
|
||||||
|
->orderBy('id');
|
||||||
|
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$query->whereKey($artworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$healthy = 0;
|
||||||
|
$missing = 0;
|
||||||
|
$written = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
$query->chunkById($chunkSize, function ($artworks) use ($storage, $disk, $variants, $limit, $dryRun, &$processed, &$healthy, &$missing, &$written, &$failed) {
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
if ($limit !== null && $processed >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$missingVariants = $this->resolveMissingVariants($artwork, $variants, $storage, $disk);
|
||||||
|
$hasMissing = $missingVariants !== [];
|
||||||
|
|
||||||
|
if ($hasMissing) {
|
||||||
|
$missing++;
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Artwork %d missing thumbnails: %s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
implode(',', $missingVariants),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$healthy++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->persistAuditResult((int) $artwork->id, $hasMissing, $missingVariants);
|
||||||
|
$written++;
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Thumbnail audit complete. processed=%d healthy=%d missing=%d written=%d failed=%d',
|
||||||
|
$processed,
|
||||||
|
$healthy,
|
||||||
|
$missing,
|
||||||
|
$written,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveVariants(): array
|
||||||
|
{
|
||||||
|
$configured = array_keys((array) config('uploads.derivatives', []));
|
||||||
|
$configured = array_values(array_filter(array_map(
|
||||||
|
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||||
|
$configured,
|
||||||
|
)));
|
||||||
|
|
||||||
|
$requested = (array) $this->option('variant');
|
||||||
|
if ($requested === []) {
|
||||||
|
return $configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRequested = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||||
|
$requested,
|
||||||
|
))));
|
||||||
|
|
||||||
|
$invalid = array_values(array_diff($normalizedRequested, $configured));
|
||||||
|
if ($invalid !== []) {
|
||||||
|
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
|
||||||
|
$this->line('Configured variants: ' . implode(', ', $configured));
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $variants
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
|
||||||
|
{
|
||||||
|
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||||
|
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
|
||||||
|
|
||||||
|
if ($hash === '' || $thumbExt === '') {
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $hash . '.' . $thumbExt;
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$objectPath = $storage->objectPathForVariant($variant, $hash, $filename);
|
||||||
|
if (! $disk->exists($objectPath)) {
|
||||||
|
$missing[] = $variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $missingVariants
|
||||||
|
*/
|
||||||
|
private function persistAuditResult(int $artworkId, bool $hasMissing, array $missingVariants): void
|
||||||
|
{
|
||||||
|
DB::table('artworks')
|
||||||
|
->where('id', $artworkId)
|
||||||
|
->update([
|
||||||
|
'has_missing_thumbnails' => $hasMissing,
|
||||||
|
'missing_thumbnail_variants_json' => $missingVariants === []
|
||||||
|
? null
|
||||||
|
: json_encode(array_values($missingVariants), JSON_UNESCAPED_SLASHES),
|
||||||
|
'thumbnails_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,14 +27,22 @@ class ConfigureMeilisearchIndex extends Command
|
|||||||
private const SORTABLE_ATTRIBUTES = [
|
private const SORTABLE_ATTRIBUTES = [
|
||||||
'created_at',
|
'created_at',
|
||||||
'published_at_ts',
|
'published_at_ts',
|
||||||
|
'missing_thumbnail_rank',
|
||||||
'trending_score_24h',
|
'trending_score_24h',
|
||||||
'trending_score_7d',
|
'trending_score_7d',
|
||||||
'favorites_count',
|
'favorites_count',
|
||||||
'downloads_count',
|
'downloads_count',
|
||||||
'awards_received_count',
|
'awards_received_count',
|
||||||
|
'awards_score_7d',
|
||||||
|
'awards_score_30d',
|
||||||
'views',
|
'views',
|
||||||
'likes',
|
'likes',
|
||||||
'downloads',
|
'downloads',
|
||||||
|
'ranking_score',
|
||||||
|
'engagement_velocity',
|
||||||
|
'shares_count',
|
||||||
|
'comments_count',
|
||||||
|
'heat_score',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +52,11 @@ class ConfigureMeilisearchIndex extends Command
|
|||||||
'id',
|
'id',
|
||||||
'is_public',
|
'is_public',
|
||||||
'is_approved',
|
'is_approved',
|
||||||
|
'is_mature',
|
||||||
|
'is_mature_effective',
|
||||||
|
'maturity_level',
|
||||||
|
'maturity_status',
|
||||||
|
'has_missing_thumbnails',
|
||||||
'category',
|
'category',
|
||||||
'content_type',
|
'content_type',
|
||||||
'tags',
|
'tags',
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ declare(strict_types=1);
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Models\ArtworkAwardStat;
|
|
||||||
use App\Services\ArtworkAwardService;
|
use App\Services\ArtworkAwardService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
|
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_medals`.
|
||||||
*
|
*
|
||||||
* Score mapping (legacy score → new medal):
|
* Score mapping (legacy score → new medal):
|
||||||
* 4 → gold (weight 3)
|
* 5 → gold
|
||||||
* 3 → silver (weight 2)
|
* 4 → gold
|
||||||
* 2 → bronze (weight 1)
|
* 3 → silver
|
||||||
* 1 → skipped (too low to map meaningfully)
|
* 2 → silver
|
||||||
|
* 1 → bronze
|
||||||
|
* 0 → bronze
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* php artisan awards:import-legacy
|
* php artisan awards:import-legacy
|
||||||
@@ -29,22 +29,38 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
class ImportLegacyAwards extends Command
|
class ImportLegacyAwards extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'awards:import-legacy
|
protected $signature = 'awards:import-legacy
|
||||||
|
{--connection=legacy : Legacy database connection name}
|
||||||
|
{--artwork-id=* : Restrict import to one or more artwork IDs}
|
||||||
|
{--show-duplicates : Output skipped duplicate artwork/user pairs at the end}
|
||||||
|
{--duplicates-limit=100 : Maximum duplicate rows to print when --show-duplicates is used}
|
||||||
{--dry-run : Preview only — no writes to DB}
|
{--dry-run : Preview only — no writes to DB}
|
||||||
{--chunk=250 : Rows to process per batch}
|
{--chunk=250 : Rows to process per batch}
|
||||||
{--skip-stats : Skip per-artwork stats recalculation at the end}
|
{--skip-stats : Skip per-artwork stats recalculation at the end}
|
||||||
{--force : Overwrite existing awards instead of skipping duplicates}';
|
{--force : Overwrite existing awards instead of skipping duplicates}';
|
||||||
|
|
||||||
protected $description = 'Import legacy users_opinions into artwork_awards';
|
protected $description = 'Import legacy users_opinions into artwork_medals';
|
||||||
|
|
||||||
/** Maps legacy score value → medal string */
|
/** Maps legacy score value → medal string */
|
||||||
private const SCORE_MAP = [
|
private const SCORE_MAP = [
|
||||||
4 => 'gold',
|
0 => 'bronze',
|
||||||
|
1 => 'bronze',
|
||||||
|
2 => 'silver',
|
||||||
3 => 'silver',
|
3 => 'silver',
|
||||||
2 => 'bronze',
|
4 => 'gold',
|
||||||
|
5 => 'gold',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle(ArtworkAwardService $service): int
|
public function handle(ArtworkAwardService $service): int
|
||||||
{
|
{
|
||||||
|
$legacyConnection = (string) $this->option('connection');
|
||||||
|
$artworkIds = collect((array) $this->option('artwork-id'))
|
||||||
|
->map(static fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(static fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$showDuplicates = (bool) $this->option('show-duplicates');
|
||||||
|
$duplicatesLimit = max(1, (int) $this->option('duplicates-limit'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$chunk = max(1, (int) $this->option('chunk'));
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
$skipStats = (bool) $this->option('skip-stats');
|
$skipStats = (bool) $this->option('skip-stats');
|
||||||
@@ -56,17 +72,24 @@ class ImportLegacyAwards extends Command
|
|||||||
|
|
||||||
// Verify legacy connection is reachable
|
// Verify legacy connection is reachable
|
||||||
try {
|
try {
|
||||||
DB::connection('legacy')->getPdo();
|
DB::connection($legacyConnection)->getPdo();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
$this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $e->getMessage());
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
|
if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable('users_opinions')) {
|
||||||
$this->error('Legacy table `users_opinions` not found.');
|
$this->error("Legacy table `users_opinions` not found on connection [{$legacyConnection}].");
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$legacyQuery = DB::connection($legacyConnection)->table('users_opinions');
|
||||||
|
|
||||||
|
if ($artworkIds !== []) {
|
||||||
|
$legacyQuery->whereIn('artwork_id', $artworkIds);
|
||||||
|
$this->info('Restricting import to artwork IDs: ' . implode(', ', $artworkIds));
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-load sets of valid artwork IDs and user IDs from the new DB
|
// Pre-load sets of valid artwork IDs and user IDs from the new DB
|
||||||
$this->info('Loading new-DB artwork and user ID sets…');
|
$this->info('Loading new-DB artwork and user ID sets…');
|
||||||
$validArtworkIds = DB::table('artworks')
|
$validArtworkIds = DB::table('artworks')
|
||||||
@@ -88,9 +111,7 @@ class ImportLegacyAwards extends Command
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Count legacy rows for progress bar
|
// Count legacy rows for progress bar
|
||||||
$total = DB::connection('legacy')
|
$total = (clone $legacyQuery)->count();
|
||||||
->table('users_opinions')
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$this->info("Legacy rows to process: {$total}");
|
$this->info("Legacy rows to process: {$total}");
|
||||||
|
|
||||||
@@ -105,11 +126,13 @@ class ImportLegacyAwards extends Command
|
|||||||
'skipped_artwork' => 0,
|
'skipped_artwork' => 0,
|
||||||
'skipped_user' => 0,
|
'skipped_user' => 0,
|
||||||
'skipped_duplicate'=> 0,
|
'skipped_duplicate'=> 0,
|
||||||
|
'reported_duplicate'=> 0,
|
||||||
'updated_force' => 0,
|
'updated_force' => 0,
|
||||||
'errors' => 0,
|
'errors' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
$affectedArtworkIds = [];
|
$affectedArtworkIds = [];
|
||||||
|
$duplicateRows = [];
|
||||||
|
|
||||||
$bar = $this->output->createProgressBar($total);
|
$bar = $this->output->createProgressBar($total);
|
||||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||||
@@ -117,24 +140,30 @@ class ImportLegacyAwards extends Command
|
|||||||
$bar->setMessage('0', 'skipped');
|
$bar->setMessage('0', 'skipped');
|
||||||
$bar->start();
|
$bar->start();
|
||||||
|
|
||||||
DB::connection('legacy')
|
$legacyQuery
|
||||||
->table('users_opinions')
|
|
||||||
->orderBy('opinion_id')
|
->orderBy('opinion_id')
|
||||||
->chunk($chunk, function ($rows) use (
|
->chunk($chunk, function ($rows) use (
|
||||||
&$stats,
|
&$stats,
|
||||||
&$affectedArtworkIds,
|
&$affectedArtworkIds,
|
||||||
|
&$duplicateRows,
|
||||||
$validArtworkIds,
|
$validArtworkIds,
|
||||||
$validUserIds,
|
$validUserIds,
|
||||||
$dryRun,
|
$dryRun,
|
||||||
$force,
|
$force,
|
||||||
|
$showDuplicates,
|
||||||
|
$duplicatesLimit,
|
||||||
$bar
|
$bar
|
||||||
) {
|
) {
|
||||||
$inserts = [];
|
$inserts = [];
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
// Legacy users_opinions semantics:
|
||||||
|
// - artwork_id = the artwork being scored
|
||||||
|
// - author_id = the artwork owner / author
|
||||||
|
// - user_id = the voter who gave the score
|
||||||
$artworkId = (int) $row->artwork_id;
|
$artworkId = (int) $row->artwork_id;
|
||||||
$userId = (int) $row->author_id; // author_id = the voter
|
$userId = (int) $row->user_id;
|
||||||
$score = (int) $row->score;
|
$score = (int) $row->score;
|
||||||
$postedAt = $row->post_date ?? $now;
|
$postedAt = $row->post_date ?? $now;
|
||||||
|
|
||||||
@@ -163,11 +192,11 @@ class ImportLegacyAwards extends Command
|
|||||||
if (! $dryRun) {
|
if (! $dryRun) {
|
||||||
if ($force) {
|
if ($force) {
|
||||||
// Upsert: update medal if row already exists
|
// Upsert: update medal if row already exists
|
||||||
$affected = DB::table('artwork_awards')
|
$affected = DB::table('artwork_medals')
|
||||||
->where('artwork_id', $artworkId)
|
->where('artwork_id', $artworkId)
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->update([
|
->update([
|
||||||
'medal' => $medal,
|
'medal_type' => $medal,
|
||||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -180,13 +209,26 @@ class ImportLegacyAwards extends Command
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Skip if already exists
|
// Skip if already exists
|
||||||
if (
|
$existingMedal = DB::table('artwork_medals')
|
||||||
DB::table('artwork_awards')
|
|
||||||
->where('artwork_id', $artworkId)
|
->where('artwork_id', $artworkId)
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->exists()
|
->value('medal_type');
|
||||||
) {
|
|
||||||
|
if ($existingMedal !== null) {
|
||||||
$stats['skipped_duplicate']++;
|
$stats['skipped_duplicate']++;
|
||||||
|
|
||||||
|
if ($showDuplicates && count($duplicateRows) < $duplicatesLimit) {
|
||||||
|
$duplicateRows[] = [
|
||||||
|
'opinion_id' => (int) ($row->opinion_id ?? 0),
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'legacy_score' => $score,
|
||||||
|
'legacy_medal' => $medal,
|
||||||
|
'existing_medal' => (string) $existingMedal,
|
||||||
|
];
|
||||||
|
$stats['reported_duplicate']++;
|
||||||
|
}
|
||||||
|
|
||||||
$bar->advance();
|
$bar->advance();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -195,7 +237,7 @@ class ImportLegacyAwards extends Command
|
|||||||
$inserts[] = [
|
$inserts[] = [
|
||||||
'artwork_id' => $artworkId,
|
'artwork_id' => $artworkId,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'medal' => $medal,
|
'medal_type' => $medal,
|
||||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||||
'created_at' => $postedAt,
|
'created_at' => $postedAt,
|
||||||
'updated_at' => $postedAt,
|
'updated_at' => $postedAt,
|
||||||
@@ -212,12 +254,12 @@ class ImportLegacyAwards extends Command
|
|||||||
// stats are recalculated in bulk at the end for performance)
|
// stats are recalculated in bulk at the end for performance)
|
||||||
if (! $dryRun && ! empty($inserts)) {
|
if (! $dryRun && ! empty($inserts)) {
|
||||||
try {
|
try {
|
||||||
DB::table('artwork_awards')->insert($inserts);
|
DB::table('artwork_medals')->insert($inserts);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Fallback: insert one-by-one to isolate constraint violations
|
// Fallback: insert one-by-one to isolate constraint violations
|
||||||
foreach ($inserts as $row) {
|
foreach ($inserts as $row) {
|
||||||
try {
|
try {
|
||||||
DB::table('artwork_awards')->insertOrIgnore([$row]);
|
DB::table('artwork_medals')->insertOrIgnore([$row]);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$stats['errors']++;
|
$stats['errors']++;
|
||||||
}
|
}
|
||||||
@@ -277,6 +319,30 @@ class ImportLegacyAwards extends Command
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($showDuplicates && $stats['skipped_duplicate'] > 0) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Duplicate rows skipped: %d. Showing %d row(s)%s.',
|
||||||
|
$stats['skipped_duplicate'],
|
||||||
|
count($duplicateRows),
|
||||||
|
$stats['skipped_duplicate'] > count($duplicateRows) ? " (truncated by --duplicates-limit={$duplicatesLimit})" : ''
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($duplicateRows !== []) {
|
||||||
|
$this->table(
|
||||||
|
['Legacy opinion', 'Artwork ID', 'Voter user_id', 'Legacy score', 'Legacy medal', 'Existing medal'],
|
||||||
|
array_map(static fn (array $row): array => [
|
||||||
|
$row['opinion_id'],
|
||||||
|
$row['artwork_id'],
|
||||||
|
$row['user_id'],
|
||||||
|
$row['legacy_score'],
|
||||||
|
$row['legacy_medal'],
|
||||||
|
$row['existing_medal'],
|
||||||
|
], $duplicateRows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
109
app/Console/Commands/ImportLegacyNewsCommand.php
Normal file
109
app/Console/Commands/ImportLegacyNewsCommand.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use cPad\Plugins\News\Models\NewsArticle;
|
||||||
|
|
||||||
|
class ImportLegacyNewsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'news:import-legacy {--dry-run} {--limit=500} {--start=0}';
|
||||||
|
|
||||||
|
protected $description = 'Import News articles from legacy DB into the current Skinbase news_articles table.';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
$start = (int) $this->option('start');
|
||||||
|
|
||||||
|
// Verify legacy DB connection exists and is reachable
|
||||||
|
try {
|
||||||
|
DB::connection('legacy')->getPdo();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Cannot connect to legacy database via connection "legacy": ' . $e->getMessage());
|
||||||
|
Log::error('Legacy import failed - cannot connect to legacy', ['exception' => $e]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('news')) {
|
||||||
|
$this->error('Legacy table `news` not found on legacy connection.');
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Fetching up to %d legacy rows starting at %d...', $limit, $start));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = DB::connection('legacy')->table('news')
|
||||||
|
->orderBy('news_id')
|
||||||
|
->skip($start)
|
||||||
|
->take($limit)
|
||||||
|
->get();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Failed to query legacy DB: ' . $e->getMessage());
|
||||||
|
Log::error('Legacy import failed', ['exception' => $e]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
$this->info('No rows found in legacy news table.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Processing ' . $rows->count() . ' rows...');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
// Map fields conservatively — adjust mapping as needed for your legacy schema
|
||||||
|
$title = $row->headline ?? ($row->title ?? '');
|
||||||
|
$content = $row->content ?? ($row->message ?? '');
|
||||||
|
$excerpt = $row->preview ?? null;
|
||||||
|
$publishedAt = $row->create_date ?? ($row->published_at ?? null);
|
||||||
|
|
||||||
|
// Best-effort author mapping: try username/uname then fallback to user id 1
|
||||||
|
$authorId = 1;
|
||||||
|
if (!empty($row->uname)) {
|
||||||
|
$uid = DB::table('users')->where('username', $row->uname)->orWhere('uname', $row->uname)->value('id');
|
||||||
|
if ($uid) {
|
||||||
|
$authorId = $uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => NewsArticle::generateUniqueSlug($title),
|
||||||
|
'excerpt' => $excerpt,
|
||||||
|
'content' => $content,
|
||||||
|
'cover_image' => $row->picture ?? null,
|
||||||
|
'type' => 'announcement',
|
||||||
|
'author_id' => $authorId,
|
||||||
|
'category_id' => null,
|
||||||
|
'editorial_status' => isset($row->type) && (int)$row->type === 0 ? NewsArticle::EDITORIAL_STATUS_DRAFT : NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||||
|
'published_at' => $publishedAt ? date('Y-m-d H:i:s', strtotime($publishedAt)) : null,
|
||||||
|
'is_featured' => ($row->frontpage ?? 0) == 1,
|
||||||
|
'is_pinned' => ($row->type ?? 0) == 2,
|
||||||
|
'views' => $row->views ?? 0,
|
||||||
|
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line('[dry-run] Would insert: ' . $payload['title'] . ' (' . ($payload['published_at'] ?? 'no-date') . ')');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
NewsArticle::create($payload);
|
||||||
|
$created++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Failed to insert legacy article ' . ($row->news_id ?? '?') . ': ' . $e->getMessage());
|
||||||
|
Log::error('import-legacy: insert failed', ['exception' => $e, 'row' => $row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Done. Created %d articles (dry-run=%s).', $created, $dryRun ? 'yes' : 'no'));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Console/Commands/RebuildCreatorErasCommand.php
Normal file
90
app/Console/Commands/RebuildCreatorErasCommand.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Profile\CreatorEraService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild creator eras independently of milestones.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan creator-journey:rebuild-eras (all users)
|
||||||
|
* php artisan creator-journey:rebuild-eras {user_id} (single user)
|
||||||
|
*/
|
||||||
|
class RebuildCreatorErasCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'creator-journey:rebuild-eras
|
||||||
|
{user_id? : Rebuild eras for a single user}
|
||||||
|
{--chunk=500 : Chunk size when rebuilding all}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild creator era records from public artwork history (v2)';
|
||||||
|
|
||||||
|
public function handle(CreatorEraService $eraService, CreatorJourneyService $journeys): int
|
||||||
|
{
|
||||||
|
$userId = $this->argument('user_id');
|
||||||
|
|
||||||
|
if ($userId !== null) {
|
||||||
|
return $this->rebuildSingle((int) $userId, $eraService, $journeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rebuildAll($eraService, $journeys, (int) $this->option('chunk'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rebuildSingle(int $userId, CreatorEraService $eraService, CreatorJourneyService $journeys): int
|
||||||
|
{
|
||||||
|
$user = User::query()->find($userId);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
$this->error("User {$userId} not found.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to journeys service so eras + milestones stay in sync
|
||||||
|
$journeys->rebuildForUser($user);
|
||||||
|
|
||||||
|
$this->info("Rebuilt eras for user #{$userId}.");
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rebuildAll(CreatorEraService $eraService, CreatorJourneyService $journeys, int $chunk): int
|
||||||
|
{
|
||||||
|
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||||
|
$this->info("Rebuilding eras for {$total} users...");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$eras = 0;
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use ($journeys, $bar, &$eras): void {
|
||||||
|
foreach ($users as $userRow) {
|
||||||
|
try {
|
||||||
|
$user = User::query()->find($userRow->id);
|
||||||
|
if ($user) {
|
||||||
|
$journeys->rebuildForUser($user);
|
||||||
|
$eras++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn("Failed for user #{$userRow->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Rebuilt eras for {$eras} users.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Console/Commands/RebuildCreatorJourneyCommand.php
Normal file
123
app/Console/Commands/RebuildCreatorJourneyCommand.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\RebuildCreatorJourneyJob;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RebuildCreatorJourneyCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:rebuild-creator-journey
|
||||||
|
{user_id? : The ID of a single creator to rebuild}
|
||||||
|
{--all : Rebuild creator journey rows for all non-deleted users}
|
||||||
|
{--chunk=500 : Chunk size when --all is used}
|
||||||
|
{--queue : Dispatch rebuild jobs instead of rebuilding inline}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild persisted creator journey milestones from public source data';
|
||||||
|
|
||||||
|
public function handle(CreatorJourneyService $journeys): int
|
||||||
|
{
|
||||||
|
$userId = $this->argument('user_id');
|
||||||
|
$all = (bool) $this->option('all');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$queue = (bool) $this->option('queue');
|
||||||
|
|
||||||
|
if ($userId !== null && $all) {
|
||||||
|
$this->error('Provide either a user_id OR --all, not both.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId !== null) {
|
||||||
|
return $this->rebuildSingle((int) $userId, $journeys, $queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($all) {
|
||||||
|
return $this->rebuildAll($journeys, $chunk, $queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Provide a user_id or use --all.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rebuildSingle(int $userId, CreatorJourneyService $journeys, bool $queue): int
|
||||||
|
{
|
||||||
|
if (! DB::table('users')->where('id', $userId)->exists()) {
|
||||||
|
$this->error("User {$userId} not found.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($queue) {
|
||||||
|
RebuildCreatorJourneyJob::dispatch([$userId]);
|
||||||
|
$this->info("Queued creator journey rebuild for user #{$userId}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $journeys->rebuildForUser($userId);
|
||||||
|
|
||||||
|
$this->table(['Metric', 'Value'], [
|
||||||
|
['user_id', $userId],
|
||||||
|
['milestones_saved', $result['milestones_saved']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rebuildAll(CreatorJourneyService $journeys, int $chunk, bool $queue): int
|
||||||
|
{
|
||||||
|
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s Rebuilding creator journeys for %d users (chunk=%d)...',
|
||||||
|
$queue ? '[QUEUE]' : '[LIVE]',
|
||||||
|
$total,
|
||||||
|
$chunk,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($queue) {
|
||||||
|
$dispatched = 0;
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use (&$dispatched): void {
|
||||||
|
$ids = $users->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||||
|
RebuildCreatorJourneyJob::dispatch($ids);
|
||||||
|
$dispatched += count($ids);
|
||||||
|
$this->line(' Queued chunk of ' . count($ids) . ' users (total dispatched: ' . $dispatched . ')');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Done - {$dispatched} users queued for creator journey rebuild.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use ($journeys, &$processed, $bar): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$journeys->rebuildForUser((int) $user->id);
|
||||||
|
$processed++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Done - {$processed} creator journeys rebuilt.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Console/Commands/WarmHomepageGuestCacheCommand.php
Normal file
31
app/Console/Commands/WarmHomepageGuestCacheCommand.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\HomepageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class WarmHomepageGuestCacheCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'homepage:warm-guest-cache';
|
||||||
|
|
||||||
|
protected $description = 'Warm the guest homepage payload cache';
|
||||||
|
|
||||||
|
public function handle(HomepageService $homepage): int
|
||||||
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$payload = $homepage->warmGuestPayloadCache();
|
||||||
|
$durationMs = (microtime(true) - $startedAt) * 1000;
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Warmed guest homepage cache (%d sections) in %.2fms using store [%s].',
|
||||||
|
count($payload),
|
||||||
|
$durationMs,
|
||||||
|
$homepage->guestPayloadCacheStoreName(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ class Kernel extends ConsoleKernel
|
|||||||
RecalculateRankingsCommand::class,
|
RecalculateRankingsCommand::class,
|
||||||
MetricsSnapshotHourlyCommand::class,
|
MetricsSnapshotHourlyCommand::class,
|
||||||
RecalculateHeatCommand::class,
|
RecalculateHeatCommand::class,
|
||||||
|
\App\Console\Commands\RebuildCreatorErasCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
73
app/Enums/CreatorMilestoneType.php
Normal file
73
app/Enums/CreatorMilestoneType.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum CreatorMilestoneType: string
|
||||||
|
{
|
||||||
|
// ── v1 milestones ─────────────────────────────────────────────────────────
|
||||||
|
case FirstUpload = 'first_upload';
|
||||||
|
case FirstFeaturedArtwork = 'first_featured_artwork';
|
||||||
|
case FirstGroupRelease = 'first_group_release';
|
||||||
|
case BiggestDownloadSpike = 'biggest_download_spike';
|
||||||
|
case BestPerformingWork = 'best_performing_work';
|
||||||
|
case MostProductiveYear = 'most_productive_year';
|
||||||
|
case YearlyRecap = 'yearly_recap';
|
||||||
|
|
||||||
|
// ── v2: Comeback milestones ────────────────────────────────────────────────
|
||||||
|
case ComebackMinor = 'comeback_minor'; // 180–364 days gap
|
||||||
|
case ComebackMajor = 'comeback_major'; // 365–1094 days gap (1–3 years)
|
||||||
|
case ComebackLegendary = 'comeback_legendary'; // 1095+ days gap (3+ years)
|
||||||
|
|
||||||
|
// ── v2: Streak milestones ─────────────────────────────────────────────────
|
||||||
|
case UploadStreak3 = 'upload_streak_3';
|
||||||
|
case UploadStreak6 = 'upload_streak_6';
|
||||||
|
case UploadStreak12 = 'upload_streak_12';
|
||||||
|
case ActiveYearStreak3 = 'active_year_streak_3';
|
||||||
|
case ActiveYearStreak5 = 'active_year_streak_5';
|
||||||
|
|
||||||
|
// ── v2: Evolution / Era milestones ────────────────────────────────────────
|
||||||
|
case BeforeNow = 'before_now';
|
||||||
|
case EraStarted = 'era_started';
|
||||||
|
|
||||||
|
public function priority(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::BestPerformingWork => 100,
|
||||||
|
self::BiggestDownloadSpike => 95,
|
||||||
|
self::FirstFeaturedArtwork => 90,
|
||||||
|
self::ComebackLegendary => 88,
|
||||||
|
self::UploadStreak12 => 87,
|
||||||
|
self::ActiveYearStreak5 => 86,
|
||||||
|
self::MostProductiveYear => 85,
|
||||||
|
self::ActiveYearStreak3 => 84,
|
||||||
|
self::UploadStreak6 => 83,
|
||||||
|
self::FirstGroupRelease => 80,
|
||||||
|
self::BeforeNow => 78,
|
||||||
|
self::ComebackMajor => 77,
|
||||||
|
self::EraStarted => 76,
|
||||||
|
self::FirstUpload => 75,
|
||||||
|
self::UploadStreak3 => 72,
|
||||||
|
self::ComebackMinor => 70,
|
||||||
|
self::YearlyRecap => 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isV2(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ComebackMinor,
|
||||||
|
self::ComebackMajor,
|
||||||
|
self::ComebackLegendary,
|
||||||
|
self::UploadStreak3,
|
||||||
|
self::UploadStreak6,
|
||||||
|
self::UploadStreak12,
|
||||||
|
self::ActiveYearStreak3,
|
||||||
|
self::ActiveYearStreak5,
|
||||||
|
self::BeforeNow,
|
||||||
|
self::EraStarted => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,16 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Services\ArtworkAwardService;
|
use App\Models\ArtworkMedal;
|
||||||
|
use App\Models\ArtworkMedalStat;
|
||||||
|
use App\Services\ArtworkMedalService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final class ArtworkAwardController extends Controller
|
final class ArtworkAwardController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkAwardService $service
|
private readonly ArtworkMedalService $service
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +34,7 @@ final class ArtworkAwardController extends Controller
|
|||||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$award = $this->service->award($artwork, $user, $data['medal']);
|
$this->service->award($artwork, $user, $data['medal']);
|
||||||
|
|
||||||
// Record activity event
|
// Record activity event
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +53,32 @@ final class ArtworkAwardController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function upsert(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
$this->authorize('award', [ArtworkAward::class, $artwork]);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existed = ArtworkMedal::query()
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$this->service->upsert($artwork, $user, $data['medal_type']);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
array_merge($this->buildPayload($artwork->id, $user->id), [
|
||||||
|
'message' => $existed ? 'Medal updated.' : 'Medal added.',
|
||||||
|
]),
|
||||||
|
$existed ? 200 : 201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/artworks/{id}/award
|
* PUT /api/artworks/{id}/award
|
||||||
* Change an existing award medal.
|
* Change an existing award medal.
|
||||||
@@ -60,7 +88,7 @@ final class ArtworkAwardController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$artwork = Artwork::findOrFail($id);
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
@@ -70,7 +98,7 @@ final class ArtworkAwardController extends Controller
|
|||||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$award = $this->service->changeAward($artwork, $user, $data['medal']);
|
$this->service->changeMedal($artwork, $user, $data['medal']);
|
||||||
|
|
||||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||||
}
|
}
|
||||||
@@ -84,17 +112,29 @@ final class ArtworkAwardController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$artwork = Artwork::findOrFail($id);
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$this->authorize('remove', $existingAward);
|
$this->authorize('remove', $existingAward);
|
||||||
|
|
||||||
$this->service->removeAward($artwork, $user);
|
$this->service->removeMedal($artwork, $user);
|
||||||
|
|
||||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroyMedal(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
$this->service->removeMedal($artwork, $user);
|
||||||
|
|
||||||
|
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
|
||||||
|
'message' => 'Medal removed.',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/artworks/{id}/awards
|
* GET /api/artworks/{id}/awards
|
||||||
* Return award stats + viewer's current award.
|
* Return award stats + viewer's current award.
|
||||||
@@ -111,22 +151,29 @@ final class ArtworkAwardController extends Controller
|
|||||||
|
|
||||||
private function buildPayload(int $artworkId, ?int $userId): array
|
private function buildPayload(int $artworkId, ?int $userId): array
|
||||||
{
|
{
|
||||||
$stat = \App\Models\ArtworkAwardStat::find($artworkId);
|
$stat = ArtworkMedalStat::find($artworkId);
|
||||||
|
|
||||||
$userAward = $userId
|
$userAward = $userId
|
||||||
? ArtworkAward::where('artwork_id', $artworkId)
|
? ArtworkMedal::where('artwork_id', $artworkId)
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->value('medal')
|
->value('medal_type')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
$medals = [
|
||||||
|
'gold' => (int) ($stat?->gold_count ?? 0),
|
||||||
|
'silver' => (int) ($stat?->silver_count ?? 0),
|
||||||
|
'bronze' => (int) ($stat?->bronze_count ?? 0),
|
||||||
|
'score' => (int) ($stat?->score_total ?? 0),
|
||||||
|
'score_7d' => (int) ($stat?->score_7d ?? 0),
|
||||||
|
'score_30d' => (int) ($stat?->score_30d ?? 0),
|
||||||
|
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'awards' => [
|
'awards' => $medals,
|
||||||
'gold' => $stat?->gold_count ?? 0,
|
'medals' => $medals,
|
||||||
'silver' => $stat?->silver_count ?? 0,
|
|
||||||
'bronze' => $stat?->bronze_count ?? 0,
|
|
||||||
'score' => $stat?->score_total ?? 0,
|
|
||||||
],
|
|
||||||
'viewer_award' => $userAward,
|
'viewer_award' => $userAward,
|
||||||
|
'current_user_medal' => $userAward,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkStatsService;
|
use App\Services\ArtworkStatsService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -31,7 +32,10 @@ use Illuminate\Support\Str;
|
|||||||
*/
|
*/
|
||||||
final class ArtworkDownloadController extends Controller
|
final class ArtworkDownloadController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
public function __construct(
|
||||||
|
private readonly ArtworkStatsService $stats,
|
||||||
|
private readonly CreatorJourneyService $journeys,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, int $id): JsonResponse
|
public function __invoke(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -48,13 +52,15 @@ final class ArtworkDownloadController extends Controller
|
|||||||
// Record the download event — non-blocking, errors are swallowed.
|
// Record the download event — non-blocking, errors are swallowed.
|
||||||
$this->recordDownload($request, $artwork);
|
$this->recordDownload($request, $artwork);
|
||||||
|
|
||||||
// Increment counters — deferred via Redis when available.
|
// Increment counters immediately so Studio stats stay fresh.
|
||||||
try {
|
try {
|
||||||
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
|
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Stats failure must never interrupt the download.
|
// Stats failure must never interrupt the download.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
|
||||||
// Resolve the highest-resolution download URL available.
|
// Resolve the highest-resolution download URL available.
|
||||||
$url = $this->resolveDownloadUrl($artwork);
|
$url = $this->resolveDownloadUrl($artwork);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Models\Artwork;
|
|||||||
use App\Notifications\ArtworkLikedNotification;
|
use App\Notifications\ArtworkLikedNotification;
|
||||||
use App\Services\FollowService;
|
use App\Services\FollowService;
|
||||||
use App\Services\Activity\UserActivityService;
|
use App\Services\Activity\UserActivityService;
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use App\Services\XPService;
|
use App\Services\XPService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -168,7 +169,7 @@ final class ArtworkInteractionController extends Controller
|
|||||||
public function share(Request $request, int $artworkId): JsonResponse
|
public function share(Request $request, int $artworkId): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
|
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed,native'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (Schema::hasTable('artwork_shares')) {
|
if (Schema::hasTable('artwork_shares')) {
|
||||||
@@ -178,6 +179,8 @@ final class ArtworkInteractionController extends Controller
|
|||||||
'platform' => $data['platform'],
|
'platform' => $data['platform'],
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncArtworkStats($artworkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
@@ -216,25 +219,7 @@ final class ArtworkInteractionController extends Controller
|
|||||||
|
|
||||||
private function syncArtworkStats(int $artworkId): void
|
private function syncArtworkStats(int $artworkId): void
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('artwork_stats')) {
|
app(ArtworkStatsService::class)->syncEngagementCounts($artworkId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$favorites = Schema::hasTable('artwork_favourites')
|
|
||||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
$likes = Schema::hasTable('artwork_likes')
|
|
||||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
DB::table('artwork_stats')->updateOrInsert(
|
|
||||||
['artwork_id' => $artworkId],
|
|
||||||
[
|
|
||||||
'favorites' => $favorites,
|
|
||||||
'rating_count' => $likes,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function statusPayload(int $viewerId, int $artworkId): array
|
private function statusPayload(int $viewerId, int $artworkId): array
|
||||||
|
|||||||
@@ -16,14 +16,10 @@ use Illuminate\Http\Request;
|
|||||||
*
|
*
|
||||||
* Fire-and-forget view tracker.
|
* Fire-and-forget view tracker.
|
||||||
*
|
*
|
||||||
* Deduplication strategy (layered):
|
* Every page visit should count as a new view.
|
||||||
* 1. Session key (`art_viewed.{id}`) — prevents double-counts within the
|
* Lightweight abuse protection is handled at the route layer via throttling,
|
||||||
* same browser session (survives page reloads).
|
* while the stat increment itself is applied immediately so Studio analytics
|
||||||
* 2. Route throttle (5 per 10 minutes per IP+artwork) — catches bots that
|
* reflect new visits without waiting for the scheduler to flush Redis deltas.
|
||||||
* don't send session cookies.
|
|
||||||
*
|
|
||||||
* The frontend should additionally guard with sessionStorage so it only
|
|
||||||
* calls this endpoint once per page load.
|
|
||||||
*/
|
*/
|
||||||
final class ArtworkViewController extends Controller
|
final class ArtworkViewController extends Controller
|
||||||
{
|
{
|
||||||
@@ -43,18 +39,11 @@ final class ArtworkViewController extends Controller
|
|||||||
return response()->json(['error' => 'Not found'], 404);
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sessionKey = 'art_viewed.' . $id;
|
|
||||||
|
|
||||||
// Already counted this session — return early without touching the DB.
|
|
||||||
if ($request->hasSession() && $request->session()->has($sessionKey)) {
|
|
||||||
return response()->json(['ok' => true, 'counted' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write persistent event log (auth user_id or null for guests).
|
// Write persistent event log (auth user_id or null for guests).
|
||||||
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
|
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
|
||||||
|
|
||||||
// Defer to Redis when available, fall back to direct DB increment.
|
// Apply the increment immediately so counters stay fresh in Studio.
|
||||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
$this->stats->incrementViews((int) $artwork->id, 1, defer: false);
|
||||||
|
|
||||||
$viewerId = $request->user()?->id;
|
$viewerId = $request->user()?->id;
|
||||||
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
||||||
@@ -66,11 +55,6 @@ final class ArtworkViewController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this session so the artwork is not counted again.
|
|
||||||
if ($request->hasSession()) {
|
|
||||||
$request->session()->put($sessionKey, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['ok' => true, 'counted' => true]);
|
return response()->json(['ok' => true, 'counted' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ final class ProfileApiController extends Controller
|
|||||||
|
|
||||||
$query = Artwork::with([
|
$query = Artwork::with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
|
'user.profile:user_id,avatar_hash',
|
||||||
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($query) {
|
'categories' => function ($query) {
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
@@ -115,6 +117,8 @@ final class ProfileApiController extends Controller
|
|||||||
|
|
||||||
$indexed = Artwork::with([
|
$indexed = Artwork::with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
|
'user.profile:user_id,avatar_hash',
|
||||||
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($query) {
|
'categories' => function ($query) {
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
@@ -190,6 +194,15 @@ final class ProfileApiController extends Controller
|
|||||||
$category = $art->categories->first();
|
$category = $art->categories->first();
|
||||||
$contentType = $category?->contentType;
|
$contentType = $category?->contentType;
|
||||||
$stats = $art->stats;
|
$stats = $art->stats;
|
||||||
|
$group = $art->group;
|
||||||
|
$isGroupPublisher = $group !== null;
|
||||||
|
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
|
||||||
|
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
|
||||||
|
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||||
|
$profileUrl = $isGroupPublisher
|
||||||
|
? $group->publicUrl()
|
||||||
|
: ($username ? '/@' . $username : null);
|
||||||
|
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $art->id,
|
'id' => $art->id,
|
||||||
@@ -198,8 +211,22 @@ final class ProfileApiController extends Controller
|
|||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'width' => $art->width,
|
'width' => $art->width,
|
||||||
'height' => $art->height,
|
'height' => $art->height,
|
||||||
'username' => $art->user->username ?? null,
|
'username' => $username,
|
||||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'profile_url' => $profileUrl,
|
||||||
|
'published_as_type' => $publisherType,
|
||||||
|
'publisher' => [
|
||||||
|
'type' => $publisherType,
|
||||||
|
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
|
||||||
|
'name' => $displayName,
|
||||||
|
'username' => $username ?? '',
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'profile_url' => $profileUrl,
|
||||||
|
],
|
||||||
|
'user_id' => $art->user_id,
|
||||||
|
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
|
||||||
|
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
|
||||||
'content_type' => $contentType?->name,
|
'content_type' => $contentType?->name,
|
||||||
'content_type_slug' => $contentType?->slug,
|
'content_type_slug' => $contentType?->slug,
|
||||||
'category' => $category?->name,
|
'category' => $category?->name,
|
||||||
|
|||||||
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal file
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
final class ProfileJourneyController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CreatorJourneyService $journeys)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
|
||||||
|
$user = User::query()
|
||||||
|
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->journeys->publicPayloadForUser($user),
|
||||||
|
'meta' => [
|
||||||
|
'username' => (string) $user->username,
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Http\Resources\ArtworkListResource;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use App\Services\RankingService;
|
use App\Services\RankingService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -26,7 +27,10 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|||||||
*/
|
*/
|
||||||
class RankController extends Controller
|
class RankController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly RankingService $ranking) {}
|
public function __construct(
|
||||||
|
private readonly RankingService $ranking,
|
||||||
|
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/rank/global
|
* GET /api/rank/global
|
||||||
@@ -65,7 +69,7 @@ class RankController extends Controller
|
|||||||
{
|
{
|
||||||
$ct = is_numeric($contentType)
|
$ct = is_numeric($contentType)
|
||||||
? ContentType::find((int) $contentType)
|
? ContentType::find((int) $contentType)
|
||||||
: ContentType::where('slug', $contentType)->first();
|
: $this->contentTypeResolver->resolve($contentType)->contentType;
|
||||||
|
|
||||||
if ($ct === null) {
|
if ($ct === null) {
|
||||||
return response()->json(['message' => 'Content type not found.'], 404);
|
return response()->json(['message' => 'Content type not found.'], 404);
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ final class SuggestedCreatorsController extends Controller
|
|||||||
u.username,
|
u.username,
|
||||||
up.avatar_hash,
|
up.avatar_hash,
|
||||||
COALESCE(us.followers_count, 0) as followers_count,
|
COALESCE(us.followers_count, 0) as followers_count,
|
||||||
COALESCE(us.artworks_count, 0) as artworks_count,
|
COALESCE(us.uploads_count, 0) as artworks_count,
|
||||||
COUNT(*) as mutual_weight
|
COUNT(*) as mutual_weight
|
||||||
')
|
')
|
||||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
|
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
|
||||||
->orderByDesc('mutual_weight')
|
->orderByDesc('mutual_weight')
|
||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
@@ -117,10 +117,10 @@ final class SuggestedCreatorsController extends Controller
|
|||||||
u.username,
|
u.username,
|
||||||
up.avatar_hash,
|
up.avatar_hash,
|
||||||
COALESCE(us.followers_count, 0) as followers_count,
|
COALESCE(us.followers_count, 0) as followers_count,
|
||||||
COALESCE(us.artworks_count, 0) as artworks_count,
|
COALESCE(us.uploads_count, 0) as artworks_count,
|
||||||
COUNT(DISTINCT t.id) as matched_tags
|
COUNT(DISTINCT t.id) as matched_tags
|
||||||
')
|
')
|
||||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
|
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
|
||||||
->orderByDesc('matched_tags')
|
->orderByDesc('matched_tags')
|
||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
@@ -197,7 +197,7 @@ final class SuggestedCreatorsController extends Controller
|
|||||||
u.username,
|
u.username,
|
||||||
up.avatar_hash,
|
up.avatar_hash,
|
||||||
COALESCE(us.followers_count, 0) as followers_count,
|
COALESCE(us.followers_count, 0) as followers_count,
|
||||||
COALESCE(us.artworks_count, 0) as artworks_count
|
COALESCE(us.uploads_count, 0) as artworks_count
|
||||||
')
|
')
|
||||||
->orderByDesc('followers_count')
|
->orderByDesc('followers_count')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use App\Uploads\Jobs\VirusScanJob;
|
|||||||
use App\Uploads\Services\PublishService;
|
use App\Uploads\Services\PublishService;
|
||||||
use App\Services\Activity\UserActivityService;
|
use App\Services\Activity\UserActivityService;
|
||||||
use App\Services\ArtworkAttributionService;
|
use App\Services\ArtworkAttributionService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Uploads\Exceptions\UploadNotFoundException;
|
use App\Uploads\Exceptions\UploadNotFoundException;
|
||||||
use App\Uploads\Exceptions\UploadOwnershipException;
|
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||||
use App\Uploads\Exceptions\UploadPublishValidationException;
|
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||||
@@ -558,7 +559,7 @@ final class UploadController extends Controller
|
|||||||
], Response::HTTP_OK);
|
], Response::HTTP_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution)
|
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -566,7 +567,7 @@ final class UploadController extends Controller
|
|||||||
'title' => ['nullable', 'string', 'max:150'],
|
'title' => ['nullable', 'string', 'max:150'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||||
'tags' => ['nullable', 'array', 'max:15'],
|
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||||
'tags.*' => ['string', 'max:64'],
|
'tags.*' => ['string', 'max:64'],
|
||||||
'is_mature' => ['nullable', 'boolean'],
|
'is_mature' => ['nullable', 'boolean'],
|
||||||
'nsfw' => ['nullable', 'boolean'],
|
'nsfw' => ['nullable', 'boolean'],
|
||||||
@@ -657,6 +658,7 @@ final class UploadController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$artwork->save();
|
$artwork->save();
|
||||||
|
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
|
||||||
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
||||||
|
|
||||||
if ($mode === 'schedule' && $publishAt) {
|
if ($mode === 'schedule' && $publishAt) {
|
||||||
@@ -760,7 +762,7 @@ final class UploadController extends Controller
|
|||||||
'title' => ['nullable', 'string', 'max:150'],
|
'title' => ['nullable', 'string', 'max:150'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||||
'tags' => ['nullable', 'array', 'max:15'],
|
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||||
'tags.*' => ['string', 'max:64'],
|
'tags.*' => ['string', 'max:64'],
|
||||||
'is_mature' => ['nullable', 'boolean'],
|
'is_mature' => ['nullable', 'boolean'],
|
||||||
'nsfw' => ['nullable', 'boolean'],
|
'nsfw' => ['nullable', 'boolean'],
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\ArtworkIndexRequest;
|
use App\Http\Requests\ArtworkIndexRequest;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ArtworkController extends Controller
|
class ArtworkController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse artworks with optional category filtering.
|
* Browse artworks with optional category filtering.
|
||||||
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
|
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
|
||||||
@@ -58,6 +63,17 @@ class ArtworkController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
|
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
|
||||||
{
|
{
|
||||||
|
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
|
||||||
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
return $this->redirectToCanonicalArtworkPath($request, $resolvedContentTypeSlug, $categoryPath, $artwork, 301);
|
||||||
|
}
|
||||||
|
|
||||||
// Manually resolve artwork by slug when provided. The route may bind
|
// Manually resolve artwork by slug when provided. The route may bind
|
||||||
// the 'artwork' parameter to an Artwork model or pass the slug string.
|
// the 'artwork' parameter to an Artwork model or pass the slug string.
|
||||||
$foundArtwork = null;
|
$foundArtwork = null;
|
||||||
@@ -67,7 +83,7 @@ class ArtworkController extends Controller
|
|||||||
$artworkSlug = $artwork->slug;
|
$artworkSlug = $artwork->slug;
|
||||||
} elseif ($artwork) {
|
} elseif ($artwork) {
|
||||||
$artworkSlug = (string) $artwork;
|
$artworkSlug = (string) $artwork;
|
||||||
$foundArtwork = $this->findArtworkForCategoryPath($contentTypeSlug, $categoryPath, $artworkSlug);
|
$foundArtwork = $this->findArtworkForCategoryPath($resolvedContentTypeSlug, $categoryPath, $artworkSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
|
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
|
||||||
@@ -75,9 +91,9 @@ class ArtworkController extends Controller
|
|||||||
// behave consistently.
|
// behave consistently.
|
||||||
if (! empty($artworkSlug)) {
|
if (! empty($artworkSlug)) {
|
||||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||||
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath);
|
$resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath);
|
||||||
if ($resolvedCategory) {
|
if ($resolvedCategory) {
|
||||||
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
|
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +106,7 @@ class ArtworkController extends Controller
|
|||||||
if ($artworkSlug) {
|
if ($artworkSlug) {
|
||||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||||
}
|
}
|
||||||
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
|
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
|
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
|
||||||
@@ -108,9 +124,8 @@ class ArtworkController extends Controller
|
|||||||
|
|
||||||
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
|
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
|
||||||
{
|
{
|
||||||
$contentType = ContentType::query()->where('slug', strtolower($contentTypeSlug))->first();
|
|
||||||
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
|
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
|
||||||
$category = $contentType ? Category::findByPath($contentType->slug, $segments) : null;
|
$category = Category::findByPath(strtolower($contentTypeSlug), $segments);
|
||||||
|
|
||||||
$query = Artwork::query()->where('slug', $artworkSlug);
|
$query = Artwork::query()->where('slug', $artworkSlug);
|
||||||
|
|
||||||
@@ -125,4 +140,17 @@ class ArtworkController extends Controller
|
|||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function redirectToCanonicalArtworkPath(Request $request, string $contentTypeSlug, string $categoryPath, Artwork|string|null $artwork, int $status = 301): RedirectResponse
|
||||||
|
{
|
||||||
|
$artworkSlug = $artwork instanceof Artwork ? $artwork->slug : (string) $artwork;
|
||||||
|
$target = url('/' . trim($contentTypeSlug . '/' . trim($categoryPath, '/') . '/' . trim($artworkSlug, '/'), '/'));
|
||||||
|
$queryString = $request->getQueryString();
|
||||||
|
|
||||||
|
if ($queryString) {
|
||||||
|
$target .= '?' . $queryString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($target, $status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkDownload;
|
use App\Models\ArtworkDownload;
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -34,6 +35,10 @@ final class ArtworkDownloadController extends Controller
|
|||||||
'gz',
|
'gz',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArtworkStatsService $stats,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, int $id): BinaryFileResponse
|
public function __invoke(Request $request, int $id): BinaryFileResponse
|
||||||
{
|
{
|
||||||
$artwork = Artwork::query()->find($id);
|
$artwork = Artwork::query()->find($id);
|
||||||
@@ -51,6 +56,15 @@ final class ArtworkDownloadController extends Controller
|
|||||||
$this->recordDownload($request, $artwork->id);
|
$this->recordDownload($request, $artwork->id);
|
||||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('Failed to increment artwork_stats download counter.', [
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (! File::isFile($filePath)) {
|
if (! File::isFile($filePath)) {
|
||||||
Log::warning('Artwork original file missing for download.', [
|
Log::warning('Artwork original file missing for download.', [
|
||||||
'artwork_id' => $artwork->id,
|
'artwork_id' => $artwork->id,
|
||||||
|
|||||||
@@ -3,23 +3,39 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class CategoryPageController extends Controller
|
class CategoryPageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private ArtworkService $artworkService)
|
public function __construct(
|
||||||
|
private ArtworkService $artworkService,
|
||||||
|
private ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
|
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
|
||||||
{
|
{
|
||||||
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
|
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
|
||||||
if (! $contentType) {
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$contentType = $resolution->contentType;
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
$target = url('/' . trim($contentType->slug . '/' . trim((string) $categoryPath, '/'), '/'));
|
||||||
|
$queryString = $request->getQueryString();
|
||||||
|
|
||||||
|
if ($queryString) {
|
||||||
|
$target .= '?' . $queryString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($target, 301);
|
||||||
|
}
|
||||||
|
|
||||||
$sort = (string) $request->get('sort', 'latest');
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,31 +7,45 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use App\Models\ContentType;
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
|
|
||||||
class PhotographyController extends Controller
|
class PhotographyController extends Controller
|
||||||
{
|
{
|
||||||
protected ArtworkService $artworks;
|
protected ArtworkService $artworks;
|
||||||
|
|
||||||
public function __construct(ArtworkService $artworks)
|
public function __construct(
|
||||||
|
ArtworkService $artworks,
|
||||||
|
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
$this->artworks = $artworks;
|
$this->artworks = $artworks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
// Legacy group mapping: Photography => id 3
|
|
||||||
// Determine the requested content type from the first URL segment (photography|wallpapers|skins)
|
|
||||||
$segment = strtolower($request->segment(1) ?? 'photography');
|
$segment = strtolower($request->segment(1) ?? 'photography');
|
||||||
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
|
$resolution = $this->contentTypeResolver->resolve($segment);
|
||||||
|
|
||||||
// Human-friendly group name (used by legacy templates)
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
$group = ucfirst($contentSlug);
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = $resolution->contentType;
|
||||||
|
$contentSlug = strtolower((string) $contentType->slug);
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
$target = url('/' . $contentSlug);
|
||||||
|
|
||||||
|
if ($request->getQueryString()) {
|
||||||
|
$target .= '?' . $request->getQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($target, 301);
|
||||||
|
}
|
||||||
|
|
||||||
// Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType
|
|
||||||
$id = null;
|
$id = null;
|
||||||
if ($contentSlug === 'photography') {
|
if ($contentSlug === 'photography') {
|
||||||
$id = 3; // legacy root id for photography in oldSite (kept for backward compatibility)
|
$id = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch legacy category info if available (only when we have an id)
|
// Fetch legacy category info if available (only when we have an id)
|
||||||
@@ -47,25 +61,20 @@ class PhotographyController extends Controller
|
|||||||
$category = null;
|
$category = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page title and description: prefer legacy category when present, otherwise use ContentType data
|
$page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
|
||||||
$ct = ContentType::where('slug', $contentSlug)->first();
|
$tidy = $category->description ?? ($contentType->description ?? null);
|
||||||
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
|
|
||||||
$tidy = $category->description ?? ($ct->description ?? null);
|
|
||||||
|
|
||||||
$perPage = 40;
|
$perPage = 40;
|
||||||
$sort = (string) $request->get('sort', 'latest');
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
|
|
||||||
// Load artworks for the requested content type using standard pagination
|
|
||||||
try {
|
try {
|
||||||
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
|
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Return an empty paginator so views using ->links() / ->firstItem() work
|
|
||||||
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
|
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
|
||||||
'path' => url()->current(),
|
'path' => url()->current(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories
|
|
||||||
$subcategories = collect();
|
$subcategories = collect();
|
||||||
try {
|
try {
|
||||||
if ($id !== null && Schema::hasTable('artworks_categories')) {
|
if ($id !== null && Schema::hasTable('artworks_categories')) {
|
||||||
@@ -79,18 +88,13 @@ class PhotographyController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $subcategories || $subcategories->count() === 0) {
|
if (! $subcategories || $subcategories->count() === 0) {
|
||||||
if ($ct) {
|
$subcategories = $contentType->rootCategories()
|
||||||
$subcategories = $ct->rootCategories()
|
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
|
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
|
||||||
} else {
|
|
||||||
$subcategories = collect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coerce collections to a paginator so the view's pagination helpers work
|
|
||||||
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
|
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
|
||||||
$page = (int) ($request->query('page', 1));
|
$page = (int) ($request->query('page', 1));
|
||||||
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
|
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
|
||||||
@@ -99,11 +103,7 @@ class PhotographyController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare variables for the modern content-type view
|
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||||
$contentType = ContentType::where('slug', $contentSlug)->first();
|
|
||||||
$rootCategories = $contentType
|
|
||||||
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
$page_meta_description = $tidy;
|
$page_meta_description = $tidy;
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use App\Models\ContentType;
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
|
|
||||||
class PhotographyController extends Controller
|
class PhotographyController extends Controller
|
||||||
{
|
{
|
||||||
protected ArtworkService $artworks;
|
protected ArtworkService $artworks;
|
||||||
|
|
||||||
public function __construct(ArtworkService $artworks)
|
public function __construct(
|
||||||
|
ArtworkService $artworks,
|
||||||
|
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
$this->artworks = $artworks;
|
$this->artworks = $artworks;
|
||||||
}
|
}
|
||||||
@@ -21,9 +24,24 @@ class PhotographyController extends Controller
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$segment = strtolower($request->segment(1) ?? 'photography');
|
$segment = strtolower($request->segment(1) ?? 'photography');
|
||||||
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
|
$resolution = $this->contentTypeResolver->resolve($segment);
|
||||||
|
|
||||||
$group = ucfirst($contentSlug);
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = $resolution->contentType;
|
||||||
|
$contentSlug = strtolower((string) $contentType->slug);
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
$target = url('/' . $contentSlug);
|
||||||
|
|
||||||
|
if ($request->getQueryString()) {
|
||||||
|
$target .= '?' . $request->getQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($target, 301);
|
||||||
|
}
|
||||||
|
|
||||||
$id = null;
|
$id = null;
|
||||||
if ($contentSlug === 'photography') {
|
if ($contentSlug === 'photography') {
|
||||||
@@ -42,9 +60,8 @@ class PhotographyController extends Controller
|
|||||||
$category = null;
|
$category = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ct = ContentType::where('slug', $contentSlug)->first();
|
$page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
|
||||||
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
|
$tidy = $category->description ?? ($contentType->description ?? null);
|
||||||
$tidy = $category->description ?? ($ct->description ?? null);
|
|
||||||
|
|
||||||
$perPage = 40;
|
$perPage = 40;
|
||||||
$sort = (string) $request->get('sort', 'latest');
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
@@ -70,15 +87,11 @@ class PhotographyController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $subcategories || $subcategories->count() === 0) {
|
if (! $subcategories || $subcategories->count() === 0) {
|
||||||
if ($ct) {
|
$subcategories = $contentType->rootCategories()
|
||||||
$subcategories = $ct->rootCategories()
|
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
|
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
|
||||||
} else {
|
|
||||||
$subcategories = collect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
|
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
|
||||||
@@ -89,10 +102,7 @@ class PhotographyController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentType = ContentType::where('slug', $contentSlug)->first();
|
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||||
$rootCategories = $contentType
|
|
||||||
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
$page_meta_description = $tidy;
|
$page_meta_description = $tidy;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ namespace App\Http\Controllers\RSS;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ContentType;
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use App\Services\RSS\RSSFeedBuilder;
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
@@ -30,36 +32,53 @@ final class ExploreFeedController extends Controller
|
|||||||
'latest' => 300,
|
'latest' => 300,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
public function __construct(
|
||||||
|
private readonly RSSFeedBuilder $builder,
|
||||||
|
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** /rss/explore/{type} — defaults to latest */
|
/** /rss/explore/{type} — defaults to latest */
|
||||||
public function byType(string $type): Response
|
public function byType(Request $request, string $type): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->feed($type, 'latest');
|
return $this->feed($request, $type, 'latest');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /rss/explore/{type}/{mode} */
|
/** /rss/explore/{type}/{mode} */
|
||||||
public function byTypeMode(string $type, string $mode): Response
|
public function byTypeMode(Request $request, string $type, string $mode): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->feed($type, $mode);
|
return $this->feed($request, $type, $mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function feed(string $type, string $mode): Response
|
private function feed(Request $request, string $type, string $mode): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
|
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
|
||||||
$ttl = self::SORT_TTL[$mode] ?? 300;
|
|
||||||
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
|
|
||||||
$label = ucfirst(str_replace('-', ' ', $type));
|
|
||||||
|
|
||||||
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
|
if (! $resolution->found()) {
|
||||||
$contentType = ContentType::where('slug', $type)->first();
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
|
||||||
|
$resolvedType = $resolution->isVirtual ? 'artworks' : strtolower((string) $resolution->contentType?->slug);
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
return redirect()->to(url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : '')) . ($request->getQueryString() ? ('?' . $request->getQueryString()) : ''), 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ttl = self::SORT_TTL[$mode] ?? 300;
|
||||||
|
$feedUrl = url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : ''));
|
||||||
|
$label = $resolution->isVirtual
|
||||||
|
? 'All Artworks'
|
||||||
|
: ($resolution->contentType?->name ?? ucfirst(str_replace('-', ' ', $resolvedType)));
|
||||||
|
|
||||||
|
$artworks = Cache::remember("rss:explore:{$resolvedType}:{$mode}", $ttl, function () use ($resolution, $mode) {
|
||||||
|
$contentType = $resolution->contentType;
|
||||||
|
|
||||||
$query = Artwork::public()->published()
|
$query = Artwork::public()->published()
|
||||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
|
||||||
|
|
||||||
if ($contentType) {
|
if (! $resolution->isVirtual && $contentType) {
|
||||||
$query->whereHas('categories', fn ($q) =>
|
$query->whereHas('categories', fn ($q) =>
|
||||||
$q->where('content_type_id', $contentType->id)
|
$q->where('content_type_id', $contentType->id)
|
||||||
);
|
);
|
||||||
|
|||||||
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal file
310
app/Http/Controllers/Settings/ArtworkMaturityAdminController.php
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkMaturityAuditFinding;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityAuditService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
final class ArtworkMaturityAdminController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
|
private readonly ArtworkMaturityAuditService $audit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$stats = $this->queueStats();
|
||||||
|
$status = $this->initialStatus($request, $stats);
|
||||||
|
$routes = $this->routeNamesForRequest($request);
|
||||||
|
|
||||||
|
return Inertia::render('Moderation/ArtworkMaturityQueue', [
|
||||||
|
'title' => 'Artwork Maturity Queue',
|
||||||
|
'initialItems' => $this->queueItems($status),
|
||||||
|
'initialFilters' => [
|
||||||
|
'status' => $status,
|
||||||
|
'ai_action' => 'all',
|
||||||
|
'ai_status' => 'all',
|
||||||
|
],
|
||||||
|
'stats' => $stats,
|
||||||
|
'endpoints' => [
|
||||||
|
'list' => route($routes['list']),
|
||||||
|
'reviewPattern' => route($routes['review'], ['artwork' => '__ARTWORK__']),
|
||||||
|
],
|
||||||
|
'filterOptions' => [
|
||||||
|
'aiAction' => [
|
||||||
|
['value' => 'all', 'label' => 'All actions'],
|
||||||
|
['value' => ArtworkMaturityService::AI_ACTION_SAFE, 'label' => 'Safe'],
|
||||||
|
['value' => ArtworkMaturityService::AI_ACTION_REVIEW, 'label' => 'Review'],
|
||||||
|
['value' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH, 'label' => 'Flag high'],
|
||||||
|
],
|
||||||
|
'aiStatus' => [
|
||||||
|
['value' => 'all', 'label' => 'All statuses'],
|
||||||
|
['value' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, 'label' => 'Succeeded'],
|
||||||
|
['value' => ArtworkMaturityService::AI_STATUS_PENDING, 'label' => 'Pending'],
|
||||||
|
['value' => ArtworkMaturityService::AI_STATUS_FAILED, 'label' => 'Failed'],
|
||||||
|
['value' => ArtworkMaturityService::AI_STATUS_SKIPPED, 'label' => 'Skipped'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'reviewActions' => [
|
||||||
|
['value' => 'mark_safe', 'label' => 'Mark safe'],
|
||||||
|
['value' => 'mark_mature', 'label' => 'Mark mature'],
|
||||||
|
['value' => 'confirm_current', 'label' => 'Confirm current state'],
|
||||||
|
],
|
||||||
|
])->rootView('moderation');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = $this->normalizeStatus((string) $request->query('status', 'suspected'));
|
||||||
|
$aiAction = strtolower((string) $request->query('ai_action', 'all'));
|
||||||
|
$aiStatus = strtolower((string) $request->query('ai_status', 'all'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->queueItems($status, $aiAction, $aiStatus),
|
||||||
|
'meta' => [
|
||||||
|
'stats' => $this->queueStats(),
|
||||||
|
'status' => $status,
|
||||||
|
'filters' => [
|
||||||
|
'ai_action' => $aiAction,
|
||||||
|
'ai_status' => $aiStatus,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function review(Request $request, Artwork $artwork): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'action' => ['required', 'in:mark_safe,mark_mature,confirm_current'],
|
||||||
|
'note' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var User $moderator */
|
||||||
|
$moderator = $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
|
||||||
|
$artwork = $this->maturity->review($artwork, (string) $validated['action'], $moderator, $validated['note'] ?? null);
|
||||||
|
$this->audit->resolveFindingForReview($artwork, $moderator, (string) $validated['action'], $validated['note'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'artwork' => $this->mapQueueItem($artwork->loadMissing(['user.profile', 'group', 'categories.contentType'])),
|
||||||
|
'stats' => $this->queueStats(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function queueItems(string $status, string $aiAction = 'all', string $aiStatus = 'all'): array
|
||||||
|
{
|
||||||
|
if ($status === 'audit') {
|
||||||
|
return $this->auditQueueItems($aiAction, $aiStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Artwork::query()
|
||||||
|
->with(['user.profile', 'group', 'categories.contentType'])
|
||||||
|
->where(function ($builder): void {
|
||||||
|
$builder->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)
|
||||||
|
->orWhere(function ($reviewed): void {
|
||||||
|
$reviewed->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)
|
||||||
|
->whereNotNull('maturity_reviewed_at');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->latest('maturity_flagged_at')
|
||||||
|
->latest('published_at')
|
||||||
|
->limit(100);
|
||||||
|
|
||||||
|
if ($status === 'reviewed') {
|
||||||
|
$query->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED);
|
||||||
|
} else {
|
||||||
|
$query->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($aiAction, [
|
||||||
|
ArtworkMaturityService::AI_ACTION_SAFE,
|
||||||
|
ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||||
|
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
|
||||||
|
], true)) {
|
||||||
|
$query->where('maturity_ai_action_hint', $aiAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($aiStatus, [
|
||||||
|
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||||
|
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||||
|
ArtworkMaturityService::AI_STATUS_FAILED,
|
||||||
|
ArtworkMaturityService::AI_STATUS_SKIPPED,
|
||||||
|
], true)) {
|
||||||
|
$query->where('maturity_ai_status', $aiStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->map(fn (Artwork $artwork): array => $this->mapQueueItem($artwork))->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function auditQueueItems(string $aiAction = 'all', string $aiStatus = 'all'): array
|
||||||
|
{
|
||||||
|
$query = $this->audit->openFindingsQuery()
|
||||||
|
->latest('detected_at')
|
||||||
|
->latest('updated_at')
|
||||||
|
->limit(100);
|
||||||
|
|
||||||
|
if (in_array($aiAction, [
|
||||||
|
ArtworkMaturityService::AI_ACTION_SAFE,
|
||||||
|
ArtworkMaturityService::AI_ACTION_REVIEW,
|
||||||
|
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
|
||||||
|
], true)) {
|
||||||
|
$query->where('ai_action_hint', $aiAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($aiStatus, [
|
||||||
|
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
|
||||||
|
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||||
|
ArtworkMaturityService::AI_STATUS_FAILED,
|
||||||
|
ArtworkMaturityService::AI_STATUS_SKIPPED,
|
||||||
|
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||||
|
], true)) {
|
||||||
|
$query->where('ai_status', $aiStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->map(fn (ArtworkMaturityAuditFinding $finding): array => $this->mapAuditQueueItem($finding))->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function queueStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'suspected' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)->count(),
|
||||||
|
'audit' => $this->audit->openFindingsCount(),
|
||||||
|
'reviewed' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)->count(),
|
||||||
|
'mature' => (int) Artwork::query()->where('is_mature', true)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapAuditQueueItem(ArtworkMaturityAuditFinding $finding): array
|
||||||
|
{
|
||||||
|
$artwork = $finding->artwork;
|
||||||
|
|
||||||
|
return $this->mapQueueItem($artwork, [
|
||||||
|
'status' => (string) $finding->status,
|
||||||
|
'thumbnail_variant' => $finding->thumbnail_variant,
|
||||||
|
'detected_at' => optional($finding->detected_at)->toIsoString(),
|
||||||
|
'last_scanned_at' => optional($finding->last_scanned_at)->toIsoString(),
|
||||||
|
'ai_label' => $finding->ai_label,
|
||||||
|
'ai_confidence' => $finding->ai_confidence,
|
||||||
|
'ai_score' => $finding->ai_score,
|
||||||
|
'ai_labels' => $finding->ai_labels,
|
||||||
|
'ai_model' => $finding->ai_model,
|
||||||
|
'ai_threshold_used' => $finding->ai_threshold_used,
|
||||||
|
'ai_analysis_time_ms' => $finding->ai_analysis_time_ms,
|
||||||
|
'ai_action_hint' => $finding->ai_action_hint,
|
||||||
|
'ai_status' => $finding->ai_status,
|
||||||
|
'ai_advisory' => $finding->ai_advisory,
|
||||||
|
'legacy_unset' => $this->audit->isArtworkEligible($artwork),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapQueueItem(Artwork $artwork, ?array $audit = null): array
|
||||||
|
{
|
||||||
|
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||||
|
$publisherName = $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
|
||||||
|
$thumb = ThumbnailPresenter::present($artwork, 'md');
|
||||||
|
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $artwork->id,
|
||||||
|
'title' => (string) $artwork->title,
|
||||||
|
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id]),
|
||||||
|
'admin_url' => route('admin.cp.artworks.edit', ['id' => $artwork->id]),
|
||||||
|
'thumbnail' => $thumb['url'] ?? null,
|
||||||
|
'preview_image' => $preview['url'] ?? ($thumb['url'] ?? null),
|
||||||
|
'publisher' => $publisherName,
|
||||||
|
'published_at' => optional($artwork->published_at)->toIsoString(),
|
||||||
|
'content_type' => $category?->contentType?->name,
|
||||||
|
'category' => $category?->name,
|
||||||
|
'maturity' => $this->maturity->presentation($artwork, null),
|
||||||
|
'audit' => $audit,
|
||||||
|
'review' => [
|
||||||
|
'reviewed_at' => optional($artwork->maturity_reviewed_at)->toIsoString(),
|
||||||
|
'reviewed_by' => $artwork->maturity_reviewed_by,
|
||||||
|
'reviewer_note' => $artwork->maturity_reviewer_note,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeStatus(string $status): string
|
||||||
|
{
|
||||||
|
$normalized = Str::lower(trim($status));
|
||||||
|
|
||||||
|
return in_array($normalized, ['suspected', 'reviewed', 'audit'], true)
|
||||||
|
? $normalized
|
||||||
|
: 'suspected';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $stats
|
||||||
|
*/
|
||||||
|
private function initialStatus(Request $request, array $stats): string
|
||||||
|
{
|
||||||
|
if ($request->query->has('status')) {
|
||||||
|
return $this->normalizeStatus((string) $request->query('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($stats['suspected'] ?? 0) > 0) {
|
||||||
|
return 'suspected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($stats['audit'] ?? 0) > 0) {
|
||||||
|
return 'audit';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($stats['reviewed'] ?? 0) > 0) {
|
||||||
|
return 'reviewed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'suspected';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{list: string, review: string}
|
||||||
|
*/
|
||||||
|
private function routeNamesForRequest(Request $request): array
|
||||||
|
{
|
||||||
|
$routeName = (string) $request->route()?->getName();
|
||||||
|
|
||||||
|
if (Str::startsWith($routeName, 'admin.cp.artworks.maturity.')) {
|
||||||
|
return [
|
||||||
|
'list' => 'admin.cp.artworks.maturity.queue',
|
||||||
|
'review' => 'admin.cp.artworks.maturity.review',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'list' => 'cp.maturity.list',
|
||||||
|
'review' => 'cp.maturity.review',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal file
215
app/Http/Controllers/Settings/FeaturedArtworkAdminController.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ArtworkFeature;
|
||||||
|
use App\Services\FeaturedArtworkAdminService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class FeaturedArtworkAdminController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly FeaturedArtworkAdminService $featuredArtworks)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
|
||||||
|
$this->featuredArtworks->pageProps(),
|
||||||
|
[
|
||||||
|
'endpoints' => [
|
||||||
|
'search' => route('admin.cp.artworks.featured.search'),
|
||||||
|
'store' => route('admin.cp.artworks.featured.store'),
|
||||||
|
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
|
||||||
|
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
|
||||||
|
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
|
||||||
|
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
|
||||||
|
],
|
||||||
|
'capabilities' => [
|
||||||
|
'forceHeroEnabled' => $this->hasForceHeroColumn(),
|
||||||
|
],
|
||||||
|
'seo' => [
|
||||||
|
'title' => 'Featured Artworks — Skinbase Nova',
|
||||||
|
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||||
|
'canonical' => route('admin.cp.artworks.featured.main'),
|
||||||
|
'robots' => 'noindex,follow',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
))->rootView('collections');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'q' => ['required', 'string', 'min:1', 'max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'results' => $this->featuredArtworks->searchArtworks((string) $validated['q']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $this->validateStore($request);
|
||||||
|
$actor = $this->currentActor($request);
|
||||||
|
|
||||||
|
ArtworkFeature::query()->create([
|
||||||
|
'artwork_id' => (int) $validated['artwork_id'],
|
||||||
|
'priority' => (int) $validated['priority'],
|
||||||
|
'featured_at' => Carbon::parse((string) $validated['featured_at']),
|
||||||
|
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
|
||||||
|
'is_active' => (bool) $validated['is_active'],
|
||||||
|
'created_by' => (int) $actor->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->mutationResponse('Featured artwork added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, ArtworkFeature $feature): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $this->validateUpdate($request);
|
||||||
|
$this->ensureStateAvailable($feature, (bool) $validated['is_active']);
|
||||||
|
|
||||||
|
$feature->fill([
|
||||||
|
'priority' => (int) $validated['priority'],
|
||||||
|
'featured_at' => Carbon::parse((string) $validated['featured_at']),
|
||||||
|
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
|
||||||
|
'is_active' => (bool) $validated['is_active'],
|
||||||
|
]);
|
||||||
|
$feature->save();
|
||||||
|
|
||||||
|
return $this->mutationResponse('Featured artwork updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(ArtworkFeature $feature): JsonResponse
|
||||||
|
{
|
||||||
|
$nextState = ! (bool) $feature->is_active;
|
||||||
|
$this->ensureStateAvailable($feature, $nextState);
|
||||||
|
|
||||||
|
$feature->forceFill([
|
||||||
|
'is_active' => $nextState,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $this->mutationResponse($nextState ? 'Featured artwork activated.' : 'Featured artwork deactivated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleForceHero(ArtworkFeature $feature): JsonResponse
|
||||||
|
{
|
||||||
|
$this->ensureForceHeroAvailable();
|
||||||
|
|
||||||
|
$nextState = ! (bool) $feature->force_hero;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($feature, $nextState): void {
|
||||||
|
if ($nextState) {
|
||||||
|
ArtworkFeature::query()
|
||||||
|
->where('force_hero', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereKeyNot($feature->id)
|
||||||
|
->update(['force_hero' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$feature->forceFill([
|
||||||
|
'force_hero' => $nextState,
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->mutationResponse($nextState ? 'Force hero enabled.' : 'Force hero disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(ArtworkFeature $feature): JsonResponse
|
||||||
|
{
|
||||||
|
$feature->delete();
|
||||||
|
|
||||||
|
return $this->mutationResponse('Featured artwork entry deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validateStore(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'artwork_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('artworks', 'id'),
|
||||||
|
Rule::unique('artwork_features', 'artwork_id')->where(fn ($query) => $query->whereNull('deleted_at')),
|
||||||
|
],
|
||||||
|
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
|
||||||
|
'featured_at' => ['required', 'date'],
|
||||||
|
'expires_at' => ['nullable', 'date', 'after:featured_at'],
|
||||||
|
'is_active' => ['required', 'boolean'],
|
||||||
|
], [
|
||||||
|
'artwork_id.unique' => 'This artwork already has a featured entry. Edit the existing row instead.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validateUpdate(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
|
||||||
|
'featured_at' => ['required', 'date'],
|
||||||
|
'expires_at' => ['nullable', 'date', 'after:featured_at'],
|
||||||
|
'is_active' => ['required', 'boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureStateAvailable(ArtworkFeature $feature, bool $isActive): void
|
||||||
|
{
|
||||||
|
$conflictExists = ArtworkFeature::query()
|
||||||
|
->where('artwork_id', $feature->artwork_id)
|
||||||
|
->where('is_active', $isActive)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereKeyNot($feature->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($conflictExists) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'is_active' => 'Another featured entry for this artwork already uses that active state.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mutationResponse(string $message): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(array_merge([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => $message,
|
||||||
|
], $this->featuredArtworks->pageProps()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentActor(Request $request): object
|
||||||
|
{
|
||||||
|
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureForceHeroAvailable(): void
|
||||||
|
{
|
||||||
|
if (! $this->hasForceHeroColumn()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'force_hero' => 'Run php artisan migrate to enable force hero controls.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasForceHeroColumn(): bool
|
||||||
|
{
|
||||||
|
return Schema::hasColumn('artwork_features', 'force_hero');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\Artwork;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
use App\Models\ArtworkVersion;
|
use App\Models\ArtworkVersion;
|
||||||
|
use App\Services\ArtworkEvolutionService;
|
||||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||||
use App\Services\ArtworkSearchIndexer;
|
use App\Services\ArtworkSearchIndexer;
|
||||||
use App\Services\ArtworkAttributionService;
|
use App\Services\ArtworkAttributionService;
|
||||||
@@ -122,6 +123,7 @@ final class StudioArtworksApiController extends Controller
|
|||||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
|
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
|
||||||
{
|
{
|
||||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||||
|
$evolution = app(ArtworkEvolutionService::class);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'title' => 'sometimes|string|max:255',
|
'title' => 'sometimes|string|max:255',
|
||||||
@@ -133,7 +135,7 @@ final class StudioArtworksApiController extends Controller
|
|||||||
'timezone' => 'sometimes|nullable|string|max:64',
|
'timezone' => 'sometimes|nullable|string|max:64',
|
||||||
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||||
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
|
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
|
||||||
'tags' => 'sometimes|array|max:15',
|
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||||
'tags.*' => 'string|max:64',
|
'tags.*' => 'string|max:64',
|
||||||
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||||
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||||
@@ -147,12 +149,18 @@ final class StudioArtworksApiController extends Controller
|
|||||||
'contributor_credits.*.user_id' => 'required|integer|min:1',
|
'contributor_credits.*.user_id' => 'required|integer|min:1',
|
||||||
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
|
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
|
||||||
'contributor_credits.*.is_primary' => 'nullable|boolean',
|
'contributor_credits.*.is_primary' => 'nullable|boolean',
|
||||||
|
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
|
||||||
|
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
|
||||||
|
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$hasAttributionUpdates = array_key_exists('group', $validated)
|
$hasAttributionUpdates = array_key_exists('group', $validated)
|
||||||
|| array_key_exists('primary_author_user_id', $validated)
|
|| array_key_exists('primary_author_user_id', $validated)
|
||||||
|| array_key_exists('contributor_user_ids', $validated)
|
|| array_key_exists('contributor_user_ids', $validated)
|
||||||
|| array_key_exists('contributor_credits', $validated);
|
|| array_key_exists('contributor_credits', $validated);
|
||||||
|
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|
||||||
|
|| array_key_exists('evolution_relation_type', $validated)
|
||||||
|
|| array_key_exists('evolution_note', $validated);
|
||||||
|
|
||||||
$attributionPayload = [
|
$attributionPayload = [
|
||||||
'group' => $validated['group'] ?? $artwork->group?->slug,
|
'group' => $validated['group'] ?? $artwork->group?->slug,
|
||||||
@@ -190,7 +198,13 @@ final class StudioArtworksApiController extends Controller
|
|||||||
$tags = $validated['tags'] ?? null;
|
$tags = $validated['tags'] ?? null;
|
||||||
$categoryId = $validated['category_id'] ?? null;
|
$categoryId = $validated['category_id'] ?? null;
|
||||||
$contentTypeId = $validated['content_type_id'] ?? null;
|
$contentTypeId = $validated['content_type_id'] ?? null;
|
||||||
|
$evolutionPayload = [
|
||||||
|
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
|
||||||
|
'relation_type' => $validated['evolution_relation_type'] ?? null,
|
||||||
|
'note' => $validated['evolution_note'] ?? null,
|
||||||
|
];
|
||||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
|
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
|
||||||
|
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||||
|
|
||||||
$validated['visibility'] = $visibility;
|
$validated['visibility'] = $visibility;
|
||||||
$validated['artwork_timezone'] = $timezone;
|
$validated['artwork_timezone'] = $timezone;
|
||||||
@@ -244,6 +258,14 @@ final class StudioArtworksApiController extends Controller
|
|||||||
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
|
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($hasEvolutionUpdates) {
|
||||||
|
try {
|
||||||
|
$evolution->syncPrimaryRelation($artwork->fresh(['group.members']), $request->user(), $evolutionPayload);
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
return response()->json(['errors' => $exception->errors()], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reindex in Meilisearch
|
// Reindex in Meilisearch
|
||||||
try {
|
try {
|
||||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||||
@@ -287,6 +309,25 @@ final class StudioArtworksApiController extends Controller
|
|||||||
'description_source' => $artwork->description_source ?: 'manual',
|
'description_source' => $artwork->description_source ?: 'manual',
|
||||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||||
'category_source' => $artwork->category_source ?: 'manual',
|
'category_source' => $artwork->category_source ?: 'manual',
|
||||||
|
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evolutionOptions(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'search' => ['nullable', 'string', 'max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$evolution = app(ArtworkEvolutionService::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $evolution->manageableSearchOptions($artwork, $request->user(), (string) ($validated['search'] ?? '')),
|
||||||
|
'meta' => [
|
||||||
|
'selected' => $evolution->editorRelation($artwork, $request->user()),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Group;
|
use App\Models\Group;
|
||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
|
use App\Services\ArtworkEvolutionService;
|
||||||
use App\Services\GroupMembershipService;
|
use App\Services\GroupMembershipService;
|
||||||
use App\Services\GroupService;
|
use App\Services\GroupService;
|
||||||
use App\Services\Studio\CreatorStudioAnalyticsService;
|
use App\Services\Studio\CreatorStudioAnalyticsService;
|
||||||
@@ -478,6 +479,7 @@ final class StudioController extends Controller
|
|||||||
'description_source' => $artwork->description_source ?: 'manual',
|
'description_source' => $artwork->description_source ?: 'manual',
|
||||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||||
'category_source' => $artwork->category_source ?: 'manual',
|
'category_source' => $artwork->category_source ?: 'manual',
|
||||||
|
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
|
||||||
// Versioning
|
// Versioning
|
||||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||||
@@ -485,6 +487,7 @@ final class StudioController extends Controller
|
|||||||
'contentTypes' => $this->getCategories(),
|
'contentTypes' => $this->getCategories(),
|
||||||
'groupOptions' => $availableGroups,
|
'groupOptions' => $availableGroups,
|
||||||
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
||||||
|
'evolutionRelationTypes' => app(ArtworkEvolutionService::class)->relationTypeOptions(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\ProfileUpdateRequest;
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
use App\Http\Requests\Settings\RequestEmailChangeRequest;
|
use App\Http\Requests\Settings\RequestEmailChangeRequest;
|
||||||
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
|
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
|
||||||
|
use App\Http\Requests\Settings\UpdateContentPreferencesRequest;
|
||||||
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
|
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
|
||||||
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
|
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
|
||||||
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
|
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
|
||||||
@@ -35,10 +36,11 @@ use App\Services\FollowAnalyticsService;
|
|||||||
use App\Services\LeaderboardService;
|
use App\Services\LeaderboardService;
|
||||||
use App\Services\UserSuggestionService;
|
use App\Services\UserSuggestionService;
|
||||||
use App\Services\Countries\CountryCatalogService;
|
use App\Services\Countries\CountryCatalogService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Services\ThumbnailService;
|
|
||||||
use App\Services\XPService;
|
use App\Services\XPService;
|
||||||
use App\Services\UsernameApprovalService;
|
use App\Services\UsernameApprovalService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use App\Support\CoverUrl;
|
use App\Support\CoverUrl;
|
||||||
@@ -84,6 +86,7 @@ class ProfileController extends Controller
|
|||||||
private readonly LeaderboardService $leaderboards,
|
private readonly LeaderboardService $leaderboards,
|
||||||
private readonly CountryCatalogService $countryCatalog,
|
private readonly CountryCatalogService $countryCatalog,
|
||||||
private readonly UserSuggestionService $userSuggestions,
|
private readonly UserSuggestionService $userSuggestions,
|
||||||
|
private readonly CreatorJourneyService $creatorJourney,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -312,6 +315,10 @@ class ProfileController extends Controller
|
|||||||
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
|
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
|
||||||
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
|
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
|
||||||
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
|
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
|
||||||
|
$matureContentVisibility = (string) ($profileData['mature_content_visibility'] ?? config('maturity.viewer.default_mode', 'blur'));
|
||||||
|
$matureContentWarningEnabled = array_key_exists('mature_content_warning_enabled', $profileData)
|
||||||
|
? (bool) $profileData['mature_content_warning_enabled']
|
||||||
|
: (bool) config('maturity.viewer.default_warn_on_detail', true);
|
||||||
|
|
||||||
return Inertia::render('Settings/ProfileEdit', [
|
return Inertia::render('Settings/ProfileEdit', [
|
||||||
'user' => [
|
'user' => [
|
||||||
@@ -332,6 +339,8 @@ class ProfileController extends Controller
|
|||||||
'follower_notifications' => $followerNotifications,
|
'follower_notifications' => $followerNotifications,
|
||||||
'comment_notifications' => $commentNotifications,
|
'comment_notifications' => $commentNotifications,
|
||||||
'newsletter' => $newsletter,
|
'newsletter' => $newsletter,
|
||||||
|
'mature_content_visibility' => $matureContentVisibility,
|
||||||
|
'mature_content_warning_enabled' => $matureContentWarningEnabled,
|
||||||
'last_username_change_at' => $user->last_username_change_at,
|
'last_username_change_at' => $user->last_username_change_at,
|
||||||
'username_changed_at' => $user->username_changed_at,
|
'username_changed_at' => $user->username_changed_at,
|
||||||
],
|
],
|
||||||
@@ -576,6 +585,18 @@ class ProfileController extends Controller
|
|||||||
return $this->settingsResponse($request, 'Notification settings saved successfully.');
|
return $this->settingsResponse($request, 'Notification settings saved successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateContentPreferencesSection(UpdateContentPreferencesRequest $request): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$this->persistProfileUpdates((int) $request->user()->id, [
|
||||||
|
'mature_content_visibility' => (string) $validated['mature_content_visibility'],
|
||||||
|
'mature_content_warning_enabled' => (bool) $validated['mature_content_warning_enabled'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->settingsResponse($request, 'Content preferences saved successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
|
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
@@ -918,7 +939,7 @@ class ProfileController extends Controller
|
|||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
|
|
||||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
|
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
|
||||||
->through(function (Artwork $art) {
|
->through(function (Artwork $art) {
|
||||||
return (object) $this->mapArtworkCardPayload($art);
|
return (object) $this->mapArtworkCardPayload($art);
|
||||||
});
|
});
|
||||||
@@ -926,34 +947,38 @@ class ProfileController extends Controller
|
|||||||
// ── Featured artworks for this user ─────────────────────────────────
|
// ── Featured artworks for this user ─────────────────────────────────
|
||||||
$featuredArtworks = collect();
|
$featuredArtworks = collect();
|
||||||
if (Schema::hasTable('artwork_features')) {
|
if (Schema::hasTable('artwork_features')) {
|
||||||
$featuredArtworks = DB::table('artwork_features as af')
|
$featuredQuery = Artwork::query()
|
||||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
->with([
|
||||||
->where('a.user_id', $user->id)
|
'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 ($query) {
|
||||||
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||||
|
->where('artworks.user_id', $user->id)
|
||||||
->where('af.is_active', true)
|
->where('af.is_active', true)
|
||||||
->whereNull('af.deleted_at')
|
->whereNull('af.deleted_at')
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('artworks.deleted_at')
|
||||||
->where('a.is_public', true)
|
->where('artworks.is_public', true)
|
||||||
->where('a.is_approved', true)
|
->where('artworks.is_approved', true)
|
||||||
|
->whereNotNull('artworks.published_at')
|
||||||
|
->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at'])
|
||||||
->orderByDesc('af.featured_at')
|
->orderByDesc('af.featured_at')
|
||||||
->limit(3)
|
->limit(3);
|
||||||
->select([
|
|
||||||
'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext',
|
app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer);
|
||||||
'a.width', 'a.height', 'af.label', 'af.featured_at',
|
|
||||||
])
|
$featuredArtworks = $featuredQuery
|
||||||
->get()
|
->get()
|
||||||
->map(function ($row) {
|
->map(function (Artwork $artwork) {
|
||||||
$thumbUrl = ($row->hash && $row->thumb_ext)
|
return (object) array_merge($this->mapArtworkCardPayload($artwork), [
|
||||||
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md')
|
'label' => $artwork->featured_label,
|
||||||
: '/images/placeholder.jpg';
|
'featured_at' => $this->formatIsoDate($artwork->featured_slot_at),
|
||||||
return (object) [
|
]);
|
||||||
'id' => $row->id,
|
|
||||||
'name' => $row->name,
|
|
||||||
'thumb' => $thumbUrl,
|
|
||||||
'label' => $row->label,
|
|
||||||
'featured_at' => $row->featured_at,
|
|
||||||
'width' => $row->width,
|
|
||||||
'height' => $row->height,
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +997,10 @@ class ProfileController extends Controller
|
|||||||
->where('a.is_public', true)
|
->where('a.is_public', true)
|
||||||
->where('a.is_approved', true)
|
->where('a.is_approved', true)
|
||||||
->whereNotNull('a.published_at')
|
->whereNotNull('a.published_at')
|
||||||
|
->when(app(ArtworkMaturityService::class)->viewerPreferences($viewer)['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
|
||||||
|
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
|
||||||
|
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
|
||||||
|
})
|
||||||
->orderByDesc('af.created_at')
|
->orderByDesc('af.created_at')
|
||||||
->orderByDesc('af.artwork_id')
|
->orderByDesc('af.artwork_id')
|
||||||
->limit($favouriteLimit + 1)
|
->limit($favouriteLimit + 1)
|
||||||
@@ -981,7 +1010,16 @@ class ProfileController extends Controller
|
|||||||
$hasMore = $favIds->count() > $favouriteLimit;
|
$hasMore = $favIds->count() > $favouriteLimit;
|
||||||
$favIds = $favIds->take($favouriteLimit);
|
$favIds = $favIds->take($favouriteLimit);
|
||||||
|
|
||||||
$indexed = Artwork::with('user:id,name,username')
|
$indexed = Artwork::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 ($query) {
|
||||||
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
->whereIn('id', $favIds)
|
->whereIn('id', $favIds)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
@@ -1056,18 +1094,38 @@ class ProfileController extends Controller
|
|||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
$liveAwardsReceivedCount = 0;
|
$medalTotals = [
|
||||||
if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) {
|
'gold' => 0,
|
||||||
$liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw')
|
'silver' => 0,
|
||||||
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
|
'bronze' => 0,
|
||||||
|
'count' => 0,
|
||||||
|
'score_total' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Schema::hasTable('artwork_medal_stats') && Schema::hasTable('artworks')) {
|
||||||
|
$totals = DB::table('artwork_medal_stats as aas')
|
||||||
|
->join('artworks as a', 'a.id', '=', 'aas.artwork_id')
|
||||||
->where('a.user_id', $user->id)
|
->where('a.user_id', $user->id)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
->count();
|
->selectRaw('COALESCE(SUM(aas.gold_count), 0) as gold_count')
|
||||||
|
->selectRaw('COALESCE(SUM(aas.silver_count), 0) as silver_count')
|
||||||
|
->selectRaw('COALESCE(SUM(aas.bronze_count), 0) as bronze_count')
|
||||||
|
->selectRaw('COALESCE(SUM(aas.score_total), 0) as score_total')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$medalTotals = [
|
||||||
|
'gold' => (int) ($totals->gold_count ?? 0),
|
||||||
|
'silver' => (int) ($totals->silver_count ?? 0),
|
||||||
|
'bronze' => (int) ($totals->bronze_count ?? 0),
|
||||||
|
'count' => (int) (($totals->gold_count ?? 0) + ($totals->silver_count ?? 0) + ($totals->bronze_count ?? 0)),
|
||||||
|
'score_total' => (int) ($totals->score_total ?? 0),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statsPayload = array_merge($stats ? (array) $stats : [], [
|
$statsPayload = array_merge($stats ? (array) $stats : [], [
|
||||||
'uploads_count' => $liveUploadsCount,
|
'uploads_count' => $liveUploadsCount,
|
||||||
'awards_received_count' => $liveAwardsReceivedCount,
|
'awards_received_count' => $medalTotals['count'],
|
||||||
|
'medal_totals' => $medalTotals,
|
||||||
'followers_count' => (int) $followerCount,
|
'followers_count' => (int) $followerCount,
|
||||||
'following_count' => (int) $followingCount,
|
'following_count' => (int) $followingCount,
|
||||||
]);
|
]);
|
||||||
@@ -1145,7 +1203,7 @@ class ProfileController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||||
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner);
|
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
|
||||||
|
|
||||||
// ── Profile data ─────────────────────────────────────────────────────
|
// ── Profile data ─────────────────────────────────────────────────────
|
||||||
$profile = $user->profile;
|
$profile = $user->profile;
|
||||||
@@ -1203,6 +1261,7 @@ class ProfileController extends Controller
|
|||||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||||
$groupContributionHistory = $this->buildGroupContributionHistory($user);
|
$groupContributionHistory = $this->buildGroupContributionHistory($user);
|
||||||
|
$journey = $this->creatorJourney->publicPayloadForUser($user);
|
||||||
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
|
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
|
||||||
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
|
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
|
||||||
$activeProfileUrl = $resolvedInitialTab !== null
|
$activeProfileUrl = $resolvedInitialTab !== null
|
||||||
@@ -1276,6 +1335,7 @@ class ProfileController extends Controller
|
|||||||
'collections' => $profileCollectionsPayload,
|
'collections' => $profileCollectionsPayload,
|
||||||
'achievements' => $achievementSummary,
|
'achievements' => $achievementSummary,
|
||||||
'leaderboardRank' => $leaderboardRank,
|
'leaderboardRank' => $leaderboardRank,
|
||||||
|
'journey' => $journey,
|
||||||
'groupContributionHistory' => $groupContributionHistory,
|
'groupContributionHistory' => $groupContributionHistory,
|
||||||
'countryName' => $countryName,
|
'countryName' => $countryName,
|
||||||
'isOwner' => $isOwner,
|
'isOwner' => $isOwner,
|
||||||
@@ -1288,6 +1348,7 @@ class ProfileController extends Controller
|
|||||||
'collectionsFeaturedUrl' => route('collections.featured'),
|
'collectionsFeaturedUrl' => route('collections.featured'),
|
||||||
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
||||||
'profileTabUrls' => $profileTabUrls,
|
'profileTabUrls' => $profileTabUrls,
|
||||||
|
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
|
||||||
])->withViewData([
|
])->withViewData([
|
||||||
'page_title' => $pageTitle,
|
'page_title' => $pageTitle,
|
||||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||||
@@ -1435,8 +1496,17 @@ class ProfileController extends Controller
|
|||||||
$category = $art->categories->first();
|
$category = $art->categories->first();
|
||||||
$contentType = $category?->contentType;
|
$contentType = $category?->contentType;
|
||||||
$stats = $art->stats;
|
$stats = $art->stats;
|
||||||
|
$group = $art->group;
|
||||||
|
$isGroupPublisher = $group !== null;
|
||||||
|
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
|
||||||
|
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
|
||||||
|
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||||
|
$profileUrl = $isGroupPublisher
|
||||||
|
? $group->publicUrl()
|
||||||
|
: ($username ? '/@' . $username : null);
|
||||||
|
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||||
|
|
||||||
return [
|
return app(ArtworkMaturityService::class)->decoratePayload([
|
||||||
'id' => $art->id,
|
'id' => $art->id,
|
||||||
'name' => $art->title,
|
'name' => $art->title,
|
||||||
'picture' => $art->file_name,
|
'picture' => $art->file_name,
|
||||||
@@ -1444,11 +1514,22 @@ class ProfileController extends Controller
|
|||||||
'published_at' => $this->formatIsoDate($art->published_at),
|
'published_at' => $this->formatIsoDate($art->published_at),
|
||||||
'thumb' => $present['url'],
|
'thumb' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $art->user->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
'username' => $art->user->username ?? null,
|
'username' => $username,
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'profile_url' => $profileUrl,
|
||||||
|
'published_as_type' => $publisherType,
|
||||||
|
'publisher' => [
|
||||||
|
'type' => $publisherType,
|
||||||
|
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
|
||||||
|
'name' => $displayName,
|
||||||
|
'username' => $username ?? '',
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'profile_url' => $profileUrl,
|
||||||
|
],
|
||||||
'user_id' => $art->user_id,
|
'user_id' => $art->user_id,
|
||||||
'author_level' => (int) ($art->user?->level ?? 1),
|
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
|
||||||
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
|
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
|
||||||
'content_type' => $contentType?->name,
|
'content_type' => $contentType?->name,
|
||||||
'content_type_slug' => $contentType?->slug,
|
'content_type_slug' => $contentType?->slug,
|
||||||
'category' => $category?->name,
|
'category' => $category?->name,
|
||||||
@@ -1458,7 +1539,7 @@ class ProfileController extends Controller
|
|||||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||||
'width' => $art->width,
|
'width' => $art->width,
|
||||||
'height' => $art->height,
|
'height' => $art->height,
|
||||||
];
|
], $art, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatIsoDate(mixed $value): ?string
|
private function formatIsoDate(mixed $value): ?string
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Services\ContentSanitizer;
|
|||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Services\ErrorSuggestionService;
|
use App\Services\ErrorSuggestionService;
|
||||||
use App\Services\GroupService;
|
use App\Services\GroupService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Support\Seo\SeoFactory;
|
use App\Support\Seo\SeoFactory;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -23,7 +24,10 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
final class ArtworkPageController extends Controller
|
final class ArtworkPageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly GroupService $groups) {}
|
public function __construct(
|
||||||
|
private readonly GroupService $groups,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
|
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
|
||||||
{
|
{
|
||||||
@@ -145,6 +149,7 @@ final class ArtworkPageController extends Controller
|
|||||||
->whereKeyNot($artwork->id)
|
->whereKeyNot($artwork->id)
|
||||||
->public()
|
->public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
|
||||||
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
||||||
$query->where('user_id', $artwork->user_id);
|
$query->where('user_id', $artwork->user_id);
|
||||||
|
|
||||||
@@ -176,14 +181,14 @@ final class ArtworkPageController extends Controller
|
|||||||
$md = ThumbnailPresenter::present($item, 'md');
|
$md = ThumbnailPresenter::present($item, 'md');
|
||||||
$lg = ThumbnailPresenter::present($item, 'lg');
|
$lg = ThumbnailPresenter::present($item, 'lg');
|
||||||
|
|
||||||
return [
|
return $this->maturity->decoratePayload([
|
||||||
'id' => (int) $item->id,
|
'id' => (int) $item->id,
|
||||||
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||||
'thumb' => $md['url'] ?? null,
|
'thumb' => $md['url'] ?? null,
|
||||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||||
];
|
], $item, request()->user());
|
||||||
})
|
})
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use App\Models\ContentType;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -17,8 +20,6 @@ use Illuminate\Pagination\AbstractCursorPaginator;
|
|||||||
|
|
||||||
class BrowseGalleryController extends \App\Http\Controllers\Controller
|
class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||||
{
|
{
|
||||||
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other', 'digital-art'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meilisearch sort-field arrays per sort alias.
|
* Meilisearch sort-field arrays per sort alias.
|
||||||
* First element is primary sort; subsequent elements are tie-breakers.
|
* First element is primary sort; subsequent elements are tie-breakers.
|
||||||
@@ -74,6 +75,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private ArtworkService $artworks,
|
private ArtworkService $artworks,
|
||||||
private ArtworkSearchService $search,
|
private ArtworkSearchService $search,
|
||||||
|
private ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
private ArtworkMaturityService $maturity,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +124,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
public function content(Request $request, string $contentTypeSlug, ?string $path = null)
|
public function content(Request $request, string $contentTypeSlug, ?string $path = null)
|
||||||
{
|
{
|
||||||
$contentSlug = strtolower($contentTypeSlug);
|
$requestedSlug = strtolower($contentTypeSlug);
|
||||||
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
|
$resolution = $this->contentTypeResolver->resolve($requestedSlug);
|
||||||
|
|
||||||
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentType = ContentType::where('slug', $contentSlug)->first();
|
$contentType = $resolution->contentType;
|
||||||
if (! $contentType) {
|
$contentSlug = strtolower((string) $contentType->slug);
|
||||||
abort(404);
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
return $this->redirectToContentTypePath($request, $contentSlug, $path, 301);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default sort: trending (not chronological)
|
// Default sort: trending (not chronological)
|
||||||
@@ -265,12 +272,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
$contentTypeSlug = strtolower((string) $contentTypeSlug);
|
$contentTypeSlug = strtolower((string) $contentTypeSlug);
|
||||||
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
|
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
|
||||||
|
|
||||||
|
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
|
||||||
|
if (! $resolution->found() || $resolution->contentType === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
|
||||||
|
|
||||||
// Normalize artwork param if route-model binding returned an Artwork model
|
// Normalize artwork param if route-model binding returned an Artwork model
|
||||||
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
|
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
|
||||||
|
|
||||||
|
if ($resolution->requiresRedirect()) {
|
||||||
|
$path = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||||
|
|
||||||
|
return $this->redirectToContentTypePath($req, $resolvedContentTypeSlug, $path, 301);
|
||||||
|
}
|
||||||
|
|
||||||
return app(\App\Http\Controllers\ArtworkController::class)->show(
|
return app(\App\Http\Controllers\ArtworkController::class)->show(
|
||||||
$req,
|
$req,
|
||||||
$contentTypeSlug,
|
$resolvedContentTypeSlug,
|
||||||
$categoryPath,
|
$categoryPath,
|
||||||
$artworkSlug
|
$artworkSlug
|
||||||
);
|
);
|
||||||
@@ -293,7 +313,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||||
|
|
||||||
return (object) [
|
return (object) $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||||
@@ -317,7 +337,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
'published_at' => $artwork->published_at,
|
'published_at' => $artwork->published_at,
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
];
|
], $artwork, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -372,9 +392,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
private function mainCategories(): Collection
|
private function mainCategories(): Collection
|
||||||
{
|
{
|
||||||
return ContentType::ordered()
|
return $this->contentTypeResolver
|
||||||
->whereIn('slug', self::CONTENT_TYPE_SLUGS)
|
->publicContentTypes()
|
||||||
->get(['name', 'slug'])
|
|
||||||
->map(function (ContentType $type) {
|
->map(function (ContentType $type) {
|
||||||
return (object) [
|
return (object) [
|
||||||
'id' => $type->id,
|
'id' => $type->id,
|
||||||
@@ -385,6 +404,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function redirectToContentTypePath(Request $request, string $contentTypeSlug, ?string $path = null, int $status = 301): RedirectResponse
|
||||||
|
{
|
||||||
|
$target = url('/' . trim($contentTypeSlug . '/' . trim((string) $path, '/'), '/'));
|
||||||
|
$queryString = $request->getQueryString();
|
||||||
|
|
||||||
|
if ($queryString) {
|
||||||
|
$target .= '?' . $queryString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($target, $status);
|
||||||
|
}
|
||||||
|
|
||||||
private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array
|
private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array
|
||||||
{
|
{
|
||||||
$canonicalQuery = $request->query();
|
$canonicalQuery = $request->query();
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ namespace App\Http\Controllers\Web;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class DailyUploadsController extends Controller
|
class DailyUploadsController extends Controller
|
||||||
{
|
{
|
||||||
protected ArtworkService $artworks;
|
protected ArtworkService $artworks;
|
||||||
|
|
||||||
public function __construct(ArtworkService $artworks)
|
public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
|
||||||
{
|
{
|
||||||
$this->artworks = $artworks;
|
$this->artworks = $artworks;
|
||||||
}
|
}
|
||||||
@@ -76,11 +77,11 @@ class DailyUploadsController extends Controller
|
|||||||
|
|
||||||
private function prepareArts($ars)
|
private function prepareArts($ars)
|
||||||
{
|
{
|
||||||
return $ars->map(function (Artwork $ar) {
|
$items = $ars->map(function (Artwork $ar): array {
|
||||||
$primaryCategory = $ar->categories->sortBy('sort_order')->first();
|
$primaryCategory = $ar->categories->sortBy('sort_order')->first();
|
||||||
$present = \App\Services\ThumbnailPresenter::present($ar, 'md');
|
$present = \App\Services\ThumbnailPresenter::present($ar, 'md');
|
||||||
|
|
||||||
return (object) [
|
return $this->maturity->decoratePayload([
|
||||||
'id' => $ar->id,
|
'id' => $ar->id,
|
||||||
'name' => $ar->title,
|
'name' => $ar->title,
|
||||||
'thumb' => $present['url'],
|
'thumb' => $present['url'],
|
||||||
@@ -88,7 +89,11 @@ class DailyUploadsController extends Controller
|
|||||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||||
'category_name' => $primaryCategory->name ?? '',
|
'category_name' => $primaryCategory->name ?? '',
|
||||||
'uname' => $ar->user->name ?? 'Skinbase',
|
'uname' => $ar->user->name ?? 'Skinbase',
|
||||||
];
|
], $ar, request()->user());
|
||||||
});
|
})->values()->all();
|
||||||
|
|
||||||
|
return collect($this->maturity->filterPayloadItems($items, request()->user()))
|
||||||
|
->map(static fn (array $item): object => (object) $item)
|
||||||
|
->values();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ use App\Services\ArtworkSearchService;
|
|||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||||
use App\Services\UserSuggestionService;
|
use App\Services\UserSuggestionService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@@ -38,6 +40,7 @@ final class DiscoverController extends Controller
|
|||||||
private readonly GridFiller $gridFiller,
|
private readonly GridFiller $gridFiller,
|
||||||
private readonly CommunityActivityService $communityActivity,
|
private readonly CommunityActivityService $communityActivity,
|
||||||
private readonly UserSuggestionService $userSuggestions,
|
private readonly UserSuggestionService $userSuggestions,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||||
@@ -178,6 +181,7 @@ final class DiscoverController extends Controller
|
|||||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||||
->whereRaw('YEAR(published_at) < ?', [$today->year])
|
->whereRaw('YEAR(published_at) < ?', [$today->year])
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
@@ -270,7 +274,8 @@ final class DiscoverController extends Controller
|
|||||||
|
|
||||||
$artworks = collect($feedResult['data'] ?? [])->map(
|
$artworks = collect($feedResult['data'] ?? [])->map(
|
||||||
fn (array $item) => $this->presentRecommendedArtwork($item)
|
fn (array $item) => $this->presentRecommendedArtwork($item)
|
||||||
)->values();
|
);
|
||||||
|
$artworks = $this->reorderDiscoverItemsByThumbnailHealth($artworks)->values();
|
||||||
|
|
||||||
$meta = $feedResult['meta'] ?? [];
|
$meta = $feedResult['meta'] ?? [];
|
||||||
$nextCursor = $meta['next_cursor'] ?? null;
|
$nextCursor = $meta['next_cursor'] ?? null;
|
||||||
@@ -345,6 +350,7 @@ final class DiscoverController extends Controller
|
|||||||
->published()
|
->published()
|
||||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||||
->whereIn('user_id', $followingIds)
|
->whereIn('user_id', $followingIds)
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
@@ -416,6 +422,7 @@ final class DiscoverController extends Controller
|
|||||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||||
'categories.contentType:id,slug,name',
|
'categories.contentType:id,slug,name',
|
||||||
])
|
])
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
@@ -438,6 +445,7 @@ final class DiscoverController extends Controller
|
|||||||
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
|
||||||
->select('artworks.*')
|
->select('artworks.*')
|
||||||
->where('artworks.published_at', '>=', $cutoff)
|
->where('artworks.published_at', '>=', $cutoff)
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('discover_stats.ranking_score')
|
->orderByDesc('discover_stats.ranking_score')
|
||||||
->orderByDesc('discover_stats.engagement_velocity')
|
->orderByDesc('discover_stats.engagement_velocity')
|
||||||
->orderByDesc('discover_stats.views')
|
->orderByDesc('discover_stats.views')
|
||||||
@@ -465,6 +473,7 @@ final class DiscoverController extends Controller
|
|||||||
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
|
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
|
||||||
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
||||||
->where('artworks.published_at', '>=', $cutoff)
|
->where('artworks.published_at', '>=', $cutoff)
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('discover_stats.heat_score')
|
->orderByDesc('discover_stats.heat_score')
|
||||||
->orderByDesc('discover_stats.engagement_velocity')
|
->orderByDesc('discover_stats.engagement_velocity')
|
||||||
->orderByDesc('artworks.published_at')
|
->orderByDesc('artworks.published_at')
|
||||||
@@ -496,6 +505,7 @@ final class DiscoverController extends Controller
|
|||||||
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
||||||
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
|
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
|
||||||
->where('artworks.published_at', '>=', $cutoff)
|
->where('artworks.published_at', '>=', $cutoff)
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc('recent_signal_24h')
|
->orderByDesc('recent_signal_24h')
|
||||||
->orderByDesc('artworks.published_at')
|
->orderByDesc('artworks.published_at')
|
||||||
->orderByDesc('artworks.id')
|
->orderByDesc('artworks.id')
|
||||||
@@ -599,7 +609,7 @@ final class DiscoverController extends Controller
|
|||||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||||
|
|
||||||
return (object) [
|
return (object) $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||||
@@ -624,7 +634,7 @@ final class DiscoverController extends Controller
|
|||||||
'published_at' => $artwork->published_at,
|
'published_at' => $artwork->published_at,
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
];
|
], $artwork, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,6 +686,7 @@ final class DiscoverController extends Controller
|
|||||||
->whereIn('user_id', $followingIds)
|
->whereIn('user_id', $followingIds)
|
||||||
->where('published_at', '>=', now()->subDays(30))
|
->where('published_at', '>=', now()->subDays(30))
|
||||||
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderMissingThumbnailsLast()
|
||||||
->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)'))
|
->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)'))
|
||||||
->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)'))
|
->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)'))
|
||||||
->orderByDesc('artworks.published_at')
|
->orderByDesc('artworks.published_at')
|
||||||
@@ -703,4 +714,42 @@ final class DiscoverController extends Controller
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, object> $items
|
||||||
|
* @return Collection<int, object>
|
||||||
|
*/
|
||||||
|
private function reorderDiscoverItemsByThumbnailHealth(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
|
||||||
|
|
||||||
|
return $healthy
|
||||||
|
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ namespace App\Http\Controllers\Web;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ContentType;
|
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||||
@@ -27,8 +28,6 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
*/
|
*/
|
||||||
final class ExploreController extends Controller
|
final class ExploreController extends Controller
|
||||||
{
|
{
|
||||||
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
|
|
||||||
|
|
||||||
/** Meilisearch sort-field arrays per sort alias. */
|
/** Meilisearch sort-field arrays per sort alias. */
|
||||||
private const SORT_MAP = [
|
private const SORT_MAP = [
|
||||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||||
@@ -65,6 +64,8 @@ final class ExploreController extends Controller
|
|||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
private readonly GridFiller $gridFiller,
|
private readonly GridFiller $gridFiller,
|
||||||
private readonly SpotlightEngineInterface $spotlight,
|
private readonly SpotlightEngineInterface $spotlight,
|
||||||
|
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── /explore (hub) ──────────────────────────────────────────────────
|
// ── /explore (hub) ──────────────────────────────────────────────────
|
||||||
@@ -75,13 +76,15 @@ final class ExploreController extends Controller
|
|||||||
$perPage = $this->resolvePerPage($request);
|
$perPage = $this->resolvePerPage($request);
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||||
|
$cacheVersion = $this->cacheVersion();
|
||||||
|
|
||||||
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () =>
|
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||||
Artwork::search('')->options([
|
$this->search->searchWithThumbnailPreference([
|
||||||
'filter' => 'is_public = true AND is_approved = true',
|
'filter' => 'is_public = true AND is_approved = true',
|
||||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||||
])->paginate($perPage)
|
], $perPage, false, $page)
|
||||||
);
|
);
|
||||||
|
$artworks = $this->filterBrowsableArtworks($artworks);
|
||||||
// EGS: fill grid to minimum when uploads are sparse
|
// EGS: fill grid to minimum when uploads are sparse
|
||||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||||
@@ -121,35 +124,43 @@ final class ExploreController extends Controller
|
|||||||
|
|
||||||
public function byType(Request $request, string $type)
|
public function byType(Request $request, string $type)
|
||||||
{
|
{
|
||||||
$type = strtolower($type);
|
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
|
||||||
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
|
|
||||||
|
if (! $resolution->found()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "artworks" is the umbrella — search all types
|
$isAll = $resolution->isVirtual && $resolution->virtualType === 'artworks';
|
||||||
$isAll = $type === 'artworks';
|
|
||||||
|
if (! $isAll && $resolution->contentType === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedTypeSlug = $isAll ? 'artworks' : strtolower((string) $resolution->contentType->slug);
|
||||||
|
|
||||||
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
|
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
|
||||||
if (! $isAll) {
|
if (! $isAll) {
|
||||||
return redirect()->to($this->canonicalTypeUrl($request, $type), 301);
|
return redirect()->to($this->canonicalTypeUrl($request, $resolvedTypeSlug), 301);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sort = $this->resolveSort($request);
|
$sort = $this->resolveSort($request);
|
||||||
$perPage = $this->resolvePerPage($request);
|
$perPage = $this->resolvePerPage($request);
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||||
|
$cacheVersion = $this->cacheVersion();
|
||||||
|
|
||||||
$filter = 'is_public = true AND is_approved = true';
|
$filter = 'is_public = true AND is_approved = true';
|
||||||
if (!$isAll) {
|
if (!$isAll) {
|
||||||
$filter .= ' AND content_type = "' . $type . '"';
|
$filter .= ' AND content_type = "' . $type . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () =>
|
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||||
Artwork::search('')->options([
|
$this->search->searchWithThumbnailPreference([
|
||||||
'filter' => $filter,
|
'filter' => $filter,
|
||||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||||
])->paginate($perPage)
|
], $perPage, false, $page)
|
||||||
);
|
);
|
||||||
|
$artworks = $this->filterBrowsableArtworks($artworks);
|
||||||
// EGS: fill grid to minimum when uploads are sparse
|
// EGS: fill grid to minimum when uploads are sparse
|
||||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||||
@@ -163,7 +174,7 @@ final class ExploreController extends Controller
|
|||||||
$contentType = null;
|
$contentType = null;
|
||||||
$subcategories = $mainCategories;
|
$subcategories = $mainCategories;
|
||||||
if (! $isAll) {
|
if (! $isAll) {
|
||||||
$contentType = ContentType::where('slug', $type)->first();
|
$contentType = $resolution->contentType;
|
||||||
$subcategories = $contentType
|
$subcategories = $contentType
|
||||||
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
||||||
: collect();
|
: collect();
|
||||||
@@ -172,10 +183,10 @@ final class ExploreController extends Controller
|
|||||||
if ($isAll) {
|
if ($isAll) {
|
||||||
$humanType = 'Artworks';
|
$humanType = 'Artworks';
|
||||||
} else {
|
} else {
|
||||||
$humanType = $contentType?->name ?? ucfirst($type);
|
$humanType = $contentType?->name ?? ucfirst($resolvedTypeSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = url('/explore/' . $type);
|
$baseUrl = url('/explore/' . $resolvedTypeSlug);
|
||||||
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
|
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
|
||||||
|
|
||||||
return view('gallery.index', [
|
return view('gallery.index', [
|
||||||
@@ -192,11 +203,11 @@ final class ExploreController extends Controller
|
|||||||
'hero_description' => "Browse {$humanType} on Skinbase.",
|
'hero_description' => "Browse {$humanType} on Skinbase.",
|
||||||
'breadcrumbs' => collect([
|
'breadcrumbs' => collect([
|
||||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||||
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
|
(object) ['name' => $humanType, 'url' => "/explore/{$resolvedTypeSlug}"],
|
||||||
]),
|
]),
|
||||||
'page_title' => "{$humanType} - Explore - Skinbase",
|
'page_title' => "{$humanType} - Explore - Skinbase",
|
||||||
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
|
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
|
||||||
'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography',
|
'page_meta_keywords' => strtolower($resolvedTypeSlug) . ', explore, skinbase, artworks, wallpapers, skins, photography',
|
||||||
'page_canonical' => $seo['canonical'],
|
'page_canonical' => $seo['canonical'],
|
||||||
'page_rel_prev' => $seo['prev'],
|
'page_rel_prev' => $seo['prev'],
|
||||||
'page_rel_next' => $seo['next'],
|
'page_rel_next' => $seo['next'],
|
||||||
@@ -208,12 +219,17 @@ final class ExploreController extends Controller
|
|||||||
|
|
||||||
public function byTypeMode(Request $request, string $type, string $mode)
|
public function byTypeMode(Request $request, string $type, string $mode)
|
||||||
{
|
{
|
||||||
$type = strtolower($type);
|
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
|
||||||
if ($type !== 'artworks') {
|
|
||||||
|
if (! $resolution->found()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($resolution->isVirtual && $resolution->virtualType === 'artworks')) {
|
||||||
$query = $request->query();
|
$query = $request->query();
|
||||||
$query['sort'] = $this->normalizeSort((string) $mode);
|
$query['sort'] = $this->normalizeSort((string) $mode);
|
||||||
|
|
||||||
return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301);
|
return redirect()->to($this->canonicalTypeUrl($request, strtolower((string) $resolution->contentType?->slug), $query), 301);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite the sort via the URL segment and delegate
|
// Rewrite the sort via the URL segment and delegate
|
||||||
@@ -225,8 +241,8 @@ final class ExploreController extends Controller
|
|||||||
|
|
||||||
private function mainCategories(): Collection
|
private function mainCategories(): Collection
|
||||||
{
|
{
|
||||||
$categories = ContentType::ordered()
|
$categories = $this->contentTypeResolver
|
||||||
->get(['name', 'slug'])
|
->publicContentTypes()
|
||||||
->map(fn ($ct) => (object) [
|
->map(fn ($ct) => (object) [
|
||||||
'name' => $ct->name,
|
'name' => $ct->name,
|
||||||
'slug' => $ct->slug,
|
'slug' => $ct->slug,
|
||||||
@@ -272,6 +288,26 @@ final class ExploreController extends Controller
|
|||||||
return max(12, min($v, 80));
|
return max(12, min($v, 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cacheVersion(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) Cache::get('explore.cache.version', 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
|
||||||
|
{
|
||||||
|
$paginator->setCollection(
|
||||||
|
$paginator->getCollection()
|
||||||
|
->filter(fn ($artwork) => $artwork instanceof Artwork
|
||||||
|
&& $artwork->deleted_at === null
|
||||||
|
&& (bool) $artwork->is_public
|
||||||
|
&& (bool) $artwork->is_approved
|
||||||
|
&& $artwork->published_at !== null)
|
||||||
|
->values()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $paginator;
|
||||||
|
}
|
||||||
|
|
||||||
private function presentArtwork(Artwork $artwork): object
|
private function presentArtwork(Artwork $artwork): object
|
||||||
{
|
{
|
||||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||||
@@ -289,7 +325,7 @@ final class ExploreController extends Controller
|
|||||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||||
|
|
||||||
return (object) [
|
return (object) $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'content_type_name' => $primary?->contentType?->name ?? '',
|
'content_type_name' => $primary?->contentType?->name ?? '',
|
||||||
@@ -314,7 +350,7 @@ final class ExploreController extends Controller
|
|||||||
'slug' => $artwork->slug ?? '',
|
'slug' => $artwork->slug ?? '',
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
];
|
], $artwork, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function paginationSeo(Request $request, string $base, mixed $paginator): array
|
private function paginationSeo(Request $request, string $base, mixed $paginator): array
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -13,7 +14,7 @@ class FeaturedArtworksController extends Controller
|
|||||||
{
|
{
|
||||||
protected ArtworkService $artworks;
|
protected ArtworkService $artworks;
|
||||||
|
|
||||||
public function __construct(ArtworkService $artworks)
|
public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
|
||||||
{
|
{
|
||||||
$this->artworks = $artworks;
|
$this->artworks = $artworks;
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,8 @@ class FeaturedArtworksController extends Controller
|
|||||||
/** @var LengthAwarePaginator $artworks */
|
/** @var LengthAwarePaginator $artworks */
|
||||||
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
|
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
|
||||||
|
|
||||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
$artworks->setCollection(
|
||||||
|
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork): array {
|
||||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$categoryName = $primaryCategory->name ?? '';
|
$categoryName = $primaryCategory->name ?? '';
|
||||||
$categorySlug = $primaryCategory->slug ?? '';
|
$categorySlug = $primaryCategory->slug ?? '';
|
||||||
@@ -37,7 +39,7 @@ class FeaturedArtworksController extends Controller
|
|||||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||||
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
||||||
|
|
||||||
return (object) [
|
return $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'slug' => $artwork->slug,
|
'slug' => $artwork->slug,
|
||||||
@@ -53,8 +55,11 @@ class FeaturedArtworksController extends Controller
|
|||||||
'height' => $artwork->height,
|
'height' => $artwork->height,
|
||||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
];
|
], $artwork, $request->user());
|
||||||
});
|
})->values()->all(), $request->user()))
|
||||||
|
->map(static fn (array $item): object => (object) $item)
|
||||||
|
->values()
|
||||||
|
);
|
||||||
|
|
||||||
$artworkTypes = [
|
$artworkTypes = [
|
||||||
1 => 'Bronze Awards',
|
1 => 'Bronze Awards',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ContentType;
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
@@ -26,52 +26,6 @@ final class RssFeedController extends Controller
|
|||||||
/** Number of items per legacy feed. */
|
/** Number of items per legacy feed. */
|
||||||
private const FEED_LIMIT = 25;
|
private const FEED_LIMIT = 25;
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouped feed definitions shown on the /rss-feeds info page.
|
|
||||||
* Each group has a 'label' and an array of 'feeds' with title + url.
|
|
||||||
*/
|
|
||||||
public const FEED_GROUPS = [
|
|
||||||
'global' => [
|
|
||||||
'label' => 'Global',
|
|
||||||
'feeds' => [
|
|
||||||
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'discover' => [
|
|
||||||
'label' => 'Discover',
|
|
||||||
'feeds' => [
|
|
||||||
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
|
|
||||||
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
|
|
||||||
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'explore' => [
|
|
||||||
'label' => 'Explore',
|
|
||||||
'feeds' => [
|
|
||||||
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
|
|
||||||
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
|
|
||||||
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
|
|
||||||
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
|
|
||||||
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'blog' => [
|
|
||||||
'label' => 'Blog',
|
|
||||||
'feeds' => [
|
|
||||||
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'legacy' => [
|
|
||||||
'label' => 'Legacy Feeds',
|
|
||||||
'feeds' => [
|
|
||||||
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
|
|
||||||
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
|
|
||||||
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
|
|
||||||
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Flat feed list kept for backward-compatibility (old view logic). */
|
/** Flat feed list kept for backward-compatibility (old view logic). */
|
||||||
public const FEEDS = [
|
public const FEEDS = [
|
||||||
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
||||||
@@ -80,6 +34,10 @@ final class RssFeedController extends Controller
|
|||||||
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
|
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/** Info page at /rss-feeds */
|
/** Info page at /rss-feeds */
|
||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
@@ -94,7 +52,7 @@ final class RssFeedController extends Controller
|
|||||||
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
||||||
]),
|
]),
|
||||||
'feeds' => self::FEEDS,
|
'feeds' => self::FEEDS,
|
||||||
'feed_groups' => self::FEED_GROUPS,
|
'feed_groups' => $this->feedGroups(),
|
||||||
'center_content' => true,
|
'center_content' => true,
|
||||||
'center_max' => '3xl',
|
'center_max' => '3xl',
|
||||||
]);
|
]);
|
||||||
@@ -134,7 +92,7 @@ final class RssFeedController extends Controller
|
|||||||
|
|
||||||
private function feedByContentType(string $slug, string $title, string $feedPath): Response
|
private function feedByContentType(string $slug, string $title, string $feedPath): Response
|
||||||
{
|
{
|
||||||
$contentType = ContentType::where('slug', $slug)->first();
|
$contentType = $this->contentTypeResolver->resolve($slug)->contentType;
|
||||||
|
|
||||||
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
|
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
|
||||||
|
|
||||||
@@ -160,4 +118,70 @@ final class RssFeedController extends Controller
|
|||||||
'Content-Type' => 'application/rss+xml; charset=utf-8',
|
'Content-Type' => 'application/rss+xml; charset=utf-8',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function feedGroups(): array
|
||||||
|
{
|
||||||
|
$exploreFeeds = [[
|
||||||
|
'title' => 'All Artworks',
|
||||||
|
'url' => '/rss/explore/artworks',
|
||||||
|
'description' => 'Latest artworks of all types.',
|
||||||
|
]];
|
||||||
|
|
||||||
|
foreach ($this->contentTypeResolver->publicContentTypes() as $contentType) {
|
||||||
|
$name = (string) $contentType->name;
|
||||||
|
$slug = (string) $contentType->slug;
|
||||||
|
|
||||||
|
$exploreFeeds[] = [
|
||||||
|
'title' => $name,
|
||||||
|
'url' => '/rss/explore/' . $slug,
|
||||||
|
'description' => 'Latest ' . strtolower($name) . '.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->contentTypeResolver->publicContentTypes()->isNotEmpty()) {
|
||||||
|
$firstType = $this->contentTypeResolver->publicContentTypes()->first();
|
||||||
|
|
||||||
|
$exploreFeeds[] = [
|
||||||
|
'title' => 'Trending ' . $firstType->name,
|
||||||
|
'url' => '/rss/explore/' . $firstType->slug . '/trending',
|
||||||
|
'description' => 'Trending ' . strtolower((string) $firstType->name) . ' this week.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'global' => [
|
||||||
|
'label' => 'Global',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'discover' => [
|
||||||
|
'label' => 'Discover',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
|
||||||
|
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
|
||||||
|
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'explore' => [
|
||||||
|
'label' => 'Explore',
|
||||||
|
'feeds' => $exploreFeeds,
|
||||||
|
],
|
||||||
|
'blog' => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'legacy' => [
|
||||||
|
'label' => 'Legacy Feeds',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class SearchController extends Controller
|
|||||||
{
|
{
|
||||||
$q = trim((string) $request->query('q', ''));
|
$q = trim((string) $request->query('q', ''));
|
||||||
$sort = $request->query('sort', 'latest');
|
$sort = $request->query('sort', 'latest');
|
||||||
|
$hasQuery = $q !== '';
|
||||||
|
|
||||||
$sortMap = [
|
$sortMap = [
|
||||||
'popular' => 'views:desc',
|
'popular' => 'views:desc',
|
||||||
@@ -30,17 +31,17 @@ final class SearchController extends Controller
|
|||||||
'downloads' => 'downloads:desc',
|
'downloads' => 'downloads:desc',
|
||||||
];
|
];
|
||||||
|
|
||||||
$artworks = $q !== ''
|
$artworks = $hasQuery
|
||||||
? $this->search->search($q, [
|
? $this->search->search($q, [
|
||||||
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
|
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
|
||||||
])
|
])
|
||||||
: $this->search->popular(24);
|
: $this->search->popular(24);
|
||||||
|
|
||||||
$groups = $q !== ''
|
$groups = $hasQuery
|
||||||
? $this->groups->searchCards($q, $request->user(), 6)
|
? $this->groups->searchCards($q, $request->user(), 6)
|
||||||
: $this->groups->surfaceCards($request->user(), 'featured', 4);
|
: $this->groups->surfaceCards($request->user(), 'featured', 4);
|
||||||
|
|
||||||
$news = $q !== ''
|
$news = $hasQuery
|
||||||
? NewsArticle::query()
|
? NewsArticle::query()
|
||||||
->with(['author:id,username,name', 'category:id,name,slug'])
|
->with(['author:id,username,name', 'category:id,name,slug'])
|
||||||
->published()
|
->published()
|
||||||
@@ -55,15 +56,59 @@ final class SearchController extends Controller
|
|||||||
->get()
|
->get()
|
||||||
: collect();
|
: collect();
|
||||||
|
|
||||||
|
$groupResults = collect($groups ?? []);
|
||||||
|
$newsResults = collect($news ?? []);
|
||||||
|
$resultCount = method_exists($artworks, 'total') ? (int) $artworks->total() : 0;
|
||||||
|
$groupResultCount = $groupResults->count();
|
||||||
|
$newsResultCount = $newsResults->count();
|
||||||
|
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
|
||||||
|
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : $artworks)
|
||||||
|
->map(fn ($art) => $this->mapArtworkCard($art))
|
||||||
|
->values();
|
||||||
|
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||||
|
|
||||||
return view('search.index', [
|
return view('search.index', [
|
||||||
'q' => $q,
|
'q' => $q,
|
||||||
|
'hasQuery' => $hasQuery,
|
||||||
'sort' => $sort,
|
'sort' => $sort,
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
|
'groupResults' => $groupResults,
|
||||||
|
'groupResultCount' => $groupResultCount,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
|
'resultCount' => $resultCount,
|
||||||
'news' => $news,
|
'news' => $news,
|
||||||
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
|
'newsResults' => $newsResults,
|
||||||
|
'newsResultCount' => $newsResultCount,
|
||||||
|
'hasAnyResults' => $hasAnyResults,
|
||||||
|
'galleryArtworks' => $galleryArtworks,
|
||||||
|
'galleryNextPageUrl' => $galleryNextPageUrl,
|
||||||
|
'page_title' => $hasQuery ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
|
||||||
'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.',
|
'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.',
|
||||||
'page_robots' => 'noindex,follow',
|
'page_robots' => 'noindex,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mapArtworkCard(mixed $artwork): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $artwork->id ?? null,
|
||||||
|
'name' => $artwork->name ?? null,
|
||||||
|
'thumb' => $artwork->thumb_url ?? $artwork->thumb ?? null,
|
||||||
|
'thumb_srcset' => $artwork->thumb_srcset ?? null,
|
||||||
|
'uname' => $artwork->uname ?? '',
|
||||||
|
'username' => $artwork->username ?? '',
|
||||||
|
'avatar_url' => $artwork->avatar_url ?? null,
|
||||||
|
'profile_url' => $artwork->profile_url ?? null,
|
||||||
|
'published_as_type' => $artwork->published_as_type ?? null,
|
||||||
|
'publisher' => $artwork->publisher ?? null,
|
||||||
|
'category_name' => $artwork->category_name ?? '',
|
||||||
|
'category_slug' => $artwork->category_slug ?? '',
|
||||||
|
'slug' => $artwork->slug ?? '',
|
||||||
|
'width' => $artwork->width ?? null,
|
||||||
|
'height' => $artwork->height ?? null,
|
||||||
|
'views' => $artwork->views ?? null,
|
||||||
|
'likes' => $artwork->likes ?? null,
|
||||||
|
'downloads' => $artwork->downloads ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Services\Vision\VectorService;
|
use App\Services\Vision\VectorService;
|
||||||
@@ -35,6 +36,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly VectorService $vectors,
|
private readonly VectorService $vectors,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
private readonly HybridSimilarArtworksService $hybridService,
|
private readonly HybridSimilarArtworksService $hybridService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
|
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
|
||||||
'author_name' => $source->user?->name ?? 'Artist',
|
'author_name' => $source->user?->name ?? 'Artist',
|
||||||
'author_username' => $source->user?->username ?? '',
|
'author_username' => $source->user?->username ?? '',
|
||||||
|
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
|
||||||
'author_avatar' => AvatarUrl::forUser(
|
'author_avatar' => AvatarUrl::forUser(
|
||||||
(int) ($source->user_id ?? 0),
|
(int) ($source->user_id ?? 0),
|
||||||
$source->user?->profile?->avatar_hash ?? null,
|
$source->user?->profile?->avatar_hash ?? null,
|
||||||
@@ -79,6 +82,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'category_slug' => $primaryCat?->slug ?? '',
|
'category_slug' => $primaryCat?->slug ?? '',
|
||||||
'content_type_name' => $primaryCat?->contentType?->name ?? '',
|
'content_type_name' => $primaryCat?->contentType?->name ?? '',
|
||||||
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
|
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
|
||||||
|
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
|
||||||
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
|
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
|
||||||
'width' => $source->width ?? null,
|
'width' => $source->width ?? null,
|
||||||
'height' => $source->height ?? null,
|
'height' => $source->height ?? null,
|
||||||
@@ -144,8 +148,11 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'slug' => $art->slug ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
'width' => $art->width ?? null,
|
'width' => $art->width ?? null,
|
||||||
'height' => $art->height ?? null,
|
'height' => $art->height ?? null,
|
||||||
|
'maturity' => $art->maturity ?? null,
|
||||||
])->values();
|
])->values();
|
||||||
|
|
||||||
|
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $galleryItems,
|
'data' => $galleryItems,
|
||||||
'similarity_source' => $similaritySource,
|
'similarity_source' => $similaritySource,
|
||||||
@@ -303,7 +310,7 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||||
|
|
||||||
return (object) [
|
return (object) $this->maturity->decoratePayload([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'content_type_name' => $primary?->contentType?->name ?? '',
|
'content_type_name' => $primary?->contentType?->name ?? '',
|
||||||
@@ -328,6 +335,6 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'slug' => $artwork->slug ?? '',
|
'slug' => $artwork->slug ?? '',
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
];
|
], $artwork, request()->user());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\Tags\TagDiscoveryService;
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,6 +18,7 @@ final class TagController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
private readonly TagDiscoveryService $tagDiscovery,
|
private readonly TagDiscoveryService $tagDiscovery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -61,12 +63,12 @@ final class TagController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Map artworks into the lightweight shape expected by the gallery React component.
|
// Map artworks into the lightweight shape expected by the gallery React component.
|
||||||
$galleryCollection = $artworks->getCollection()->map(function ($a) {
|
$galleryCollection = collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function ($a) use ($request): array {
|
||||||
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($a, 'md');
|
$present = ThumbnailPresenter::present($a, 'md');
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
|
||||||
|
|
||||||
return (object) [
|
return $this->maturity->decoratePayload([
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'name' => $a->title ?? ($a->name ?? null),
|
'name' => $a->title ?? ($a->name ?? null),
|
||||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||||
@@ -82,8 +84,10 @@ final class TagController extends Controller
|
|||||||
'width' => $a->width ?? null,
|
'width' => $a->width ?? null,
|
||||||
'height' => $a->height ?? null,
|
'height' => $a->height ?? null,
|
||||||
'slug' => $a->slug ?? null,
|
'slug' => $a->slug ?? null,
|
||||||
];
|
], $a, $request->user());
|
||||||
})->values();
|
})->values()->all(), $request->user()))
|
||||||
|
->map(static fn (array $item): object => (object) $item)
|
||||||
|
->values();
|
||||||
|
|
||||||
// Replace paginator collection with the gallery-shaped collection so
|
// Replace paginator collection with the gallery-shaped collection so
|
||||||
// the gallery.index blade will generate the expected JSON payload.
|
// the gallery.index blade will generate the expected JSON payload.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ConditionalCors
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to env if config wasn't populated for some reason.
|
// Fallback to env if config wasn't populated for some reason.
|
||||||
$enabled = env('CP_ENABLE_CORS', true);
|
$enabled = env('CP_ENABLE_CORS', false);
|
||||||
|
|
||||||
if (! $enabled) {
|
if (! $enabled) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
32
app/Http/Middleware/EnsureArtworkMaturityAccess.php
Normal file
32
app/Http/Middleware/EnsureArtworkMaturityAccess.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class EnsureArtworkMaturityAccess
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($request->user('controlpanel') !== null) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$role = strtolower((string) ($user?->role ?? ''));
|
||||||
|
|
||||||
|
if (in_array($role, ['admin', 'moderator'], true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->expectsJson() && route('cp.login', absolute: false) !== null) {
|
||||||
|
return redirect()->route('cp.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ final class ArtworkTagsStoreRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tags' => 'required|array|max:15',
|
'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||||
'tags.*' => 'required|string|max:64',
|
'tags.*' => 'required|string|max:64',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class ArtworkTagsUpdateRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tags' => 'required|array|max:15',
|
'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||||
'tags.*' => 'required|string|max:64',
|
'tags.*' => 'required|string|max:64',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateContentPreferencesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mature_content_visibility' => ['required', 'in:hide,blur,show'],
|
||||||
|
'mature_content_warning_enabled' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
|||||||
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
|
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
|
||||||
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||||
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
|
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
|
||||||
'tags' => ['sometimes', 'array', 'max:15'],
|
'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||||
'tags.*' => ['string', 'max:64'],
|
'tags.*' => ['string', 'max:64'],
|
||||||
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
|
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
|
||||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Http\Resources\MissingValue;
|
use Illuminate\Http\Resources\MissingValue;
|
||||||
use App\Services\ThumbnailService;
|
use App\Services\ThumbnailService;
|
||||||
@@ -77,7 +78,7 @@ class ArtworkListResource extends JsonResource
|
|||||||
]
|
]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return app(ArtworkMaturityService::class)->decoratePayload([
|
||||||
'id' => $artId,
|
'id' => $artId,
|
||||||
'slug' => $slugVal,
|
'slug' => $slugVal,
|
||||||
'title' => $decode($get('title')),
|
'title' => $decode($get('title')),
|
||||||
@@ -106,6 +107,6 @@ class ArtworkListResource extends JsonResource
|
|||||||
'direct' => $directUrl,
|
'direct' => $directUrl,
|
||||||
'canonical' => $webUrl ?? $directUrl,
|
'canonical' => $webUrl ?? $directUrl,
|
||||||
],
|
],
|
||||||
];
|
], $this->resource, $request->user());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Services\ArtworkEvolutionService;
|
||||||
use App\Services\ContentSanitizer;
|
use App\Services\ContentSanitizer;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -97,11 +99,11 @@ class ArtworkResource extends JsonResource
|
|||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Schema::hasTable('artwork_awards')) {
|
if (Schema::hasTable('artwork_medals')) {
|
||||||
$viewerAward = DB::table('artwork_awards')
|
$viewerAward = DB::table('artwork_medals')
|
||||||
->where('user_id', $viewerId)
|
->where('user_id', $viewerId)
|
||||||
->where('artwork_id', (int) $this->id)
|
->where('artwork_id', (int) $this->id)
|
||||||
->value('medal');
|
->value('medal_type');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,8 +227,24 @@ class ArtworkResource extends JsonResource
|
|||||||
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
|
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
|
||||||
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
|
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
|
||||||
'score' => (int) ($this->awardStat?->score_total ?? 0),
|
'score' => (int) ($this->awardStat?->score_total ?? 0),
|
||||||
|
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
|
||||||
|
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
|
||||||
|
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
|
||||||
'viewer_award' => $viewerAward,
|
'viewer_award' => $viewerAward,
|
||||||
],
|
],
|
||||||
|
'medals' => [
|
||||||
|
'gold' => (int) ($this->awardStat?->gold_count ?? 0),
|
||||||
|
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
|
||||||
|
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
|
||||||
|
'score' => (int) ($this->awardStat?->score_total ?? 0),
|
||||||
|
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
|
||||||
|
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
|
||||||
|
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
|
||||||
|
'current_user_medal' => $viewerAward,
|
||||||
|
'viewer_award' => $viewerAward,
|
||||||
|
],
|
||||||
|
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
|
||||||
|
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
|
||||||
'categories' => $this->categories->map(fn ($category) => [
|
'categories' => $this->categories->map(fn ($category) => [
|
||||||
'id' => (int) $category->id,
|
'id' => (int) $category->id,
|
||||||
'slug' => (string) $category->slug,
|
'slug' => (string) $category->slug,
|
||||||
|
|||||||
82
app/Jobs/DetectArtworkMaturityJob.php
Normal file
82
app/Jobs/DetectArtworkMaturityJob.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
use App\Services\Vision\VisionService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
final class DetectArtworkMaturityJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $timeout = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $artworkId,
|
||||||
|
private readonly string $hash,
|
||||||
|
) {
|
||||||
|
$queue = (string) config('maturity.ai.queue', config('vision.queue', 'default'));
|
||||||
|
if ($queue !== '') {
|
||||||
|
$this->onQueue($queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [5, 30, 120];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(VisionService $vision, ArtworkMaturityService $maturity): void
|
||||||
|
{
|
||||||
|
if (! $vision->isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
|
||||||
|
if (! $artwork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailed = $vision->analyzeArtworkMaturityDetailed($artwork, $this->hash);
|
||||||
|
$assessment = (array) ($detailed['assessment'] ?? []);
|
||||||
|
if ($assessment === []) {
|
||||||
|
$assessment = [
|
||||||
|
'status' => ArtworkMaturityService::AI_STATUS_FAILED,
|
||||||
|
'advisory' => 'Vision maturity analysis returned no assessment payload.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$maturity->applyAiAssessment($artwork->fresh(), $assessment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->find($this->artworkId);
|
||||||
|
if ($artwork) {
|
||||||
|
app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
|
||||||
|
'status' => ArtworkMaturityService::AI_STATUS_FAILED,
|
||||||
|
'advisory' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('DetectArtworkMaturityJob failed', [
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'hash' => $this->hash,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Jobs;
|
|||||||
use App\Services\Uploads\UploadPipelineService;
|
use App\Services\Uploads\UploadPipelineService;
|
||||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||||
use App\Jobs\AutoTagArtworkJob;
|
use App\Jobs\AutoTagArtworkJob;
|
||||||
|
use App\Jobs\DetectArtworkMaturityJob;
|
||||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -48,6 +49,7 @@ final class GenerateDerivativesJob implements ShouldQueue
|
|||||||
|
|
||||||
// Auto-tagging is async and must never block publish.
|
// Auto-tagging is async and must never block publish.
|
||||||
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||||
|
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||||
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||||
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
|
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Jobs/RebuildCreatorJourneyJob.php
Normal file
35
app/Jobs/RebuildCreatorJourneyJob.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class RebuildCreatorJourneyJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 2;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $userIds
|
||||||
|
*/
|
||||||
|
public function __construct(public readonly array $userIds)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(CreatorJourneyService $journeys): void
|
||||||
|
{
|
||||||
|
foreach ($this->userIds as $userId) {
|
||||||
|
$journeys->rebuildForUser((int) $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Jobs/RecalculateArtworkMedalStatsJob.php
Normal file
29
app/Jobs/RecalculateArtworkMedalStatsJob.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\ArtworkMedalService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class RecalculateArtworkMedalStatsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public readonly int $artworkId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ArtworkMedalService $medals): void
|
||||||
|
{
|
||||||
|
$medals->refreshArtworkMedalState($this->artworkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ class Artwork extends Model
|
|||||||
'hash',
|
'hash',
|
||||||
'file_ext',
|
'file_ext',
|
||||||
'thumb_ext',
|
'thumb_ext',
|
||||||
|
'has_missing_thumbnails',
|
||||||
|
'missing_thumbnail_variants_json',
|
||||||
|
'thumbnails_checked_at',
|
||||||
'file_size',
|
'file_size',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
'width',
|
'width',
|
||||||
@@ -60,6 +63,27 @@ class Artwork extends Model
|
|||||||
'visibility',
|
'visibility',
|
||||||
'is_approved',
|
'is_approved',
|
||||||
'is_mature',
|
'is_mature',
|
||||||
|
'maturity_level',
|
||||||
|
'maturity_source',
|
||||||
|
'maturity_status',
|
||||||
|
'maturity_ai_score',
|
||||||
|
'maturity_ai_labels',
|
||||||
|
'maturity_ai_label',
|
||||||
|
'maturity_ai_confidence',
|
||||||
|
'maturity_ai_model',
|
||||||
|
'maturity_ai_threshold_used',
|
||||||
|
'maturity_ai_analysis_time_ms',
|
||||||
|
'maturity_ai_action_hint',
|
||||||
|
'maturity_ai_advisory',
|
||||||
|
'maturity_ai_status',
|
||||||
|
'maturity_ai_detected_at',
|
||||||
|
'maturity_declared_at',
|
||||||
|
'maturity_flagged_at',
|
||||||
|
'maturity_flag_reason',
|
||||||
|
'maturity_reviewed_by',
|
||||||
|
'maturity_reviewed_at',
|
||||||
|
'maturity_reviewer_note',
|
||||||
|
'maturity_mismatch_count',
|
||||||
'published_at',
|
'published_at',
|
||||||
'hash',
|
'hash',
|
||||||
'thumb_ext',
|
'thumb_ext',
|
||||||
@@ -90,7 +114,28 @@ class Artwork extends Model
|
|||||||
'visibility' => 'string',
|
'visibility' => 'string',
|
||||||
'is_approved' => 'boolean',
|
'is_approved' => 'boolean',
|
||||||
'is_mature' => 'boolean',
|
'is_mature' => 'boolean',
|
||||||
|
'maturity_level' => 'string',
|
||||||
|
'maturity_source' => 'string',
|
||||||
|
'maturity_status' => 'string',
|
||||||
|
'maturity_ai_score' => 'float',
|
||||||
|
'maturity_ai_labels' => 'array',
|
||||||
|
'maturity_ai_label' => 'string',
|
||||||
|
'maturity_ai_confidence' => 'float',
|
||||||
|
'maturity_ai_model' => 'string',
|
||||||
|
'maturity_ai_threshold_used' => 'float',
|
||||||
|
'maturity_ai_analysis_time_ms' => 'integer',
|
||||||
|
'maturity_ai_action_hint' => 'string',
|
||||||
|
'maturity_ai_advisory' => 'string',
|
||||||
|
'maturity_ai_status' => 'string',
|
||||||
|
'maturity_ai_detected_at' => 'datetime',
|
||||||
|
'maturity_declared_at' => 'datetime',
|
||||||
|
'maturity_flagged_at' => 'datetime',
|
||||||
|
'maturity_reviewed_at' => 'datetime',
|
||||||
|
'maturity_mismatch_count' => 'integer',
|
||||||
|
'has_missing_thumbnails' => 'boolean',
|
||||||
'published_at' => 'datetime',
|
'published_at' => 'datetime',
|
||||||
|
'missing_thumbnail_variants_json' => 'array',
|
||||||
|
'thumbnails_checked_at' => 'datetime',
|
||||||
'published_as_type' => 'string',
|
'published_as_type' => 'string',
|
||||||
'published_as_id' => 'integer',
|
'published_as_id' => 'integer',
|
||||||
'publish_at' => 'datetime',
|
'publish_at' => 'datetime',
|
||||||
@@ -184,6 +229,11 @@ class Artwork extends Model
|
|||||||
return $this->belongsTo(Group::class);
|
return $this->belongsTo(Group::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function maturityAuditFinding(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(ArtworkMaturityAuditFinding::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function uploadedBy(): BelongsTo
|
public function uploadedBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
||||||
@@ -297,12 +347,31 @@ class Artwork extends Model
|
|||||||
return $this->hasMany(ArtworkAward::class);
|
return $this->hasMany(ArtworkAward::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function medals(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkMedal::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** All file versions for this artwork (oldest first). */
|
/** All file versions for this artwork (oldest first). */
|
||||||
public function versions(): HasMany
|
public function versions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
|
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function outgoingEvolutionRelations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkRelation::class, 'source_artwork_id')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incomingEvolutionRelations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkRelation::class, 'target_artwork_id')
|
||||||
|
->orderByDesc('updated_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
/** The currently active version record. */
|
/** The currently active version record. */
|
||||||
public function currentVersion(): BelongsTo
|
public function currentVersion(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -319,6 +388,11 @@ class Artwork extends Model
|
|||||||
return $this->hasOne(ArtworkAwardStat::class);
|
return $this->hasOne(ArtworkAwardStat::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function medalStats(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(ArtworkMedalStat::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Meilisearch document for this artwork.
|
* Build the Meilisearch document for this artwork.
|
||||||
* Includes all fields required for search, filtering, sorting, and display.
|
* Includes all fields required for search, filtering, sorting, and display.
|
||||||
@@ -385,12 +459,20 @@ class Artwork extends Model
|
|||||||
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
|
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
|
||||||
'is_public' => (bool) $this->is_public,
|
'is_public' => (bool) $this->is_public,
|
||||||
'is_approved' => (bool) $this->is_approved,
|
'is_approved' => (bool) $this->is_approved,
|
||||||
|
'is_mature' => (bool) $this->is_mature,
|
||||||
|
'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'),
|
||||||
|
'maturity_level' => (string) ($this->maturity_level ?? 'safe'),
|
||||||
|
'maturity_status' => (string) ($this->maturity_status ?? 'clear'),
|
||||||
|
'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false),
|
||||||
|
'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0),
|
||||||
// ── Trending / discovery fields ────────────────────────────────────
|
// ── Trending / discovery fields ────────────────────────────────────
|
||||||
'trending_score_1h' => (float) ($this->trending_score_1h ?? 0),
|
'trending_score_1h' => (float) ($this->trending_score_1h ?? 0),
|
||||||
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
|
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
|
||||||
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
|
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
|
||||||
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
||||||
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
||||||
|
'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0),
|
||||||
|
'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0),
|
||||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||||
// ── Ranking V2 fields ───────────────────────────────────────────────
|
// ── Ranking V2 fields ───────────────────────────────────────────────
|
||||||
'ranking_score' => (float) ($stat?->ranking_score ?? 0),
|
'ranking_score' => (float) ($stat?->ranking_score ?? 0),
|
||||||
@@ -404,6 +486,8 @@ class Artwork extends Model
|
|||||||
'silver' => $awardStat?->silver_count ?? 0,
|
'silver' => $awardStat?->silver_count ?? 0,
|
||||||
'bronze' => $awardStat?->bronze_count ?? 0,
|
'bronze' => $awardStat?->bronze_count ?? 0,
|
||||||
'score' => $awardStat?->score_total ?? 0,
|
'score' => $awardStat?->score_total ?? 0,
|
||||||
|
'score_7d' => $awardStat?->score_7d ?? 0,
|
||||||
|
'score_30d' => $awardStat?->score_30d ?? 0,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -432,6 +516,32 @@ class Artwork extends Model
|
|||||||
->where("{$table}.published_at", '<=', now());
|
->where("{$table}.published_at", '<=', now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeSafeForGeneralAudience(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$table = $this->getTable();
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
|
||||||
|
->whereRaw("COALESCE(" . $table . ".maturity_status, 'clear') != ?", ['suspected']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithoutMissingThumbnails(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$table = $this->getTable();
|
||||||
|
|
||||||
|
return $query->where(function (Builder $thumbnailQuery) use ($table): void {
|
||||||
|
$thumbnailQuery->whereNull("{$table}.has_missing_thumbnails")
|
||||||
|
->orWhere("{$table}.has_missing_thumbnails", false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrderMissingThumbnailsLast(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$table = $this->getTable();
|
||||||
|
|
||||||
|
return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC");
|
||||||
|
}
|
||||||
|
|
||||||
public function getRouteKeyName(): string
|
public function getRouteKeyName(): string
|
||||||
{
|
{
|
||||||
return 'slug';
|
return 'slug';
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
class ArtworkAward extends Model
|
class ArtworkAward extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'artwork_awards';
|
protected $table = 'artwork_medals';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'artwork_id',
|
'artwork_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'medal_type',
|
||||||
'medal',
|
'medal',
|
||||||
'weight',
|
'weight',
|
||||||
];
|
];
|
||||||
@@ -27,11 +28,26 @@ class ArtworkAward extends Model
|
|||||||
public const MEDALS = ['gold', 'silver', 'bronze'];
|
public const MEDALS = ['gold', 'silver', 'bronze'];
|
||||||
|
|
||||||
public const WEIGHTS = [
|
public const WEIGHTS = [
|
||||||
'gold' => 3,
|
'gold' => 5,
|
||||||
'silver' => 2,
|
'silver' => 3,
|
||||||
'bronze' => 1,
|
'bronze' => 1,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static function weightFor(string $medal): int
|
||||||
|
{
|
||||||
|
return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public static function weights(): array
|
||||||
|
{
|
||||||
|
return collect(self::MEDALS)
|
||||||
|
->mapWithKeys(fn (string $medal): array => [$medal => self::weightFor($medal)])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public function artwork(): BelongsTo
|
public function artwork(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Artwork::class);
|
return $this->belongsTo(Artwork::class);
|
||||||
@@ -41,4 +57,14 @@ class ArtworkAward extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMedalAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->attributes['medal_type'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMedalAttribute(?string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['medal_type'] = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
class ArtworkAwardStat extends Model
|
class ArtworkAwardStat extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'artwork_award_stats';
|
protected $table = 'artwork_medal_stats';
|
||||||
|
|
||||||
public $primaryKey = 'artwork_id';
|
public $primaryKey = 'artwork_id';
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
public $timestamps = false;
|
public $timestamps = true;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'artwork_id',
|
'artwork_id',
|
||||||
@@ -21,6 +21,10 @@ class ArtworkAwardStat extends Model
|
|||||||
'silver_count',
|
'silver_count',
|
||||||
'bronze_count',
|
'bronze_count',
|
||||||
'score_total',
|
'score_total',
|
||||||
|
'score_7d',
|
||||||
|
'score_30d',
|
||||||
|
'last_medaled_at',
|
||||||
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -30,6 +34,10 @@ class ArtworkAwardStat extends Model
|
|||||||
'silver_count' => 'integer',
|
'silver_count' => 'integer',
|
||||||
'bronze_count' => 'integer',
|
'bronze_count' => 'integer',
|
||||||
'score_total' => 'integer',
|
'score_total' => 'integer',
|
||||||
|
'score_7d' => 'integer',
|
||||||
|
'score_30d' => 'integer',
|
||||||
|
'last_medaled_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,33 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class ArtworkFeature extends Model
|
class ArtworkFeature extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'artwork_features';
|
use SoftDeletes;
|
||||||
|
|
||||||
public $timestamps = false;
|
protected $table = 'artwork_features';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'artwork_id',
|
'artwork_id',
|
||||||
'type',
|
'type',
|
||||||
'featured_at',
|
'featured_at',
|
||||||
|
'expires_at',
|
||||||
|
'priority',
|
||||||
|
'label',
|
||||||
|
'note',
|
||||||
|
'is_active',
|
||||||
|
'force_hero',
|
||||||
|
'created_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'featured_at' => 'datetime',
|
'featured_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'priority' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'force_hero' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function artwork(): BelongsTo
|
public function artwork(): BelongsTo
|
||||||
|
|||||||
61
app/Models/ArtworkMaturityAuditFinding.php
Normal file
61
app/Models/ArtworkMaturityAuditFinding.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class ArtworkMaturityAuditFinding extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const STATUS_OPEN = 'open';
|
||||||
|
public const STATUS_REVIEWED = 'reviewed';
|
||||||
|
public const STATUS_CLEARED = 'cleared';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'artwork_id',
|
||||||
|
'status',
|
||||||
|
'thumbnail_variant',
|
||||||
|
'ai_label',
|
||||||
|
'ai_confidence',
|
||||||
|
'ai_score',
|
||||||
|
'ai_labels',
|
||||||
|
'ai_model',
|
||||||
|
'ai_threshold_used',
|
||||||
|
'ai_analysis_time_ms',
|
||||||
|
'ai_action_hint',
|
||||||
|
'ai_status',
|
||||||
|
'ai_advisory',
|
||||||
|
'detected_at',
|
||||||
|
'last_scanned_at',
|
||||||
|
'resolution_action',
|
||||||
|
'resolution_note',
|
||||||
|
'resolved_by',
|
||||||
|
'resolved_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'ai_confidence' => 'float',
|
||||||
|
'ai_score' => 'float',
|
||||||
|
'ai_labels' => 'array',
|
||||||
|
'ai_threshold_used' => 'float',
|
||||||
|
'ai_analysis_time_ms' => 'integer',
|
||||||
|
'detected_at' => 'datetime',
|
||||||
|
'last_scanned_at' => 'datetime',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolver(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'resolved_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Models/ArtworkMedal.php
Normal file
9
app/Models/ArtworkMedal.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class ArtworkMedal extends ArtworkAward
|
||||||
|
{
|
||||||
|
}
|
||||||
9
app/Models/ArtworkMedalStat.php
Normal file
9
app/Models/ArtworkMedalStat.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class ArtworkMedalStat extends ArtworkAwardStat
|
||||||
|
{
|
||||||
|
}
|
||||||
48
app/Models/ArtworkRelation.php
Normal file
48
app/Models/ArtworkRelation.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class ArtworkRelation extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const TYPE_REMAKE_OF = 'remake_of';
|
||||||
|
public const TYPE_REMASTER_OF = 'remaster_of';
|
||||||
|
public const TYPE_REVISION_OF = 'revision_of';
|
||||||
|
public const TYPE_INSPIRED_BY = 'inspired_by';
|
||||||
|
public const TYPE_VARIATION_OF = 'variation_of';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'source_artwork_id',
|
||||||
|
'target_artwork_id',
|
||||||
|
'relation_type',
|
||||||
|
'note',
|
||||||
|
'sort_order',
|
||||||
|
'created_by_user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sourceArtwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'source_artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetArtwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'target_artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,27 @@ class Category extends Model
|
|||||||
|
|
||||||
protected $casts = ['is_active' => 'boolean'];
|
protected $casts = ['is_active' => 'boolean'];
|
||||||
|
|
||||||
|
public function getNameAttribute(?string $value): ?string
|
||||||
|
{
|
||||||
|
return self::decodeHtmlEntities($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNameAttribute(?string $value): void
|
||||||
|
{
|
||||||
|
$normalized = self::decodeHtmlEntities($value);
|
||||||
|
$this->attributes['name'] = $normalized !== null ? trim($normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescriptionAttribute(?string $value): ?string
|
||||||
|
{
|
||||||
|
return self::decodeHtmlEntities($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescriptionAttribute(?string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['description'] = self::decodeHtmlEntities($value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure slug is always lowercase and valid before saving.
|
* Ensure slug is always lowercase and valid before saving.
|
||||||
*/
|
*/
|
||||||
@@ -159,4 +180,25 @@ class Category extends Model
|
|||||||
|
|
||||||
return $category;
|
return $category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function decodeHtmlEntities(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $value;
|
||||||
|
|
||||||
|
for ($index = 0; $index < 5; $index++) {
|
||||||
|
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
if ($next === $decoded) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,19 +653,26 @@ class Collection extends Model
|
|||||||
return $this->isPubliclyAccessible();
|
return $this->isPubliclyAccessible();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolvedCoverArtwork(bool $publicOnly = false): ?Artwork
|
public function resolvedCoverArtwork(bool $publicOnly = false, bool $hideMature = false): ?Artwork
|
||||||
{
|
{
|
||||||
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
|
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
|
||||||
if ($cover && (! $publicOnly || $this->artworkIsPubliclyVisible($cover))) {
|
if ($cover && $this->artworkMatchesCoverVisibility($cover, $publicOnly, $hideMature)) {
|
||||||
return $cover;
|
return $cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
$relation = $publicOnly ? 'publicArtworks' : 'artworks';
|
$relation = $publicOnly ? 'publicArtworks' : 'artworks';
|
||||||
$artworks = $this->relationLoaded($relation)
|
$artworks = $this->relationLoaded($relation)
|
||||||
? $this->getRelation($relation)
|
? $this->getRelation($relation)
|
||||||
: $this->{$relation}()->limit(1)->get();
|
: $this->{$relation}()
|
||||||
|
->when($hideMature, function ($query): void {
|
||||||
|
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
|
||||||
|
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", ['suspected']);
|
||||||
|
})
|
||||||
|
->limit(1)
|
||||||
|
->get();
|
||||||
|
|
||||||
return $artworks->first();
|
return $artworks
|
||||||
|
->first(fn (Artwork $artwork): bool => $this->artworkMatchesCoverVisibility($artwork, $publicOnly, $hideMature));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncArtworksCount(): void
|
public function syncArtworksCount(): void
|
||||||
@@ -712,4 +719,18 @@ class Collection extends Model
|
|||||||
&& $artwork->published_at !== null
|
&& $artwork->published_at !== null
|
||||||
&& $artwork->published_at->lte(now());
|
&& $artwork->published_at->lte(now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function artworkMatchesCoverVisibility(Artwork $artwork, bool $publicOnly, bool $hideMature): bool
|
||||||
|
{
|
||||||
|
if ($publicOnly && ! $this->artworkIsPubliclyVisible($artwork)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hideMature) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! (bool) $artwork->is_mature
|
||||||
|
&& (string) ($artwork->maturity_status ?? 'clear') !== 'suspected';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use App\Models\Artwork;
|
|||||||
|
|
||||||
class ContentType extends Model
|
class ContentType extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['name','slug','description','order'];
|
protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'order' => 'integer',
|
'order' => 'integer',
|
||||||
|
'hide_from_menu' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
|
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
|
||||||
@@ -21,6 +22,11 @@ class ContentType extends Model
|
|||||||
return $query->orderBy('order')->orderBy('name')->orderBy('id');
|
return $query->orderBy('order')->orderBy('name')->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeVisibleInToolbar(EloquentBuilder $query): EloquentBuilder
|
||||||
|
{
|
||||||
|
return $query->where('hide_from_menu', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function categories(): HasMany
|
public function categories(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Category::class);
|
return $this->hasMany(Category::class);
|
||||||
@@ -31,6 +37,11 @@ class ContentType extends Model
|
|||||||
return $this->categories()->whereNull('parent_id');
|
return $this->categories()->whereNull('parent_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function slugHistories(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ContentTypeSlugHistory::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return an Eloquent builder for Artworks that belong to this content type.
|
* Return an Eloquent builder for Artworks that belong to this content type.
|
||||||
* This traverses the pivot `artwork_category` via the `categories` relation.
|
* This traverses the pivot `artwork_category` via the `categories` relation.
|
||||||
@@ -43,8 +54,33 @@ class ContentType extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMascotUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->resolveAssetUrl($this->mascot_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCoverArtUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->resolveAssetUrl($this->cover_art_path);
|
||||||
|
}
|
||||||
|
|
||||||
public function getRouteKeyName(): string
|
public function getRouteKeyName(): string
|
||||||
{
|
{
|
||||||
return 'slug';
|
return 'slug';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveAssetUrl(?string $path): ?string
|
||||||
|
{
|
||||||
|
$path = trim((string) $path);
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/Models/ContentTypeSlugHistory.php
Normal file
19
app/Models/ContentTypeSlugHistory.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ContentTypeSlugHistory extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'content_type_id',
|
||||||
|
'old_slug',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function contentType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ContentType::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Models/CreatorEra.php
Normal file
45
app/Models/CreatorEra.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property string $era_type
|
||||||
|
* @property string $title
|
||||||
|
* @property string|null $description
|
||||||
|
* @property \Carbon\Carbon $starts_at
|
||||||
|
* @property \Carbon\Carbon|null $ends_at
|
||||||
|
* @property bool $is_current
|
||||||
|
* @property array|null $metadata
|
||||||
|
*/
|
||||||
|
final class CreatorEra extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'era_type',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'starts_at',
|
||||||
|
'ends_at',
|
||||||
|
'is_current',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'ends_at' => 'datetime',
|
||||||
|
'is_current' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/CreatorMilestone.php
Normal file
46
app/Models/CreatorMilestone.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class CreatorMilestone extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'occurred_at',
|
||||||
|
'occurred_year',
|
||||||
|
'related_artwork_id',
|
||||||
|
'is_public',
|
||||||
|
'priority',
|
||||||
|
'payload_json',
|
||||||
|
'computed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'occurred_at' => 'datetime',
|
||||||
|
'occurred_year' => 'integer',
|
||||||
|
'related_artwork_id' => 'integer',
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
'priority' => 'integer',
|
||||||
|
'payload_json' => 'array',
|
||||||
|
'computed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'related_artwork_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Artwork::class);
|
return $this->hasMany(Artwork::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function givenArtworkMedals(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkMedal::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function collections(): HasMany
|
public function collections(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Collection::class)->latest('updated_at');
|
return $this->hasMany(Collection::class)->latest('updated_at');
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class UserProfile extends Model
|
|||||||
'follower_notifications',
|
'follower_notifications',
|
||||||
'comment_notifications',
|
'comment_notifications',
|
||||||
'newsletter',
|
'newsletter',
|
||||||
|
'mature_content_visibility',
|
||||||
|
'mature_content_warning_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -46,6 +48,7 @@ class UserProfile extends Model
|
|||||||
'follower_notifications' => 'boolean',
|
'follower_notifications' => 'boolean',
|
||||||
'comment_notifications' => 'boolean',
|
'comment_notifications' => 'boolean',
|
||||||
'newsletter' => 'boolean',
|
'newsletter' => 'boolean',
|
||||||
|
'mature_content_warning_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public $timestamps = true;
|
public $timestamps = true;
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Jobs\RecalculateArtworkMedalStatsJob;
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Services\ArtworkAwardService;
|
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ArtworkAwardObserver
|
class ArtworkAwardObserver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkAwardService $service,
|
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,12 +35,7 @@ class ArtworkAwardObserver
|
|||||||
|
|
||||||
private function refresh(ArtworkAward $award): void
|
private function refresh(ArtworkAward $award): void
|
||||||
{
|
{
|
||||||
$this->service->recalcStats($award->artwork_id);
|
RecalculateArtworkMedalStatsJob::dispatchSync((int) $award->artwork_id);
|
||||||
|
|
||||||
$artwork = $award->artwork;
|
|
||||||
if ($artwork) {
|
|
||||||
$this->service->syncToSearch($artwork);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function trackCreatorStats(ArtworkAward $award, int $delta): void
|
private function trackCreatorStats(ArtworkAward $award, int $delta): void
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
use App\Models\ArtworkComment;
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use App\Services\UserMentionSyncService;
|
use App\Services\UserMentionSyncService;
|
||||||
use App\Services\XPService;
|
use App\Services\XPService;
|
||||||
@@ -17,7 +19,9 @@ use Illuminate\Support\Facades\DB;
|
|||||||
class ArtworkCommentObserver
|
class ArtworkCommentObserver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly ArtworkStatsService $artworkStats,
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
|
private readonly CreatorJourneyService $journeys,
|
||||||
private readonly UserMentionSyncService $mentionSync,
|
private readonly UserMentionSyncService $mentionSync,
|
||||||
private readonly XPService $xp,
|
private readonly XPService $xp,
|
||||||
) {}
|
) {}
|
||||||
@@ -27,6 +31,7 @@ class ArtworkCommentObserver
|
|||||||
$creatorId = $this->creatorId($comment->artwork_id);
|
$creatorId = $this->creatorId($comment->artwork_id);
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->incrementCommentsReceived($creatorId);
|
$this->userStats->incrementCommentsReceived($creatorId);
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The commenter is "active"
|
// The commenter is "active"
|
||||||
@@ -34,6 +39,7 @@ class ArtworkCommentObserver
|
|||||||
$this->userStats->setLastActiveAt($comment->user_id);
|
$this->userStats->setLastActiveAt($comment->user_id);
|
||||||
$this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork');
|
$this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork');
|
||||||
$this->mentionSync->syncForComment($comment);
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updated(ArtworkComment $comment): void
|
public function updated(ArtworkComment $comment): void
|
||||||
@@ -49,9 +55,11 @@ class ArtworkCommentObserver
|
|||||||
$creatorId = $this->creatorId($comment->artwork_id);
|
$creatorId = $this->creatorId($comment->artwork_id);
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mentionSync->deleteForComment((int) $comment->id);
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
|
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hard delete after soft delete — already decremented; nothing to do. */
|
/** Hard delete after soft delete — already decremented; nothing to do. */
|
||||||
@@ -63,15 +71,23 @@ class ArtworkCommentObserver
|
|||||||
$creatorId = $this->creatorId($comment->artwork_id);
|
$creatorId = $this->creatorId($comment->artwork_id);
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mentionSync->deleteForComment((int) $comment->id);
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
|
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function restored(ArtworkComment $comment): void
|
public function restored(ArtworkComment $comment): void
|
||||||
{
|
{
|
||||||
$this->mentionSync->syncForComment($comment);
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
|
||||||
|
|
||||||
|
$creatorId = $this->creatorId($comment->artwork_id);
|
||||||
|
if ($creatorId) {
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function creatorId(int $artworkId): ?int
|
private function creatorId(int $artworkId): ?int
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Observers;
|
|||||||
use App\Jobs\RecComputeSimilarByBehaviorJob;
|
use App\Jobs\RecComputeSimilarByBehaviorJob;
|
||||||
use App\Jobs\RecComputeSimilarHybridJob;
|
use App\Jobs\RecComputeSimilarHybridJob;
|
||||||
use App\Models\ArtworkFavourite;
|
use App\Models\ArtworkFavourite;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ class ArtworkFavouriteObserver
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
|
private readonly CreatorJourneyService $journeys,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function created(ArtworkFavourite $favourite): void
|
public function created(ArtworkFavourite $favourite): void
|
||||||
@@ -25,6 +27,7 @@ class ArtworkFavouriteObserver
|
|||||||
$creatorId = $this->creatorId($favourite->artwork_id);
|
$creatorId = $this->creatorId($favourite->artwork_id);
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->incrementFavoritesReceived($creatorId);
|
$this->userStats->incrementFavoritesReceived($creatorId);
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// §7.5 On-demand: recompute behavior similarity when artwork reaches threshold
|
// §7.5 On-demand: recompute behavior similarity when artwork reaches threshold
|
||||||
@@ -36,6 +39,7 @@ class ArtworkFavouriteObserver
|
|||||||
$creatorId = $this->creatorId($favourite->artwork_id);
|
$creatorId = $this->creatorId($favourite->artwork_id);
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->decrementFavoritesReceived($creatorId);
|
$this->userStats->decrementFavoritesReceived($creatorId);
|
||||||
|
$this->journeys->requestRebuild($creatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
app/Observers/ArtworkFeatureObserver.php
Normal file
63
app/Observers/ArtworkFeatureObserver.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkFeature;
|
||||||
|
use App\Services\HomepageService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
|
||||||
|
final class ArtworkFeatureObserver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HomepageService $homepage,
|
||||||
|
private readonly CreatorJourneyService $journeys,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function created(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
$this->queueCreatorRebuild($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
$this->queueCreatorRebuild($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
$this->queueCreatorRebuild($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restored(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
$this->queueCreatorRebuild($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDeleted(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
$this->queueCreatorRebuild($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueCreatorRebuild(ArtworkFeature $feature): void
|
||||||
|
{
|
||||||
|
$artwork = $feature->relationLoaded('artwork')
|
||||||
|
? $feature->artwork
|
||||||
|
: Artwork::withTrashed()->find($feature->artwork_id);
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ use App\Jobs\RecComputeSimilarByTagsJob;
|
|||||||
use App\Jobs\RecComputeSimilarHybridJob;
|
use App\Jobs\RecComputeSimilarHybridJob;
|
||||||
use App\Jobs\Posts\AutoUploadPostJob;
|
use App\Jobs\Posts\AutoUploadPostJob;
|
||||||
use App\Services\ArtworkSearchIndexer;
|
use App\Services\ArtworkSearchIndexer;
|
||||||
|
use App\Services\HomepageService;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
use App\Services\XPService;
|
use App\Services\XPService;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs artwork documents to Meilisearch on every relevant model event.
|
* Syncs artwork documents to Meilisearch on every relevant model event.
|
||||||
@@ -25,6 +28,8 @@ class ArtworkObserver
|
|||||||
private readonly ArtworkSearchIndexer $indexer,
|
private readonly ArtworkSearchIndexer $indexer,
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
private readonly XPService $xp,
|
private readonly XPService $xp,
|
||||||
|
private readonly HomepageService $homepage,
|
||||||
|
private readonly CreatorJourneyService $journeys,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** New artwork created — index; bump uploadscount + last_upload_at. */
|
/** New artwork created — index; bump uploadscount + last_upload_at. */
|
||||||
@@ -33,6 +38,11 @@ class ArtworkObserver
|
|||||||
$this->indexer->index($artwork);
|
$this->indexer->index($artwork);
|
||||||
$this->userStats->incrementUploads($artwork->user_id);
|
$this->userStats->incrementUploads($artwork->user_id);
|
||||||
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at);
|
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at);
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
|
||||||
|
if ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null) {
|
||||||
|
$this->bumpExploreCacheVersion();
|
||||||
|
}
|
||||||
|
|
||||||
if ($artwork->published_at !== null) {
|
if ($artwork->published_at !== null) {
|
||||||
$this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id);
|
$this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id);
|
||||||
@@ -75,6 +85,18 @@ class ArtworkObserver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->shouldClearFeaturedCaches($artwork)) {
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at'])) {
|
||||||
|
$this->bumpExploreCacheVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'visibility', 'deleted_at', 'published_as_type', 'published_as_id'])) {
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft delete — remove from search and decrement uploads_count. */
|
/** Soft delete — remove from search and decrement uploads_count. */
|
||||||
@@ -82,12 +104,20 @@ class ArtworkObserver
|
|||||||
{
|
{
|
||||||
$this->indexer->delete($artwork->id);
|
$this->indexer->delete($artwork->id);
|
||||||
$this->userStats->decrementUploads($artwork->user_id);
|
$this->userStats->decrementUploads($artwork->user_id);
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
$this->bumpExploreCacheVersion();
|
||||||
|
|
||||||
|
if ($artwork->features()->exists()) {
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */
|
/** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */
|
||||||
public function forceDeleted(Artwork $artwork): void
|
public function forceDeleted(Artwork $artwork): void
|
||||||
{
|
{
|
||||||
$this->indexer->delete($artwork->id);
|
$this->indexer->delete($artwork->id);
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
$this->bumpExploreCacheVersion();
|
||||||
|
|
||||||
// If deleted_at was null the artwork was not soft-deleted before;
|
// If deleted_at was null the artwork was not soft-deleted before;
|
||||||
// the deleted() event did NOT fire, so we decrement here.
|
// the deleted() event did NOT fire, so we decrement here.
|
||||||
@@ -101,5 +131,25 @@ class ArtworkObserver
|
|||||||
{
|
{
|
||||||
$this->indexer->index($artwork);
|
$this->indexer->index($artwork);
|
||||||
$this->userStats->incrementUploads($artwork->user_id);
|
$this->userStats->incrementUploads($artwork->user_id);
|
||||||
|
$this->journeys->requestRebuild((int) $artwork->user_id);
|
||||||
|
$this->bumpExploreCacheVersion();
|
||||||
|
|
||||||
|
if ($artwork->features()->exists()) {
|
||||||
|
$this->homepage->clearFeaturedAndMedalCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bumpExploreCacheVersion(): void
|
||||||
|
{
|
||||||
|
Cache::forever('explore.cache.version', ((int) Cache::get('explore.cache.version', 1)) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldClearFeaturedCaches(Artwork $artwork): bool
|
||||||
|
{
|
||||||
|
if (! $artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at', 'has_missing_thumbnails'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $artwork->features()->exists();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Observers/ContentTypeObserver.php
Normal file
37
app/Observers/ContentTypeObserver.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use App\Models\ContentTypeSlugHistory;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
|
|
||||||
|
class ContentTypeObserver
|
||||||
|
{
|
||||||
|
public function created(ContentType $contentType): void
|
||||||
|
{
|
||||||
|
app(ContentTypeSlugResolver::class)->flushCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(ContentType $contentType): void
|
||||||
|
{
|
||||||
|
if ($contentType->wasChanged('slug')) {
|
||||||
|
$oldSlug = strtolower(trim((string) $contentType->getOriginal('slug')));
|
||||||
|
$newSlug = strtolower(trim((string) $contentType->slug));
|
||||||
|
|
||||||
|
if ($oldSlug !== '' && $oldSlug !== $newSlug) {
|
||||||
|
ContentTypeSlugHistory::query()->updateOrCreate(
|
||||||
|
['old_slug' => $oldSlug],
|
||||||
|
['content_type_id' => $contentType->id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app(ContentTypeSlugResolver::class)->flushCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(ContentType $contentType): void
|
||||||
|
{
|
||||||
|
app(ContentTypeSlugResolver::class)->flushCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Observers/GroupReleaseContributorObserver.php
Normal file
39
app/Observers/GroupReleaseContributorObserver.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\GroupReleaseContributor;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
|
||||||
|
final class GroupReleaseContributorObserver
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CreatorJourneyService $journeys)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function created(GroupReleaseContributor $contributor): void
|
||||||
|
{
|
||||||
|
$this->journeys->requestRebuild((int) $contributor->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(GroupReleaseContributor $contributor): void
|
||||||
|
{
|
||||||
|
$this->journeys->requestRebuild((int) $contributor->user_id);
|
||||||
|
|
||||||
|
if ($contributor->wasChanged('user_id')) {
|
||||||
|
$this->journeys->requestRebuild((int) $contributor->getOriginal('user_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(GroupReleaseContributor $contributor): void
|
||||||
|
{
|
||||||
|
$this->journeys->requestRebuild((int) $contributor->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDeleted(GroupReleaseContributor $contributor): void
|
||||||
|
{
|
||||||
|
$this->journeys->requestRebuild((int) $contributor->user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Observers/GroupReleaseObserver.php
Normal file
58
app/Observers/GroupReleaseObserver.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\GroupRelease;
|
||||||
|
use App\Services\Profile\CreatorJourneyService;
|
||||||
|
|
||||||
|
final class GroupReleaseObserver
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CreatorJourneyService $journeys)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function created(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
$this->queueAffectedUsers($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
if (! $release->wasChanged(['status', 'visibility', 'released_at', 'published_at', 'deleted_at', 'group_id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->queueAffectedUsers($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
$this->queueAffectedUsers($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restored(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
$this->queueAffectedUsers($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDeleted(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
$this->queueAffectedUsers($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueAffectedUsers(GroupRelease $release): void
|
||||||
|
{
|
||||||
|
$userIds = $release->contributorLinks()
|
||||||
|
->pluck('user_id')
|
||||||
|
->filter()
|
||||||
|
->map(fn ($userId): int => (int) $userId)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
foreach ($userIds as $userId) {
|
||||||
|
$this->journeys->requestRebuild((int) $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -27,14 +28,26 @@ class ArtworkAwardPolicy
|
|||||||
* that isn't their own.
|
* that isn't their own.
|
||||||
* Returns false (→ 403 or 404 based on caller) when the check fails.
|
* Returns false (→ 403 or 404 based on caller) when the check fails.
|
||||||
*/
|
*/
|
||||||
public function award(User $user, Artwork $artwork): bool
|
public function award(User $user, Artwork $artwork): Response
|
||||||
{
|
{
|
||||||
|
if (! config('artwork_medals.enabled', true)) {
|
||||||
|
return Response::deny('Artwork medals are currently disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
if (! $artwork->is_public || ! $artwork->is_approved) {
|
if (! $artwork->is_public || ! $artwork->is_approved) {
|
||||||
return false;
|
return Response::deny('This artwork is not eligible for medals.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($artwork->deleted_at !== null) {
|
||||||
|
return Response::deny('This artwork is no longer available for medals.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($artwork->published_at === null || $artwork->published_at->isFuture()) {
|
||||||
|
return Response::deny('This artwork is not published yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($artwork->user_id === $user->id) {
|
if ($artwork->user_id === $user->id) {
|
||||||
return false;
|
return Response::deny('You cannot medal your own artwork.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->accountIsMature($user);
|
return $this->accountIsMature($user);
|
||||||
@@ -58,12 +71,28 @@ class ArtworkAwardPolicy
|
|||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private function accountIsMature(User $user): bool
|
private function accountIsMature(User $user): Response
|
||||||
{
|
{
|
||||||
if (! $user->created_at) {
|
if ((bool) config('artwork_medals.require_verified_email', true)) {
|
||||||
return true; // cannot verify — allow
|
$isVerified = method_exists($user, 'hasVerifiedEmail')
|
||||||
|
? $user->hasVerifiedEmail()
|
||||||
|
: ! empty($user->email_verified_at);
|
||||||
|
|
||||||
|
if (! $isVerified) {
|
||||||
|
return Response::deny('Verify your email address before giving medals.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->created_at->diffInDays(now()) >= 7;
|
if (! $user->created_at) {
|
||||||
|
return Response::allow(); // cannot verify — allow
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumAgeHours = (int) config('artwork_medals.minimum_account_age_hours', 24);
|
||||||
|
|
||||||
|
if ($user->created_at->diffInHours(now()) < $minimumAgeHours) {
|
||||||
|
return Response::deny("Your account must be at least {$minimumAgeHours} hours old before giving medals.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::allow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,26 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Models\ArtworkComment;
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\ArtworkFeature;
|
||||||
use App\Models\ArtworkFavourite;
|
use App\Models\ArtworkFavourite;
|
||||||
|
use App\Models\ArtworkMedal;
|
||||||
use App\Models\ArtworkReaction;
|
use App\Models\ArtworkReaction;
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use App\Models\GroupRelease;
|
||||||
|
use App\Models\GroupReleaseContributor;
|
||||||
use App\Observers\ArtworkAwardObserver;
|
use App\Observers\ArtworkAwardObserver;
|
||||||
use App\Observers\ArtworkCommentObserver;
|
use App\Observers\ArtworkCommentObserver;
|
||||||
|
use App\Observers\ArtworkFeatureObserver;
|
||||||
use App\Observers\ArtworkFavouriteObserver;
|
use App\Observers\ArtworkFavouriteObserver;
|
||||||
use App\Observers\ArtworkObserver;
|
use App\Observers\ArtworkObserver;
|
||||||
use App\Observers\ArtworkReactionObserver;
|
use App\Observers\ArtworkReactionObserver;
|
||||||
|
use App\Observers\ContentTypeObserver;
|
||||||
|
use App\Observers\GroupReleaseContributorObserver;
|
||||||
|
use App\Observers\GroupReleaseObserver;
|
||||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||||
use App\Services\Upload\UploadDraftService;
|
use App\Services\Upload\UploadDraftService;
|
||||||
|
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -82,6 +93,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// after the folder was renamed from legacy/ to _legacy/.
|
// after the folder was renamed from legacy/ to _legacy/.
|
||||||
View::addNamespace('legacy', resource_path('views/_legacy'));
|
View::addNamespace('legacy', resource_path('views/_legacy'));
|
||||||
|
|
||||||
|
$exceptionRendererComponentsPath = base_path('vendor/laravel/framework/src/Illuminate/Foundation/resources/exceptions/renderer/components');
|
||||||
|
|
||||||
|
if (is_dir($exceptionRendererComponentsPath)) {
|
||||||
|
Blade::anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer');
|
||||||
|
}
|
||||||
|
|
||||||
$this->configureAuthRateLimiters();
|
$this->configureAuthRateLimiters();
|
||||||
$this->configureUploadRateLimiters();
|
$this->configureUploadRateLimiters();
|
||||||
$this->configureMessagingRateLimiters();
|
$this->configureMessagingRateLimiters();
|
||||||
@@ -94,10 +111,15 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->configureMailFailureLogging();
|
$this->configureMailFailureLogging();
|
||||||
|
|
||||||
ArtworkAward::observe(ArtworkAwardObserver::class);
|
ArtworkAward::observe(ArtworkAwardObserver::class);
|
||||||
|
ArtworkMedal::observe(ArtworkAwardObserver::class);
|
||||||
Artwork::observe(ArtworkObserver::class);
|
Artwork::observe(ArtworkObserver::class);
|
||||||
|
ArtworkFeature::observe(ArtworkFeatureObserver::class);
|
||||||
ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
|
ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
|
||||||
ArtworkComment::observe(ArtworkCommentObserver::class);
|
ArtworkComment::observe(ArtworkCommentObserver::class);
|
||||||
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
||||||
|
ContentType::observe(ContentTypeObserver::class);
|
||||||
|
GroupRelease::observe(GroupReleaseObserver::class);
|
||||||
|
GroupReleaseContributor::observe(GroupReleaseContributorObserver::class);
|
||||||
|
|
||||||
// ── OAuth / SocialiteProviders ──────────────────────────────────────
|
// ── OAuth / SocialiteProviders ──────────────────────────────────────
|
||||||
Event::listen(
|
Event::listen(
|
||||||
@@ -134,6 +156,17 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$avatarHash = null;
|
$avatarHash = null;
|
||||||
$displayName = null;
|
$displayName = null;
|
||||||
$userId = null;
|
$userId = null;
|
||||||
|
$toolbarContentTypes = collect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$toolbarContentTypes = $this->app
|
||||||
|
->make(ContentTypeSlugResolver::class)
|
||||||
|
->toolbarContentTypes()
|
||||||
|
->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug']))
|
||||||
|
->map(fn (array $attributes) => new ContentType($attributes));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$toolbarContentTypes = collect();
|
||||||
|
}
|
||||||
|
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
@@ -188,7 +221,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
|
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName'));
|
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace the framework HandleCors with our ConditionalCors so the
|
// Replace the framework HandleCors with our ConditionalCors so the
|
||||||
@@ -384,13 +417,14 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('artwork-awards', function (Request $request): array {
|
RateLimiter::for('artwork-awards', function (Request $request): array {
|
||||||
$userId = $request->user()?->id;
|
$userId = $request->user()?->id;
|
||||||
$artworkId = (int) $request->route('id');
|
$artworkId = (int) $request->route('id');
|
||||||
|
$perMinute = max(1, (int) config('artwork_medals.rate_limit_per_minute', 10));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Prevent burst spam on a single artwork while allowing normal exploration.
|
// Prevent burst spam on a single artwork while allowing normal exploration.
|
||||||
Limit::perMinute(20)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId),
|
Limit::perMinute($perMinute)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId),
|
||||||
// Global safety net for user/IP across all artworks.
|
// Global safety net for user/IP across all artworks.
|
||||||
Limit::perMinute(120)->by('awards:user:' . ($userId ?? 'guest')),
|
Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')),
|
||||||
Limit::perMinute(180)->by('awards:ip:' . $request->ip()),
|
Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -472,6 +506,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
private function registerCpadMenuItems(): void
|
private function registerCpadMenuItems(): void
|
||||||
{
|
{
|
||||||
|
if (! $this->isControlPanelRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! class_exists(Menu::class)) {
|
if (! class_exists(Menu::class)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -483,4 +521,25 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Control panel menu registration should never block the app boot.
|
// Control panel menu registration should never block the app boot.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isControlPanelRequest(): bool
|
||||||
|
{
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->app->bound('request')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = trim((string) config('cpad.webroot', config('cp.webroot', '/cp')), '/');
|
||||||
|
|
||||||
|
if ($prefix === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->app->make('request');
|
||||||
|
|
||||||
|
return $request->is($prefix) || $request->is($prefix . '/*');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,45 +4,24 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Jobs\IndexArtworkJob;
|
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkAward;
|
use App\Models\ArtworkAward;
|
||||||
use App\Models\ArtworkAwardStat;
|
use App\Models\ArtworkAwardStat;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class ArtworkAwardService
|
class ArtworkAwardService
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ArtworkMedalService $medals)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Award an artwork with the given medal.
|
* Award an artwork with the given medal.
|
||||||
* Throws ValidationException if the user already awarded this artwork.
|
* Throws ValidationException if the user already awarded this artwork.
|
||||||
*/
|
*/
|
||||||
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
|
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||||
{
|
{
|
||||||
$this->validateMedal($medal);
|
return $this->medals->award($artwork, $user, $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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,21 +29,7 @@ class ArtworkAwardService
|
|||||||
*/
|
*/
|
||||||
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
|
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||||
{
|
{
|
||||||
$this->validateMedal($medal);
|
return $this->medals->changeMedal($artwork, $user, $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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,17 +38,7 @@ class ArtworkAwardService
|
|||||||
*/
|
*/
|
||||||
public function removeAward(Artwork $artwork, User $user): void
|
public function removeAward(Artwork $artwork, User $user): void
|
||||||
{
|
{
|
||||||
$award = ArtworkAward::where('artwork_id', $artwork->id)
|
$this->medals->removeMedal($artwork, $user);
|
||||||
->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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,32 +46,7 @@ class ArtworkAwardService
|
|||||||
*/
|
*/
|
||||||
public function recalcStats(int $artworkId): ArtworkAwardStat
|
public function recalcStats(int $artworkId): ArtworkAwardStat
|
||||||
{
|
{
|
||||||
$counts = DB::table('artwork_awards')
|
return $this->medals->recalculateStats($artworkId);
|
||||||
->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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,15 +54,6 @@ class ArtworkAwardService
|
|||||||
*/
|
*/
|
||||||
public function syncToSearch(Artwork $artwork): void
|
public function syncToSearch(Artwork $artwork): void
|
||||||
{
|
{
|
||||||
IndexArtworkJob::dispatch($artwork->id);
|
$this->medals->syncArtworkToSearch((int) $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.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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\Artwork;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
* 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 BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
|
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(
|
public function __construct(
|
||||||
private readonly AdaptiveTimeWindow $timeWindow,
|
private readonly AdaptiveTimeWindow $timeWindow,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,11 +81,38 @@ final class ArtworkSearchService
|
|||||||
$options['sort'] = $sort;
|
$options['sort'] = $sort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$options = $this->viewerAwareOptions($options);
|
||||||
|
|
||||||
return Artwork::search($q ?: '')
|
return Artwork::search($q ?: '')
|
||||||
->options($options)
|
->options($options)
|
||||||
->paginate($perPage);
|
->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.
|
* 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';
|
$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) {
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||||
$query = Artwork::query()
|
$query = Artwork::query()
|
||||||
->public()
|
->public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
||||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->select('artworks.*')
|
->select('artworks.*')
|
||||||
@@ -132,14 +165,14 @@ final class ArtworkSearchService
|
|||||||
*/
|
*/
|
||||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
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 Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
|
||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||||
'sort' => ['created_at:desc'],
|
'sort' => ['created_at:desc'],
|
||||||
])
|
]))
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -181,14 +214,14 @@ final class ArtworkSearchService
|
|||||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
$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 Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
|
||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
||||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||||
])
|
]))
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -204,14 +237,14 @@ final class ArtworkSearchService
|
|||||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
$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 Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
|
||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
||||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||||
])
|
]))
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -230,7 +263,7 @@ final class ArtworkSearchService
|
|||||||
return $this->popular($limit);
|
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) {
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
|
||||||
$tagFilters = implode(' OR ', array_map(
|
$tagFilters = implode(' OR ', array_map(
|
||||||
@@ -239,10 +272,10 @@ final class ArtworkSearchService
|
|||||||
));
|
));
|
||||||
|
|
||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
|
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
|
||||||
'sort' => ['views:desc', 'likes:desc'],
|
'sort' => ['views:desc', 'likes:desc'],
|
||||||
])
|
]))
|
||||||
->paginate($limit);
|
->paginate($limit);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -252,12 +285,12 @@ final class ArtworkSearchService
|
|||||||
*/
|
*/
|
||||||
public function popular(int $perPage = 24): LengthAwarePaginator
|
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('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['views:desc', 'likes:desc'],
|
'sort' => ['views:desc', 'likes:desc'],
|
||||||
])
|
]))
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,12 +300,12 @@ final class ArtworkSearchService
|
|||||||
*/
|
*/
|
||||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
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('')
|
return Artwork::search('')
|
||||||
->options([
|
->options($this->viewerAwareOptions([
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['published_at_ts:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
]))
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -291,15 +324,13 @@ final class ArtworkSearchService
|
|||||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
$cutoff = now()->subDays($windowDays)->toDateString();
|
||||||
// Include window in cache key so adaptive expansions surface immediately
|
// 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 Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||||||
])
|
], $perPage);
|
||||||
->paginate($perPage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,15 +345,13 @@ final class ArtworkSearchService
|
|||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
$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 Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
|
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
|
||||||
])
|
], $perPage);
|
||||||
->paginate($perPage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,13 +361,11 @@ final class ArtworkSearchService
|
|||||||
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
|
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['published_at_ts:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
], $perPage);
|
||||||
->paginate($perPage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,13 +375,11 @@ final class ArtworkSearchService
|
|||||||
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
|
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['likes:desc', 'views:desc'],
|
'sort' => ['likes:desc', 'views:desc'],
|
||||||
])
|
], $perPage);
|
||||||
->paginate($perPage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,13 +389,11 @@ final class ArtworkSearchService
|
|||||||
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
|
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['downloads:desc', 'views:desc'],
|
'sort' => ['downloads:desc', 'views:desc'],
|
||||||
])
|
], $perPage);
|
||||||
->paginate($perPage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,18 +414,28 @@ final class ArtworkSearchService
|
|||||||
array_slice($tagSlugs, 0, 5)
|
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 Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
||||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||||
])
|
], $limit, true, 1);
|
||||||
->paginate($limit);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Fresh artworks in given categories, sorted by publish timestamp desc.
|
||||||
* Used for personalized "Fresh in your favourite categories" section.
|
* Used for personalized "Fresh in your favourite categories" section.
|
||||||
@@ -420,15 +453,13 @@ final class ArtworkSearchService
|
|||||||
array_slice($categorySlugs, 0, 3)
|
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 Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
|
||||||
return Artwork::search('')
|
return $this->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||||
'sort' => ['published_at_ts:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
], $limit, true, 1);
|
||||||
->paginate($limit);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +475,52 @@ final class ArtworkSearchService
|
|||||||
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
|
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
|
private function emptyPaginator(int $perPage): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace App\Services;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
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\LengthAwarePaginator;
|
||||||
use Illuminate\Contracts\Pagination\CursorPaginator;
|
use Illuminate\Contracts\Pagination\CursorPaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
@@ -11,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ArtworkService
|
* ArtworkService
|
||||||
@@ -23,6 +27,30 @@ class ArtworkService
|
|||||||
{
|
{
|
||||||
protected int $cacheTtl = 3600; // seconds
|
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.
|
* Lightweight relations needed to render browse/list cards.
|
||||||
*
|
*
|
||||||
@@ -32,7 +60,7 @@ class ArtworkService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_url',
|
'user.profile:user_id,avatar_hash',
|
||||||
'group:id,name,slug,avatar_path',
|
'group:id,name,slug,avatar_path',
|
||||||
'categories' => function ($q) {
|
'categories' => function ($q) {
|
||||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$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()
|
$query = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
->with($this->browseRelations());
|
->with($this->browseRelations());
|
||||||
|
|
||||||
$normalizedSort = strtolower(trim($sort));
|
$normalizedSort = strtolower(trim($sort));
|
||||||
@@ -122,6 +151,7 @@ class ArtworkService
|
|||||||
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
||||||
{
|
{
|
||||||
$query = Artwork::public()->published()
|
$query = Artwork::public()->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
->with($this->browseRelations())
|
->with($this->browseRelations())
|
||||||
->whereHas('categories', function ($q) use ($category) {
|
->whereHas('categories', function ($q) use ($category) {
|
||||||
$q->where('categories.id', $category->id);
|
$q->where('categories.id', $category->id);
|
||||||
@@ -141,6 +171,7 @@ class ArtworkService
|
|||||||
public function getLatestArtworks(int $limit = 10): Collection
|
public function getLatestArtworks(int $limit = 10): Collection
|
||||||
{
|
{
|
||||||
return Artwork::public()->published()
|
return Artwork::public()->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
@@ -165,13 +196,7 @@ class ArtworkService
|
|||||||
*/
|
*/
|
||||||
public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator
|
public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator
|
||||||
{
|
{
|
||||||
$contentType = ContentType::where('slug', strtolower($slug))->first();
|
$contentType = $this->resolveContentTypeOrFail($slug);
|
||||||
|
|
||||||
if (! $contentType) {
|
|
||||||
$e = new ModelNotFoundException();
|
|
||||||
$e->setModel(ContentType::class, [$slug]);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $this->browseQuery($sort)
|
$query = $this->browseQuery($sort)
|
||||||
->whereHas('categories', function ($q) use ($contentType) {
|
->whereHas('categories', function ($q) use ($contentType) {
|
||||||
@@ -198,12 +223,7 @@ class ArtworkService
|
|||||||
$parts = array_values(array_map('strtolower', $slugs));
|
$parts = array_values(array_map('strtolower', $slugs));
|
||||||
$contentTypeSlug = array_shift($parts);
|
$contentTypeSlug = array_shift($parts);
|
||||||
|
|
||||||
$contentType = ContentType::where('slug', $contentTypeSlug)->first();
|
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
|
||||||
if (! $contentType) {
|
|
||||||
$e = new ModelNotFoundException();
|
|
||||||
$e->setModel(ContentType::class, [$contentTypeSlug]);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($parts)) {
|
if (empty($parts)) {
|
||||||
$e = new ModelNotFoundException();
|
$e = new ModelNotFoundException();
|
||||||
@@ -274,30 +294,102 @@ class ArtworkService
|
|||||||
return $allIds;
|
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.
|
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
|
||||||
* Uses artwork_features table and applies public/approved/published filters.
|
* 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.*')
|
->select('artworks.*')
|
||||||
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||||
->public()
|
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
|
||||||
->published()
|
->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) {
|
->when($type !== null, function ($q) use ($type) {
|
||||||
$q->where('af.type', $type);
|
$q->where('af.type', $type);
|
||||||
})
|
});
|
||||||
->with([
|
}
|
||||||
'user:id,name,username',
|
|
||||||
'categories' => function ($q) {
|
private function applyFeaturedEligibilityFilters(Builder $query): void
|
||||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
{
|
||||||
},
|
$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('af.featured_at')
|
||||||
->orderByDesc('artworks.published_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
|
* @param int $perPage
|
||||||
* @return CursorPaginator
|
* @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)
|
$query = Artwork::where('user_id', $userId)
|
||||||
->with([
|
->with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
|
'user.profile:user_id,avatar_hash',
|
||||||
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($q) {
|
'categories' => function ($q) {
|
||||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$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) {
|
if (! $isOwner) {
|
||||||
// Apply public visibility constraints for non-owners
|
// Apply public visibility constraints for non-owners
|
||||||
$query->public()->published();
|
$query->public()->published();
|
||||||
|
$this->maturity->applyViewerFilter($query, $viewer);
|
||||||
} else {
|
} else {
|
||||||
// Owner: include all non-deleted items (do not force published/approved)
|
// Owner: include all non-deleted items (do not force published/approved)
|
||||||
$query->whereNull('deleted_at');
|
$query->whereNull('deleted_at');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Services\UserStatsService;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +92,55 @@ class ArtworkStatsService
|
|||||||
$this->incrementDownloads((int) $artwork->id, $by, $defer);
|
$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.
|
* Apply a set of deltas to the artwork_stats row inside a transaction.
|
||||||
* After updating artwork-level stats, forwards view/download counts to
|
* 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\Collection;
|
||||||
use App\Models\Group;
|
use App\Models\Group;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
@@ -33,6 +34,7 @@ class CollectionService
|
|||||||
private readonly SmartCollectionService $smartCollections,
|
private readonly SmartCollectionService $smartCollections,
|
||||||
private readonly CollectionCollaborationService $collaborators,
|
private readonly CollectionCollaborationService $collaborators,
|
||||||
private readonly GroupMembershipService $groupMembers,
|
private readonly GroupMembershipService $groupMembers,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,12 +494,14 @@ class CollectionService
|
|||||||
return $query->get();
|
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()) {
|
if ($collection->isSmart()) {
|
||||||
return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage);
|
return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$viewer ??= $ownerView ? null : request()->user();
|
||||||
|
|
||||||
$query = $collection->artworks()
|
$query = $collection->artworks()
|
||||||
->with([
|
->with([
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
@@ -515,12 +519,21 @@ class CollectionService
|
|||||||
->where('artworks.is_approved', true)
|
->where('artworks.is_approved', true)
|
||||||
->whereNotNull('artworks.published_at')
|
->whereNotNull('artworks.published_at')
|
||||||
->where('artworks.published_at', '<=', now());
|
->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) {
|
$query = match ($collection->sort_mode) {
|
||||||
Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'),
|
Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'),
|
||||||
Collection::SORT_OLDEST => $query->orderBy('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'),
|
default => $query->orderByPivot('order_num'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -843,15 +856,18 @@ class CollectionService
|
|||||||
|
|
||||||
public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array
|
public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array
|
||||||
{
|
{
|
||||||
|
$viewer ??= $ownerView ? null : request()->user();
|
||||||
$collectionList = $collections instanceof EloquentCollection
|
$collectionList = $collections instanceof EloquentCollection
|
||||||
? $collections
|
? $collections
|
||||||
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
|
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
|
||||||
|
|
||||||
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||||
|
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
|
||||||
|
|
||||||
$firstArtworkMap = $this->firstArtworkMapForCollections(
|
$firstArtworkMap = $this->firstArtworkMapForCollections(
|
||||||
$collectionIds,
|
$collectionIds,
|
||||||
! $ownerView
|
! $ownerView,
|
||||||
|
$hideMatureCovers,
|
||||||
);
|
);
|
||||||
|
|
||||||
$savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== []
|
$savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== []
|
||||||
@@ -866,9 +882,11 @@ class CollectionService
|
|||||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
|
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
|
||||||
$resolvedCover = $collection->isSmart()
|
$resolvedCover = $collection->isSmart()
|
||||||
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
||||||
: $collection->resolvedCoverArtwork(! $ownerView);
|
: $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
|
||||||
$fallbackCover = $firstArtworkMap->get((int) $collection->id);
|
$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;
|
$summary = $collection->summary ?? $collection->description;
|
||||||
$isSaved = in_array((int) $collection->id, $savedCollectionIds, true);
|
$isSaved = in_array((int) $collection->id, $savedCollectionIds, true);
|
||||||
$canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer);
|
$canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer);
|
||||||
@@ -958,6 +976,7 @@ class CollectionService
|
|||||||
'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(),
|
'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(),
|
||||||
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
|
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
|
||||||
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
|
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
|
||||||
|
'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null,
|
||||||
'cover_artwork_id' => $cover?->id,
|
'cover_artwork_id' => $cover?->id,
|
||||||
'saved' => $isSaved,
|
'saved' => $isSaved,
|
||||||
'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null,
|
'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null,
|
||||||
@@ -976,11 +995,18 @@ class CollectionService
|
|||||||
})->all();
|
})->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()
|
$cover = $collection->isSmart()
|
||||||
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
? $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 [
|
return [
|
||||||
'id' => $collection->id,
|
'id' => $collection->id,
|
||||||
@@ -1074,7 +1100,8 @@ class CollectionService
|
|||||||
'expired_at' => optional($collection->expired_at)?->toISOString(),
|
'expired_at' => optional($collection->expired_at)?->toISOString(),
|
||||||
'history_count' => (int) $collection->history_count,
|
'history_count' => (int) $collection->history_count,
|
||||||
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
|
'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,
|
'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),
|
'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,
|
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
|
||||||
@@ -1194,7 +1221,7 @@ class CollectionService
|
|||||||
* @param array<int, int> $collectionIds
|
* @param array<int, int> $collectionIds
|
||||||
* @return SupportCollection<int, Artwork>
|
* @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 === []) {
|
if ($collectionIds === []) {
|
||||||
return collect();
|
return collect();
|
||||||
@@ -1210,6 +1237,10 @@ class CollectionService
|
|||||||
->whereNotNull('a.published_at')
|
->whereNotNull('a.published_at')
|
||||||
->where('a.published_at', '<=', now());
|
->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.collection_id')
|
||||||
->orderBy('ca.order_num')
|
->orderBy('ca.order_num')
|
||||||
->select(['ca.collection_id', 'a.id'])
|
->select(['ca.collection_id', 'a.id'])
|
||||||
@@ -1237,7 +1268,7 @@ class CollectionService
|
|||||||
$contentType = $category?->contentType;
|
$contentType = $category?->contentType;
|
||||||
$stats = $artwork->stats;
|
$stats = $artwork->stats;
|
||||||
|
|
||||||
return array_merge([
|
return $this->maturity->decoratePayload(array_merge([
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'title' => $artwork->title,
|
'title' => $artwork->title,
|
||||||
'slug' => $artwork->slug,
|
'slug' => $artwork->slug,
|
||||||
@@ -1261,7 +1292,7 @@ class CollectionService
|
|||||||
'username' => $artwork->user->username,
|
'username' => $artwork->user->username,
|
||||||
'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]),
|
'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]),
|
||||||
] : null,
|
] : null,
|
||||||
], $extra);
|
], $extra), $artwork, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeLayoutModules(?array $modules, string $type, bool $allowComments, bool $allowSubmissions, bool $includePresentation = true): array
|
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');
|
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
|
private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null, ?Group $group = null): bool
|
||||||
{
|
{
|
||||||
return Collection::query()
|
return Collection::query()
|
||||||
|
|||||||
@@ -206,12 +206,18 @@ class ContentSanitizer
|
|||||||
*/
|
*/
|
||||||
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
|
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
|
||||||
{
|
{
|
||||||
|
$encodedHtml = mb_encode_numericentity(
|
||||||
|
$html,
|
||||||
|
[0x80, 0x10FFFF, 0, 0xFFFFFF],
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
|
|
||||||
// Parse with DOMDocument
|
// Parse with DOMDocument
|
||||||
$doc = new \DOMDocument('1.0', 'UTF-8');
|
$doc = new \DOMDocument('1.0', 'UTF-8');
|
||||||
// Suppress warnings from malformed fragments
|
// Suppress warnings from malformed fragments
|
||||||
libxml_use_internal_errors(true);
|
libxml_use_internal_errors(true);
|
||||||
$doc->loadHTML(
|
$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_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||||
);
|
);
|
||||||
libxml_clear_errors();
|
libxml_clear_errors();
|
||||||
@@ -226,7 +232,7 @@ class ContentSanitizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fix self-closing <a></a> etc.
|
// 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()
|
return Artwork::query()
|
||||||
->public()
|
->public()
|
||||||
->published()
|
->published()
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with([
|
->with([
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'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'),
|
'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]),
|
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||||
'thumb' => $md['url'] ?? null,
|
'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\Collection;
|
||||||
use App\Models\Group;
|
use App\Models\Group;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
@@ -32,6 +33,7 @@ class GroupService
|
|||||||
private readonly GroupActivityService $activity,
|
private readonly GroupActivityService $activity,
|
||||||
private readonly GroupHistoryService $history,
|
private readonly GroupHistoryService $history,
|
||||||
private readonly GroupReputationService $reputation,
|
private readonly GroupReputationService $reputation,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +374,8 @@ class GroupService
|
|||||||
->where('is_approved', true)
|
->where('is_approved', true)
|
||||||
->whereNotNull('published_at');
|
->whereNotNull('published_at');
|
||||||
|
|
||||||
|
$this->maturity->applyViewerFilter($query, request()->user());
|
||||||
|
|
||||||
if ((int) ($group->featured_artwork_id ?? 0) > 0) {
|
if ((int) ($group->featured_artwork_id ?? 0) > 0) {
|
||||||
$featuredArtwork = (clone $query)
|
$featuredArtwork = (clone $query)
|
||||||
->where('id', (int) $group->featured_artwork_id)
|
->where('id', (int) $group->featured_artwork_id)
|
||||||
@@ -461,14 +465,18 @@ class GroupService
|
|||||||
|
|
||||||
public function publicArtworkCards(Group $group, int $limit = 18): array
|
public function publicArtworkCards(Group $group, int $limit = 18): array
|
||||||
{
|
{
|
||||||
return Artwork::query()
|
$query = Artwork::query()
|
||||||
->with(['user.profile', 'group', 'primaryAuthor.profile'])
|
->with(['user.profile', 'group', 'primaryAuthor.profile'])
|
||||||
->where('group_id', $group->id)
|
->where('group_id', $group->id)
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where('is_public', true)
|
->where('is_public', true)
|
||||||
->where('is_approved', true)
|
->where('is_approved', true)
|
||||||
->whereNotNull('published_at')
|
->whereNotNull('published_at')
|
||||||
->latest('published_at')
|
->latest('published_at');
|
||||||
|
|
||||||
|
$this->maturity->applyViewerFilter($query, request()->user());
|
||||||
|
|
||||||
|
return $query
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
|
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
|
||||||
@@ -493,7 +501,7 @@ class GroupService
|
|||||||
|
|
||||||
private function mapPublicArtworkCard(Artwork $artwork): array
|
private function mapPublicArtworkCard(Artwork $artwork): array
|
||||||
{
|
{
|
||||||
return [
|
return $this->maturity->decoratePayload([
|
||||||
'id' => (int) $artwork->id,
|
'id' => (int) $artwork->id,
|
||||||
'title' => (string) $artwork->title,
|
'title' => (string) $artwork->title,
|
||||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
|
'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),
|
'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork),
|
||||||
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
|
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
|
||||||
'published_at' => $artwork->published_at?->toISOString(),
|
'published_at' => $artwork->published_at?->toISOString(),
|
||||||
];
|
], $artwork, request()->user());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function studioDashboardSummary(Group $group): array
|
public function studioDashboardSummary(Group $group): array
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\Recommendations\RecommendationFeedResolver;
|
|||||||
use App\Services\UserPreferenceService;
|
use App\Services\UserPreferenceService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use App\Models\Collection as CollectionModel;
|
use App\Models\Collection as CollectionModel;
|
||||||
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use cPad\Plugins\News\Models\NewsArticle;
|
use cPad\Plugins\News\Models\NewsArticle;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HomepageService
|
* HomepageService
|
||||||
@@ -32,9 +34,11 @@ use cPad\Plugins\News\Models\NewsArticle;
|
|||||||
final class HomepageService
|
final class HomepageService
|
||||||
{
|
{
|
||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
|
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
|
||||||
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'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:id,name,slug,content_type_id,sort_order',
|
||||||
'categories.contentType:id,name,slug',
|
'categories.contentType:id,name,slug',
|
||||||
];
|
];
|
||||||
@@ -42,6 +46,7 @@ final class HomepageService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkService $artworks,
|
private readonly ArtworkService $artworks,
|
||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
|
private readonly ArtworkMaturityService $maturity,
|
||||||
private readonly UserPreferenceService $prefs,
|
private readonly UserPreferenceService $prefs,
|
||||||
private readonly RecommendationFeedResolver $feedResolver,
|
private readonly RecommendationFeedResolver $feedResolver,
|
||||||
private readonly GridFiller $gridFiller,
|
private readonly GridFiller $gridFiller,
|
||||||
@@ -60,9 +65,60 @@ final class HomepageService
|
|||||||
* Return all homepage section data as a single array ready to JSON-encode.
|
* Return all homepage section data as a single array ready to JSON-encode.
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
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 [
|
return [
|
||||||
'hero' => $this->getHeroArtwork(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
|
'community_favorites' => $this->getCommunityFavorites(),
|
||||||
|
'hall_of_fame' => $this->getHallOfFame(),
|
||||||
'rising' => $this->getRising(),
|
'rising' => $this->getRising(),
|
||||||
'trending' => $this->getTrending(),
|
'trending' => $this->getTrending(),
|
||||||
'fresh' => $this->getFreshUploads(),
|
'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.
|
* Personalized homepage data for an authenticated user.
|
||||||
*
|
*
|
||||||
@@ -97,6 +168,8 @@ final class HomepageService
|
|||||||
'is_logged_in' => true,
|
'is_logged_in' => true,
|
||||||
'user_data' => $this->getUserData($user),
|
'user_data' => $this->getUserData($user),
|
||||||
'hero' => $this->getHeroArtwork(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
|
'community_favorites' => $this->getCommunityFavorites(),
|
||||||
|
'hall_of_fame' => $this->getHallOfFame(),
|
||||||
'for_you' => $this->getForYouPreview($user),
|
'for_you' => $this->getForYouPreview($user),
|
||||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||||
'rising' => $this->getRising(),
|
'rising' => $this->getRising(),
|
||||||
@@ -127,13 +200,15 @@ final class HomepageService
|
|||||||
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
||||||
{
|
{
|
||||||
try {
|
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'] ?? '');
|
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
|
||||||
$discoveryEndpoint = route('api.discovery.events.store');
|
$discoveryEndpoint = route('api.discovery.events.store');
|
||||||
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
|
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
|
||||||
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
|
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
|
||||||
|
|
||||||
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
|
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');
|
$reason = (string) ($item['reason'] ?? 'Picked for you');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -146,6 +221,8 @@ final class HomepageService
|
|||||||
'author_username' => (string) ($item['username'] ?? ''),
|
'author_username' => (string) ($item['username'] ?? ''),
|
||||||
'author_avatar' => $item['avatar_url'] ?? null,
|
'author_avatar' => $item['avatar_url'] ?? null,
|
||||||
'avatar_url' => $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' => $item['thumbnail_url'] ?? null,
|
||||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||||
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
|
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
|
||||||
@@ -276,18 +353,18 @@ final class HomepageService
|
|||||||
*/
|
*/
|
||||||
public function getHeroArtwork(): ?array
|
public function getHeroArtwork(): ?array
|
||||||
{
|
{
|
||||||
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
|
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
|
||||||
$result = $this->artworks->getFeaturedArtworks(null, 1);
|
$artwork = $this->artworks->getFeaturedArtworkWinner();
|
||||||
|
|
||||||
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
|
if (! $artwork instanceof Artwork) {
|
||||||
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
$artwork = Artwork::query()
|
||||||
$artwork = $result->getCollection()->first();
|
->public()
|
||||||
} elseif ($result instanceof \Illuminate\Support\Collection) {
|
->published()
|
||||||
$artwork = $result->first();
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
} elseif (is_array($result)) {
|
->withoutMissingThumbnails()
|
||||||
$artwork = $result[0] ?? null;
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
} else {
|
->latest('published_at')
|
||||||
$artwork = null;
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($artwork instanceof Artwork) {
|
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).
|
* 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();
|
$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 {
|
try {
|
||||||
$results = Artwork::search('')
|
$results = $this->search->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||||
])
|
], $limit, true, 1);
|
||||||
->paginate($limit, 'page', 1);
|
|
||||||
|
|
||||||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||||||
|
|
||||||
@@ -327,7 +466,7 @@ final class HomepageService
|
|||||||
return $this->getRisingLowSignalFromDb($limit);
|
return $this->getRisingLowSignalFromDb($limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return $this->fillArtworkRailFromArchive($items, $limit)
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
@@ -346,8 +485,10 @@ final class HomepageService
|
|||||||
*/
|
*/
|
||||||
private function getRisingFromDb(int $limit): array
|
private function getRisingFromDb(int $limit): array
|
||||||
{
|
{
|
||||||
return Artwork::public()
|
$artworks = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->select('artworks.*')
|
->select('artworks.*')
|
||||||
@@ -355,7 +496,9 @@ final class HomepageService
|
|||||||
->orderByDesc('artwork_stats.heat_score')
|
->orderByDesc('artwork_stats.heat_score')
|
||||||
->orderByDesc('artwork_stats.engagement_velocity')
|
->orderByDesc('artwork_stats.engagement_velocity')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get()
|
->get();
|
||||||
|
|
||||||
|
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
@@ -363,8 +506,10 @@ final class HomepageService
|
|||||||
|
|
||||||
private function getRisingLowSignalFromDb(int $limit): array
|
private function getRisingLowSignalFromDb(int $limit): array
|
||||||
{
|
{
|
||||||
return Artwork::public()
|
$artworks = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
||||||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||||||
@@ -375,7 +520,9 @@ final class HomepageService
|
|||||||
->orderByDesc('artworks.published_at')
|
->orderByDesc('artworks.published_at')
|
||||||
->orderByDesc('artworks.id')
|
->orderByDesc('artworks.id')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get()
|
->get();
|
||||||
|
|
||||||
|
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
@@ -392,14 +539,12 @@ final class HomepageService
|
|||||||
{
|
{
|
||||||
$cutoff = now()->subDays(30)->toDateString();
|
$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 {
|
try {
|
||||||
$results = Artwork::search('')
|
$results = $this->search->searchWithThumbnailPreference([
|
||||||
->options([
|
|
||||||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||||||
])
|
], $limit, true, 1);
|
||||||
->paginate($limit, 'page', 1);
|
|
||||||
|
|
||||||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||||||
|
|
||||||
@@ -407,7 +552,7 @@ final class HomepageService
|
|||||||
return $this->getTrendingFromDb($limit);
|
return $this->getTrendingFromDb($limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return $this->fillArtworkRailFromArchive($items, $limit)
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
@@ -427,8 +572,10 @@ final class HomepageService
|
|||||||
*/
|
*/
|
||||||
private function getTrendingFromDb(int $limit): array
|
private function getTrendingFromDb(int $limit): array
|
||||||
{
|
{
|
||||||
return Artwork::public()
|
$artworks = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->select('artworks.*')
|
->select('artworks.*')
|
||||||
@@ -436,7 +583,9 @@ final class HomepageService
|
|||||||
->orderByDesc('artwork_stats.ranking_score')
|
->orderByDesc('artwork_stats.ranking_score')
|
||||||
->orderByDesc('artwork_stats.engagement_velocity')
|
->orderByDesc('artwork_stats.engagement_velocity')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get()
|
->get();
|
||||||
|
|
||||||
|
return $this->fillArtworkRailFromArchive($artworks, $limit)
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
@@ -450,11 +599,13 @@ final class HomepageService
|
|||||||
{
|
{
|
||||||
// Include EGS mode in cache key so toggling EGS updates the section within TTL
|
// Include EGS mode in cache key so toggling EGS updates the section within TTL
|
||||||
$egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std';
|
$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 {
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
|
||||||
$artworks = Artwork::public()
|
$artworks = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
@@ -541,6 +692,7 @@ final class HomepageService
|
|||||||
|
|
||||||
$latestArtworkIds = Artwork::public()
|
$latestArtworkIds = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->whereIn('user_id', $userIds)
|
->whereIn('user_id', $userIds)
|
||||||
->whereNotNull('hash')
|
->whereNotNull('hash')
|
||||||
->whereNotNull('thumb_ext')
|
->whereNotNull('thumb_ext')
|
||||||
@@ -698,7 +850,7 @@ final class HomepageService
|
|||||||
'u.username',
|
'u.username',
|
||||||
'up.avatar_hash',
|
'up.avatar_hash',
|
||||||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
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)
|
->where('u.id', '!=', $user->id)
|
||||||
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
|
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
|
||||||
@@ -738,11 +890,13 @@ final class HomepageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Cache::remember(
|
return Cache::remember(
|
||||||
"homepage.following.{$user->id}",
|
"homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
|
||||||
60, // short TTL – personal data
|
60, // short TTL – personal data
|
||||||
function () use ($followingIds): array {
|
function () use ($followingIds): array {
|
||||||
$artworks = Artwork::public()
|
$artworks = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
|
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||||
|
->withoutMissingThumbnails()
|
||||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||||
->whereIn('user_id', $followingIds)
|
->whereIn('user_id', $followingIds)
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
@@ -766,7 +920,13 @@ final class HomepageService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
$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
|
return $items
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
@@ -790,7 +950,13 @@ final class HomepageService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
$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
|
return $items
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
@@ -839,9 +1005,89 @@ final class HomepageService
|
|||||||
|
|
||||||
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
||||||
|
|
||||||
|
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;
|
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
|
private function collectionHasNoRisingMomentum(Collection $items): bool
|
||||||
{
|
{
|
||||||
if ($items->isEmpty()) {
|
if ($items->isEmpty()) {
|
||||||
@@ -875,18 +1121,32 @@ final class HomepageService
|
|||||||
|
|
||||||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||||||
{
|
{
|
||||||
|
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
|
||||||
|
$thumbSm = $artwork->thumbUrl('sm');
|
||||||
$thumbMd = $artwork->thumbUrl('md');
|
$thumbMd = $artwork->thumbUrl('md');
|
||||||
$thumbLg = $artwork->thumbUrl('lg');
|
$thumbLg = $artwork->thumbUrl('lg');
|
||||||
|
$thumbXl = $artwork->thumbUrl('xl');
|
||||||
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
|
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
|
||||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||||
|
|
||||||
$authorId = $artwork->user_id;
|
$thumbSrcset = collect([
|
||||||
$authorName = $artwork->user?->name ?? 'Artist';
|
$thumbSm ? $thumbSm . ' 320w' : null,
|
||||||
$authorUsername = $artwork->user?->username ?? '';
|
$thumbMd ? $thumbMd . ' 640w' : null,
|
||||||
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
$thumbLg ? $thumbLg . ' 1280w' : null,
|
||||||
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
|
$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,
|
'id' => $artwork->id,
|
||||||
'title' => $artwork->title ?? 'Untitled',
|
'title' => $artwork->title ?? 'Untitled',
|
||||||
'slug' => $artwork->slug,
|
'slug' => $artwork->slug,
|
||||||
@@ -894,9 +1154,14 @@ final class HomepageService
|
|||||||
'author_id' => $authorId,
|
'author_id' => $authorId,
|
||||||
'author_username' => $authorUsername,
|
'author_username' => $authorUsername,
|
||||||
'author_avatar' => $authorAvatar,
|
'author_avatar' => $authorAvatar,
|
||||||
|
'published_as_type' => $artwork->publishedAsType(),
|
||||||
|
'publisher' => $publisher,
|
||||||
'thumb' => $thumb,
|
'thumb' => $thumb,
|
||||||
|
'thumb_sm' => $thumbSm,
|
||||||
'thumb_md' => $thumbMd,
|
'thumb_md' => $thumbMd,
|
||||||
'thumb_lg' => $thumbLg,
|
'thumb_lg' => $thumbLg,
|
||||||
|
'thumb_xl' => $thumbXl,
|
||||||
|
'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
|
||||||
'category_name' => $primaryCategory->name ?? '',
|
'category_name' => $primaryCategory->name ?? '',
|
||||||
'category_slug' => $primaryCategory->slug ?? '',
|
'category_slug' => $primaryCategory->slug ?? '',
|
||||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||||
@@ -905,6 +1170,65 @@ final class HomepageService
|
|||||||
'width' => $artwork->width,
|
'width' => $artwork->width,
|
||||||
'height' => $artwork->height,
|
'height' => $artwork->height,
|
||||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user