minor fixes
This commit is contained in:
@@ -26,6 +26,7 @@ class ConfigureMeilisearchIndex extends Command
|
||||
*/
|
||||
private const SORTABLE_ATTRIBUTES = [
|
||||
'created_at',
|
||||
'published_at_ts',
|
||||
'trending_score_24h',
|
||||
'trending_score_7d',
|
||||
'favorites_count',
|
||||
|
||||
419
app/Console/Commands/HealthCheckCommand.php
Normal file
419
app/Console/Commands/HealthCheckCommand.php
Normal file
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Comprehensive service health check.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan health:check # all checks
|
||||
* php artisan health:check --only=meili # single service
|
||||
* php artisan health:check --json # machine-readable JSON output
|
||||
*/
|
||||
class HealthCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'health:check
|
||||
{--only= : Run only a named check (mysql|redis|cache|meilisearch|reverb|vision|horizon|app)}
|
||||
{--json : Output results as JSON}';
|
||||
|
||||
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Reverb, Vision, Horizon, App).';
|
||||
|
||||
/** Collected results: [name => [status, message, details]] */
|
||||
private array $results = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$only = $this->option('only') ? strtolower((string) $this->option('only')) : null;
|
||||
|
||||
$checks = [
|
||||
'mysql' => fn () => $this->checkMysql(),
|
||||
'redis' => fn () => $this->checkRedis(),
|
||||
'cache' => fn () => $this->checkCache(),
|
||||
'meilisearch' => fn () => $this->checkMeilisearch(),
|
||||
'reverb' => fn () => $this->checkReverb(),
|
||||
'vision' => fn () => $this->checkVision(),
|
||||
'horizon' => fn () => $this->checkHorizon(),
|
||||
'app' => fn () => $this->checkApp(),
|
||||
];
|
||||
|
||||
if ($only !== null) {
|
||||
if (! array_key_exists($only, $checks)) {
|
||||
$this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks)));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$checks = [$only => $checks[$only]];
|
||||
}
|
||||
|
||||
foreach ($checks as $name => $check) {
|
||||
$check();
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($this->results, JSON_PRETTY_PRINT));
|
||||
return $this->hasFailures() ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->renderTable();
|
||||
|
||||
$failed = $this->countByStatus('fail');
|
||||
$warned = $this->countByStatus('warn');
|
||||
|
||||
$this->newLine();
|
||||
if ($failed > 0) {
|
||||
$this->error("❌ {$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($warned > 0) {
|
||||
$this->warn("⚠️ All checks passed with {$warned} warning(s).");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->info('✅ All checks passed.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Individual checks ──────────────────────────────────────────────────────
|
||||
|
||||
private function checkMysql(): void
|
||||
{
|
||||
try {
|
||||
DB::select('SELECT 1');
|
||||
$db = config('database.connections.' . config('database.default') . '.database');
|
||||
$artworkCount = DB::table('artworks')->whereNull('deleted_at')->count();
|
||||
$this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]);
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('mysql', 'Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRedis(): void
|
||||
{
|
||||
try {
|
||||
$pong = Redis::ping();
|
||||
// ping returns "+PONG\r\n" string or true depending on driver
|
||||
$ok = $pong === true || str_contains((string) $pong, 'PONG');
|
||||
if ($ok) {
|
||||
$info = Redis::info('server');
|
||||
$version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown');
|
||||
$this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]);
|
||||
} else {
|
||||
$this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('redis', 'Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkCache(): void
|
||||
{
|
||||
try {
|
||||
$key = '_healthcheck_' . uniqid('', true);
|
||||
$value = 'ok_' . time();
|
||||
Cache::put($key, $value, 10);
|
||||
$got = Cache::get($key);
|
||||
Cache::forget($key);
|
||||
|
||||
$driver = config('cache.default');
|
||||
if ($got === $value) {
|
||||
$this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]);
|
||||
} else {
|
||||
$this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('cache', 'Cache test failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMeilisearch(): void
|
||||
{
|
||||
try {
|
||||
/** @var MeilisearchClient $client */
|
||||
$client = app(MeilisearchClient::class);
|
||||
$health = $client->health();
|
||||
|
||||
if (($health['status'] ?? '') !== 'available') {
|
||||
$this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health));
|
||||
return;
|
||||
}
|
||||
|
||||
$version = $client->version()['pkgVersion'] ?? 'unknown';
|
||||
$indexName = (new Artwork())->searchableAs();
|
||||
$index = $client->index($indexName);
|
||||
$stats = $index->stats();
|
||||
$docCount = (int) ($stats['numberOfDocuments'] ?? 0);
|
||||
$isIndexing = (bool) ($stats['isIndexing'] ?? false);
|
||||
|
||||
// Expected: ≥ 50% of DB artworks should be indexed
|
||||
$dbCount = DB::table('artworks')
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', 1)
|
||||
->where('is_approved', 1)
|
||||
->count();
|
||||
|
||||
$status = 'pass';
|
||||
$message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount}).";
|
||||
|
||||
if ($isIndexing) {
|
||||
$message .= ' [currently re-indexing]';
|
||||
}
|
||||
|
||||
if ($docCount === 0) {
|
||||
$status = 'fail';
|
||||
$message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\"";
|
||||
} elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) {
|
||||
$status = 'warn';
|
||||
$message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild";
|
||||
}
|
||||
|
||||
// Check pending Meilisearch tasks
|
||||
try {
|
||||
$tasks = $client->getTasks(['statuses' => 'enqueued,processing']);
|
||||
$pendingCount = $tasks->getTotal();
|
||||
if ($pendingCount > 0) {
|
||||
$message .= " ({$pendingCount} tasks still pending)";
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
$this->result('meilisearch', $status, $message, [
|
||||
'index' => $indexName,
|
||||
'indexed_docs' => $docCount,
|
||||
'db_eligible' => $dbCount,
|
||||
'is_indexing' => $isIndexing,
|
||||
'meili_version' => $version,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkReverb(): void
|
||||
{
|
||||
$host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', '');
|
||||
$port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443));
|
||||
$scheme = config('reverb.servers.reverb.options.scheme') ?? env('REVERB_SCHEME', 'https');
|
||||
|
||||
if (empty($host)) {
|
||||
$this->warn_check('reverb', 'REVERB_HOST not configured — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reverb exposes an HTTP health endpoint at /apps/{appId}
|
||||
// We do a plain TCP connect as the minimal check; a refused connection means down.
|
||||
$timeout = 5;
|
||||
try {
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$proto = $scheme === 'https' ? 'ssl' : 'tcp';
|
||||
$fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout);
|
||||
|
||||
if ($fp === false) {
|
||||
$this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]");
|
||||
return;
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
// Try the HTTP health probe (Reverb answers 200 on /)
|
||||
$url = "{$scheme}://{$host}:{$port}/";
|
||||
$response = $this->httpGet($url, 3);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
// Reverb returns 200 or 400 on the root — both mean it's alive
|
||||
if ($code >= 200 && $code < 500) {
|
||||
$this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]);
|
||||
} else {
|
||||
$this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('reverb', 'Check failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkVision(): void
|
||||
{
|
||||
$services = [
|
||||
'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health',
|
||||
'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health',
|
||||
];
|
||||
|
||||
$allPassed = true;
|
||||
$messages = [];
|
||||
|
||||
foreach ($services as $label => $url) {
|
||||
if ($url === '/health' || $url === '') {
|
||||
$messages[] = "{$label}: not configured";
|
||||
continue;
|
||||
}
|
||||
|
||||
$response = $this->httpGet($url, 5);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
if ($code >= 200 && $code < 300) {
|
||||
$messages[] = "{$label}: OK (HTTP {$code})";
|
||||
} elseif ($code === 0) {
|
||||
$allPassed = false;
|
||||
$messages[] = "{$label}: UNREACHABLE ({$url})";
|
||||
} else {
|
||||
$allPassed = false;
|
||||
$messages[] = "{$label}: HTTP {$code} ({$url})";
|
||||
}
|
||||
}
|
||||
|
||||
$summary = implode(' | ', $messages);
|
||||
|
||||
if ($allPassed) {
|
||||
$this->pass('vision', $summary);
|
||||
} else {
|
||||
$this->warn_check('vision', $summary);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHorizon(): void
|
||||
{
|
||||
try {
|
||||
// Horizon stores its status in Redis under the horizon:master-supervisor key prefix.
|
||||
// A simpler cross-version check: look for any horizon-related Redis key.
|
||||
$status = Cache::store('redis')->get('horizon:status');
|
||||
|
||||
if ($status === null) {
|
||||
// Try reading directly from Redis
|
||||
$status = Redis::get('horizon:status');
|
||||
}
|
||||
|
||||
if ($status === null) {
|
||||
$this->warn_check('horizon', 'No Horizon status key in Redis — Horizon may not be running or has never started.');
|
||||
return;
|
||||
}
|
||||
|
||||
$status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status);
|
||||
|
||||
if ($status === 'running') {
|
||||
$this->pass('horizon', "Horizon status: running.");
|
||||
} elseif ($status === 'paused') {
|
||||
$this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue");
|
||||
} else {
|
||||
$this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkApp(): void
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url', ''), '/');
|
||||
|
||||
if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) {
|
||||
$this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the app homepage
|
||||
$response = $this->httpGet($appUrl . '/', 10);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
if ($code === 200) {
|
||||
$ttfb = $response['ttfb'] ?? 0;
|
||||
$this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]);
|
||||
} elseif ($code > 0) {
|
||||
$this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]);
|
||||
} else {
|
||||
$this->failCheck('app', "Homepage unreachable. URL: {$appUrl}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function httpGet(string $url, int $timeout = 5): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ttfb = (int) round((microtime(true) - $start) * 1000);
|
||||
return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb];
|
||||
} catch (Throwable) {
|
||||
return ['code' => 0, 'body' => '', 'ttfb' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
private function pass(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'pass', $message, $details);
|
||||
}
|
||||
|
||||
private function failCheck(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'fail', $message, $details);
|
||||
}
|
||||
|
||||
private function warn_check(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'warn', $message, $details);
|
||||
}
|
||||
|
||||
private function result(string $name, string $status, string $message, array $details = []): void
|
||||
{
|
||||
$this->results[$name] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private function renderTable(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->line(' <fg=white;options=bold>SERVICE STATUS MESSAGE</>');
|
||||
$this->line(' ' . str_repeat('─', 90));
|
||||
|
||||
foreach ($this->results as $name => $r) {
|
||||
[$icon, $color] = match ($r['status']) {
|
||||
'pass' => ['✅', 'green'],
|
||||
'warn' => ['⚠️ ', 'yellow'],
|
||||
default => ['❌', 'red'],
|
||||
};
|
||||
|
||||
$label = str_pad(strtoupper($name), 15);
|
||||
$status = str_pad(strtoupper($r['status']), 7);
|
||||
$message = $r['message'];
|
||||
|
||||
$this->line(" {$icon} <fg={$color}>{$label} {$status}</> {$message}");
|
||||
}
|
||||
|
||||
$this->line(' ' . str_repeat('─', 90));
|
||||
}
|
||||
|
||||
private function hasFailures(): bool
|
||||
{
|
||||
return $this->countByStatus('fail') > 0;
|
||||
}
|
||||
|
||||
private function countByStatus(string $status): int
|
||||
{
|
||||
return count(array_filter($this->results, fn ($r) => $r['status'] === $status));
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,14 @@ use Throwable;
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
||||
* php artisan skinbase:import-legacy-artworks --artwork-id=69527
|
||||
*/
|
||||
class ImportLegacyArtworks extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-artworks
|
||||
{--chunk=500 : chunk size for processing}
|
||||
{--limit= : maximum number of legacy rows to import}
|
||||
{--artwork-id= : import only one legacy wallz row by id}
|
||||
{--dry-run : do not persist any changes}
|
||||
{--legacy-connection=legacy : name of legacy DB connection}
|
||||
{--legacy-table=wallz : legacy artworks table name}
|
||||
@@ -73,15 +75,28 @@ class ImportLegacyArtworks extends Command
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
||||
$artworkId = $this->option('artwork-id') ? (int) $this->option('artwork-id') : null;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
$connectedTable = $this->option('connected-table');
|
||||
|
||||
if ($artworkId !== null && $artworkId <= 0) {
|
||||
$this->error('The --artwork-id option must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$this->info("Scoping import to legacy artwork id={$artworkId}");
|
||||
$query->where('id', $artworkId);
|
||||
$limit = 1;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
||||
@@ -277,8 +292,14 @@ class ImportLegacyArtworks extends Command
|
||||
return null;
|
||||
}, 'id');
|
||||
|
||||
if ($artworkId !== null && $processed === 0) {
|
||||
$this->warn("Legacy artwork id={$artworkId} was not found in {$legacyConn}.{$legacyTable}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Import complete. Processed: ' . $processed);
|
||||
|
||||
return 0;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,22 @@ use Illuminate\Support\Facades\Log;
|
||||
* Runs every 10–15 minutes via scheduler.
|
||||
*
|
||||
* Formula:
|
||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12
|
||||
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12) / window_hours)
|
||||
*
|
||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||
*
|
||||
* heat_score = raw_heat * age_factor
|
||||
*
|
||||
* Usage: php artisan nova:recalculate-heat
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --lookback-hours=24 --dry-run
|
||||
*/
|
||||
class RecalculateHeatCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-heat
|
||||
{--days=60 : Only process artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--lookback-hours=24 : Smooth heat deltas over this many trailing hours}
|
||||
{--dry-run : Compute scores without writing to DB}';
|
||||
|
||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||
@@ -44,31 +45,34 @@ class RecalculateHeatCommand extends Command
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$now = now();
|
||||
$currentHour = $now->copy()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
$lookbackStart = $currentHour->copy()->subHours($lookbackHours);
|
||||
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
|
||||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||
->distinct()
|
||||
->pluck('artwork_id');
|
||||
|
||||
if ($artworkIds->isEmpty()) {
|
||||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||
$this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Load all snapshots for the two hours in bulk
|
||||
// Load all snapshots for the lookback window in bulk
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->orderBy('bucket_hour')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
@@ -101,27 +105,57 @@ class RecalculateHeatCommand extends Command
|
||||
}
|
||||
|
||||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
if (! $currentSnapshot) {
|
||||
$currentSnapshot = $artworkSnapshots->last();
|
||||
}
|
||||
|
||||
// If we only have one snapshot, use it as current with zero deltas
|
||||
if (!$currentSnapshot && !$prevSnapshot) {
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
$baselineSnapshot = $artworkSnapshots
|
||||
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
|
||||
->first();
|
||||
|
||||
if (! $currentSnapshot) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate deltas
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
// One-hour counters remain explicit fields for dashboards and debugging.
|
||||
$viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
|
||||
// Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising.
|
||||
// A single snapshot without an earlier baseline should not count as new momentum.
|
||||
if ($baselineSnapshot) {
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0));
|
||||
|
||||
$windowHours = max(
|
||||
1.0,
|
||||
abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour)))
|
||||
);
|
||||
} else {
|
||||
$viewsDelta = 0;
|
||||
$downloadsDelta = 0;
|
||||
$favouritesDelta = 0;
|
||||
$commentsDelta = 0;
|
||||
$sharesDelta = 0;
|
||||
$windowHours = 1.0;
|
||||
}
|
||||
|
||||
// Raw heat
|
||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||
$rawHeat = (
|
||||
($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares'])
|
||||
) / $windowHours;
|
||||
|
||||
// Age factor: favors newer works
|
||||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||
@@ -134,11 +168,11 @@ class RecalculateHeatCommand extends Command
|
||||
'artwork_id' => $artworkId,
|
||||
'heat_score' => round($heatScore, 4),
|
||||
'heat_score_updated_at' => $now,
|
||||
'views_1h' => $viewsDelta,
|
||||
'downloads_1h' => $downloadsDelta,
|
||||
'favourites_1h' => $favouritesDelta,
|
||||
'comments_1h' => $commentsDelta,
|
||||
'shares_1h' => $sharesDelta,
|
||||
'views_1h' => $viewsDelta1h,
|
||||
'downloads_1h' => $downloadsDelta1h,
|
||||
'favourites_1h' => $favouritesDelta1h,
|
||||
'comments_1h' => $commentsDelta1h,
|
||||
'shares_1h' => $sharesDelta1h,
|
||||
];
|
||||
|
||||
$updatedCount++;
|
||||
|
||||
@@ -13,7 +13,7 @@ use Carbon\Carbon;
|
||||
|
||||
class RepairLegacyWallzUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||
protected $signature = 'legacySB:repair-legacy-wallz-users
|
||||
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy table to update}
|
||||
|
||||
@@ -64,6 +64,18 @@ class DashboardGalleryController extends Controller
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
@@ -74,13 +86,18 @@ class DashboardGalleryController extends Controller
|
||||
'category_slug' => $primary?->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'avatar_url' => AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
),
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* DiscoverFeedController
|
||||
@@ -22,7 +25,10 @@ use Illuminate\Support\Facades\Cache;
|
||||
*/
|
||||
final class DiscoverFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
public function __construct(
|
||||
private readonly RSSFeedBuilder $builder,
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
) {}
|
||||
|
||||
/** /rss/discover → redirect to fresh */
|
||||
public function index(): Response
|
||||
@@ -77,15 +83,19 @@ final class DiscoverFeedController extends Controller
|
||||
public function rising(): Response
|
||||
{
|
||||
$feedUrl = url('/rss/discover/rising');
|
||||
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.heat_score')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
$artworks = Cache::remember(
|
||||
"rss:discover:rising.{$windowDays}d",
|
||||
600,
|
||||
function () use ($windowDays) {
|
||||
$artworks = $this->risingArtworks($windowDays);
|
||||
|
||||
if ($this->collectionHasNoRisingMomentum($artworks)) {
|
||||
return $this->risingLowSignalArtworks($windowDays);
|
||||
}
|
||||
|
||||
return $artworks;
|
||||
}
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
@@ -95,4 +105,76 @@ final class DiscoverFeedController extends Controller
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
|
||||
private function risingArtworks(int $windowDays): Collection
|
||||
{
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
|
||||
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
|
||||
->where('artworks.published_at', '>=', $cutoff)
|
||||
->orderByDesc('artwork_stats.heat_score')
|
||||
->orderByDesc('artwork_stats.engagement_velocity')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function risingLowSignalArtworks(int $windowDays): Collection
|
||||
{
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
||||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||||
})
|
||||
->select('artworks.*')
|
||||
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
|
||||
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
|
||||
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
|
||||
->where('artworks.published_at', '>=', $cutoff)
|
||||
->orderByDesc('recent_signal_24h')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function collectionHasNoRisingMomentum(Collection $artworks): bool
|
||||
{
|
||||
if ($artworks->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $artworks->every(function (Artwork $artwork): bool {
|
||||
return (float) ($artwork->heat_score ?? 0) <= 0
|
||||
&& (float) ($artwork->engagement_velocity ?? 0) <= 0;
|
||||
});
|
||||
}
|
||||
|
||||
private function risingRecentActivitySubquery()
|
||||
{
|
||||
$since = now()->startOfHour()->subHours(24);
|
||||
|
||||
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
|
||||
->selectRaw('rising_snapshots.artwork_id')
|
||||
->selectRaw('(
|
||||
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
|
||||
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
|
||||
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
|
||||
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
|
||||
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
|
||||
) as recent_signal_24h')
|
||||
->where('rising_snapshots.bucket_hour', '>=', $since)
|
||||
->groupBy('rising_snapshots.artwork_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,11 +280,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
@@ -295,9 +302,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
'category_slug' => $primaryCategory->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Models\Artwork;
|
||||
use App\Services\CommunityActivityService;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\EarlyGrowth\FeedBlender;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use App\Services\UserSuggestionService;
|
||||
@@ -33,8 +33,8 @@ final class DiscoverController extends Controller
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkSearchService $searchService,
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
private readonly RecommendationFeedResolver $feedResolver,
|
||||
private readonly FeedBlender $feedBlender,
|
||||
private readonly GridFiller $gridFiller,
|
||||
private readonly CommunityActivityService $communityActivity,
|
||||
private readonly UserSuggestionService $userSuggestions,
|
||||
@@ -45,9 +45,18 @@ final class DiscoverController extends Controller
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverTrending($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
|
||||
try {
|
||||
$results = $this->searchService->discoverTrending($perPage);
|
||||
} catch (\Throwable) {
|
||||
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
|
||||
}
|
||||
|
||||
if ($this->paginatorIsEmpty($results)) {
|
||||
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
|
||||
}
|
||||
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -64,9 +73,22 @@ final class DiscoverController extends Controller
|
||||
public function rising(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverRising($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
|
||||
try {
|
||||
$results = $this->searchService->discoverRising($perPage);
|
||||
} catch (\Throwable) {
|
||||
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
|
||||
}
|
||||
|
||||
if ($this->paginatorIsEmpty($results)) {
|
||||
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
|
||||
}
|
||||
|
||||
if ($this->paginatorHasNoRisingMomentum($results)) {
|
||||
$results = $this->fallbackRisingLowSignalFromDatabase($perPage, $windowDays);
|
||||
}
|
||||
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -83,11 +105,12 @@ final class DiscoverController extends Controller
|
||||
public function fresh(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverFresh($perPage);
|
||||
// EGS: blend fresh feed with curated + spotlight on page 1
|
||||
$results = $this->feedBlender->blend($results, $perPage, $page);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
|
||||
if ($this->paginatorIsEmpty($results)) {
|
||||
$results = $this->fallbackFreshFromDatabase($perPage);
|
||||
}
|
||||
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -351,6 +374,152 @@ final class DiscoverController extends Controller
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function paginatorIsEmpty($paginator): bool
|
||||
{
|
||||
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$items = $paginator->getCollection();
|
||||
|
||||
return ! $items || $items->isEmpty();
|
||||
}
|
||||
|
||||
private function paginatorHasNoRisingMomentum($paginator): bool
|
||||
{
|
||||
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$items = $paginator->getCollection();
|
||||
|
||||
if (! $items || $items->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $items->every(function ($item): bool {
|
||||
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
|
||||
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
|
||||
|
||||
return $heat <= 0.0 && $velocity <= 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
private function fallbackFreshFromDatabase(int $perPage)
|
||||
{
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
private function fallbackTrendingFromDatabase(int $perPage, int $windowDays)
|
||||
{
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', $cutoff)
|
||||
->orderByDesc('discover_stats.ranking_score')
|
||||
->orderByDesc('discover_stats.engagement_velocity')
|
||||
->orderByDesc('discover_stats.views')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
private function fallbackRisingFromDatabase(int $perPage, int $windowDays)
|
||||
{
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
|
||||
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
||||
->where('artworks.published_at', '>=', $cutoff)
|
||||
->orderByDesc('discover_stats.heat_score')
|
||||
->orderByDesc('discover_stats.engagement_velocity')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
private function fallbackRisingLowSignalFromDatabase(int $perPage, int $windowDays)
|
||||
{
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
$recentActivity = $this->risingRecentActivitySubquery();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($recentActivity, 'recent_rising_activity', function ($join): void {
|
||||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||||
})
|
||||
->select('artworks.*')
|
||||
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
|
||||
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
|
||||
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
|
||||
->where('artworks.published_at', '>=', $cutoff)
|
||||
->orderByDesc('recent_signal_24h')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
private function risingRecentActivitySubquery()
|
||||
{
|
||||
$since = now()->startOfHour()->subHours(24);
|
||||
|
||||
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
|
||||
->selectRaw('rising_snapshots.artwork_id')
|
||||
->selectRaw('(
|
||||
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
|
||||
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
|
||||
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
|
||||
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
|
||||
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
|
||||
) as recent_signal_24h')
|
||||
->where('rising_snapshots.bucket_hour', '>=', $since)
|
||||
->groupBy('rising_snapshots.artwork_id');
|
||||
}
|
||||
|
||||
private function hydrateDiscoverSearchResults($paginator): void
|
||||
{
|
||||
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
|
||||
@@ -377,6 +546,7 @@ final class DiscoverController extends Controller
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
])
|
||||
->get()
|
||||
@@ -398,9 +568,12 @@ final class DiscoverController extends Controller
|
||||
'category_slug' => $item->category_slug ?? '',
|
||||
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
|
||||
'thumb_srcset' => $item->thumb_srcset ?? null,
|
||||
'uname' => $item->author ?? $item->uname ?? 'Skinbase',
|
||||
'username' => $item->username ?? '',
|
||||
'uname' => $item->author_name ?? $item->author ?? $item->uname ?? 'Skinbase',
|
||||
'username' => (($item->published_as_type ?? null) === 'group') ? '' : ($item->username ?? ''),
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64),
|
||||
'profile_url' => $item->profile_url ?? null,
|
||||
'published_as_type' => $item->published_as_type ?? null,
|
||||
'publisher' => $item->publisher ?? null,
|
||||
'published_at' => $item->published_at ?? null,
|
||||
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
||||
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
|
||||
@@ -413,11 +586,18 @@ final class DiscoverController extends Controller
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
@@ -429,8 +609,18 @@ final class DiscoverController extends Controller
|
||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
|
||||
@@ -276,11 +276,18 @@ final class ExploreController extends Controller
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
@@ -291,9 +298,18 @@ final class ExploreController extends Controller
|
||||
'category_slug' => $primary->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
|
||||
@@ -290,11 +290,18 @@ final class SimilarArtworksPageController extends Controller
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
: AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
@@ -305,9 +312,18 @@ final class SimilarArtworksPageController extends Controller
|
||||
'category_slug' => $primary?->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,7 +17,6 @@ final class TagController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GridFiller $gridFiller,
|
||||
private readonly TagDiscoveryService $tagDiscovery,
|
||||
) {}
|
||||
|
||||
@@ -45,29 +43,10 @@ final class TagController extends Controller
|
||||
|
||||
public function show(Tag $tag, Request $request): View
|
||||
{
|
||||
$sort = $request->query('sort', 'popular'); // popular | latest | downloads
|
||||
$sort = $request->query('sort', 'popular'); // popular | likes | latest | downloads
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
// Convert sort param to Meili sort expression
|
||||
$sortMap = [
|
||||
'popular' => 'views:desc',
|
||||
'likes' => 'likes:desc',
|
||||
'latest' => 'created_at:desc',
|
||||
'downloads' => 'downloads:desc',
|
||||
];
|
||||
$meiliSort = $sortMap[$sort] ?? 'views:desc';
|
||||
|
||||
$artworks = \App\Models\Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND tags = "' . addslashes($tag->slug) . '"',
|
||||
'sort' => [$meiliSort],
|
||||
])
|
||||
->paginate($perPage)
|
||||
->appends(['sort' => $sort]);
|
||||
|
||||
// EGS: ensure tag pages never show a half-empty grid on page 1
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$artworks = $this->search->byTag($tag->slug, $perPage, $sort);
|
||||
|
||||
// Eager-load relations used by the gallery presenter and thumbnails.
|
||||
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||
|
||||
@@ -7,10 +7,24 @@ namespace App\Http\Requests\Uploads;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadTokenService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class UploadChunkRequest extends FormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$uploadError = $this->detectChunkUploadError();
|
||||
|
||||
if ($uploadError !== null && $uploadError !== UPLOAD_ERR_OK) {
|
||||
$this->logChunkUploadFailure($uploadError);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'chunk' => [$this->messageForUploadError($uploadError)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
@@ -79,6 +93,63 @@ final class UploadChunkRequest extends FormRequest
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
private function detectChunkUploadError(): ?int
|
||||
{
|
||||
$uploadedFile = $this->file('chunk');
|
||||
if ($uploadedFile !== null) {
|
||||
return (int) $uploadedFile->getError();
|
||||
}
|
||||
|
||||
$rawError = data_get($_FILES, 'chunk.error');
|
||||
if ($rawError === null || $rawError === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $rawError;
|
||||
}
|
||||
|
||||
private function messageForUploadError(int $error): string
|
||||
{
|
||||
return match ($error) {
|
||||
UPLOAD_ERR_INI_SIZE => 'The upload chunk exceeded PHP upload_max_filesize. Lower UPLOAD_CHUNK_MAX_BYTES or raise upload_max_filesize/post_max_size.',
|
||||
UPLOAD_ERR_FORM_SIZE => 'The upload chunk exceeded the allowed form upload size.',
|
||||
UPLOAD_ERR_PARTIAL => 'The upload chunk was only partially received. Check Nginx/PHP-FPM request handling and network stability.',
|
||||
UPLOAD_ERR_NO_FILE => 'No upload chunk file was received by PHP.',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'PHP upload_tmp_dir is missing or unavailable. Check the configured temporary upload directory on the server.',
|
||||
UPLOAD_ERR_CANT_WRITE => 'PHP could not write the upload chunk to the temporary directory. Check upload_tmp_dir permissions and free disk space.',
|
||||
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload chunk before Laravel could process it.',
|
||||
default => 'The upload chunk failed before Laravel could read it. Check PHP temporary upload storage and request size limits.',
|
||||
};
|
||||
}
|
||||
|
||||
private function logChunkUploadFailure(int $error): void
|
||||
{
|
||||
$uploadTmpDir = (string) (ini_get('upload_tmp_dir') ?: sys_get_temp_dir() ?: '');
|
||||
$tmpExists = $uploadTmpDir !== '' ? is_dir($uploadTmpDir) : false;
|
||||
$tmpWritable = $tmpExists ? is_writable($uploadTmpDir) : false;
|
||||
|
||||
logger()->warning('Upload chunk failed before validation completed', [
|
||||
'session_id' => (string) $this->input('session_id'),
|
||||
'user_id' => $this->user()?->id,
|
||||
'ip' => $this->ip(),
|
||||
'upload_error' => $error,
|
||||
'upload_error_message' => $this->messageForUploadError($error),
|
||||
'content_length' => $this->server('CONTENT_LENGTH'),
|
||||
'post_max_size' => ini_get('post_max_size'),
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'upload_tmp_dir' => $uploadTmpDir,
|
||||
'tmp_exists' => $tmpExists,
|
||||
'tmp_writable' => $tmpWritable,
|
||||
'raw_files' => isset($_FILES['chunk']) ? [
|
||||
'name' => $_FILES['chunk']['name'] ?? null,
|
||||
'type' => $_FILES['chunk']['type'] ?? null,
|
||||
'size' => $_FILES['chunk']['size'] ?? null,
|
||||
'tmp_name' => $_FILES['chunk']['tmp_name'] ?? null,
|
||||
'error' => $_FILES['chunk']['error'] ?? null,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logUnauthorized(string $reason): void
|
||||
{
|
||||
logger()->warning('Upload chunk unauthorized access', [
|
||||
|
||||
@@ -61,6 +61,22 @@ class ArtworkListResource extends JsonResource
|
||||
|
||||
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
$group = $this->relationLoaded('group') ? $this->group : null;
|
||||
$user = $this->relationLoaded('user') ? $this->user : null;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$publisher = ($group || $user)
|
||||
? [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'id' => (int) ($isGroupPublisher ? $group?->id : $user?->id),
|
||||
'name' => $decode($isGroupPublisher ? $group?->name : $user?->name),
|
||||
'username' => $isGroupPublisher ? '' : (string) ($user?->username ?? ''),
|
||||
'avatar_url' => $isGroupPublisher ? $group?->avatarUrl() : $user?->profile?->avatar_url,
|
||||
'profile_url' => $isGroupPublisher
|
||||
? $group?->publicUrl()
|
||||
: (! empty($user?->username) ? '/@' . $user->username : null),
|
||||
]
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $artId,
|
||||
'slug' => $slugVal,
|
||||
@@ -71,12 +87,12 @@ class ArtworkListResource extends JsonResource
|
||||
'height' => $get('height'),
|
||||
],
|
||||
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
||||
'author' => $this->whenLoaded('user', function () use ($decode) {
|
||||
return [
|
||||
'name' => $decode($this->user->name ?? null),
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
];
|
||||
}),
|
||||
'author' => $publisher,
|
||||
'publisher' => $publisher,
|
||||
'author_name' => $publisher['name'] ?? '',
|
||||
'avatar_url' => $publisher['avatar_url'] ?? null,
|
||||
'profile_url' => $publisher['profile_url'] ?? null,
|
||||
'published_as_type' => $publisher['type'] ?? null,
|
||||
'category' => $primaryCategory ? [
|
||||
'slug' => $primaryCategory->slug ?? null,
|
||||
'name' => $decode($primaryCategory->name ?? null),
|
||||
|
||||
@@ -329,6 +329,7 @@ class Artwork extends Model
|
||||
|
||||
$stat = $this->stats;
|
||||
$awardStat = $this->awardStat;
|
||||
$publishedSortAt = $this->published_at ?? $this->created_at;
|
||||
|
||||
// Orientation derived from pixel dimensions
|
||||
$orientation = 'square';
|
||||
@@ -380,7 +381,8 @@ class Artwork extends Model
|
||||
'downloads' => (int) ($stat?->downloads ?? 0),
|
||||
'likes' => (int) ($stat?->favorites ?? 0),
|
||||
'views' => (int) ($stat?->views ?? 0),
|
||||
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
|
||||
'created_at' => $publishedSortAt?->toDateString() ?? '',
|
||||
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
|
||||
'is_public' => (bool) $this->is_public,
|
||||
'is_approved' => (bool) $this->is_approved,
|
||||
// ── Trending / discovery fields ────────────────────────────────────
|
||||
|
||||
@@ -255,6 +255,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
return $this->buildUploadLimits($request, 'init');
|
||||
});
|
||||
|
||||
RateLimiter::for('uploads-chunk', function (Request $request): array {
|
||||
return $this->buildUploadLimits($request, 'chunk');
|
||||
});
|
||||
|
||||
RateLimiter::for('uploads-finish', function (Request $request): array {
|
||||
return $this->buildUploadLimits($request, 'finish');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Tag;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
||||
@@ -19,6 +20,7 @@ final class ArtworkSearchService
|
||||
{
|
||||
private const BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
@@ -82,22 +84,46 @@ final class ArtworkSearchService
|
||||
/**
|
||||
* Load artworks for a tag page, sorted by views + likes descending.
|
||||
*/
|
||||
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
|
||||
public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
if (! $tag) {
|
||||
return $this->emptyPaginator($perPage);
|
||||
}
|
||||
|
||||
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
|
||||
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
|
||||
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->with(['user.profile', 'categories.contentType']);
|
||||
|
||||
match ($sort) {
|
||||
'likes' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
'latest' => $query
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id'),
|
||||
'downloads' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
default => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
};
|
||||
|
||||
return $query
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,12 +151,12 @@ final class ArtworkSearchService
|
||||
* Used by categoryPageSort() and contentTypePageSort().
|
||||
*/
|
||||
private const CATEGORY_SORT_FIELDS = [
|
||||
'trending' => ['trending_score_24h:desc', 'created_at:desc'],
|
||||
'fresh' => ['created_at:desc'],
|
||||
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'fresh' => ['published_at_ts:desc'],
|
||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
|
||||
'oldest' => ['created_at:asc'],
|
||||
'oldest' => ['published_at_ts:asc'],
|
||||
];
|
||||
|
||||
/** Cache TTL (seconds) per sort alias for category pages. */
|
||||
@@ -237,7 +263,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent artworks by created_at.
|
||||
* Most recent artworks by publish timestamp.
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
@@ -245,7 +271,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -294,7 +320,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -310,7 +336,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -378,7 +404,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by created_at desc.
|
||||
* Fresh artworks in given categories, sorted by publish timestamp desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
@@ -400,7 +426,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,24 @@ class ArtworkService
|
||||
{
|
||||
protected int $cacheTtl = 3600; // seconds
|
||||
|
||||
/**
|
||||
* Lightweight relations needed to render browse/list cards.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private function browseRelations(): array
|
||||
{
|
||||
return [
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_url',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared browse query used by /browse, content-type pages, and category pages.
|
||||
*/
|
||||
@@ -30,13 +48,7 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->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']);
|
||||
},
|
||||
]);
|
||||
->with($this->browseRelations());
|
||||
|
||||
$normalizedSort = strtolower(trim($sort));
|
||||
if ($normalizedSort === 'oldest') {
|
||||
@@ -110,6 +122,7 @@ class ArtworkService
|
||||
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
||||
{
|
||||
$query = Artwork::public()->published()
|
||||
->with($this->browseRelations())
|
||||
->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('categories.id', $category->id);
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ final class AdaptiveTimeWindow
|
||||
{
|
||||
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
|
||||
|
||||
return Cache::remember('egs.uploads_per_day', $ttl, function (): float {
|
||||
return (float) Cache::remember('egs.uploads_per_day', $ttl, function (): float {
|
||||
$count = Artwork::query()
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
@@ -72,7 +72,7 @@ final class AdaptiveTimeWindow
|
||||
->where('published_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
return round($count / 7, 2);
|
||||
return (float) round($count / 7, 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,15 @@ final class HomepageService
|
||||
}
|
||||
|
||||
public function getHomepageGroups(?\App\Models\User $viewer = null): array
|
||||
{
|
||||
if (! $viewer) {
|
||||
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
|
||||
}
|
||||
|
||||
return $this->buildHomepageGroups($viewer);
|
||||
}
|
||||
|
||||
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
|
||||
{
|
||||
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
|
||||
$spotlight = $featured[0] ?? null;
|
||||
@@ -314,6 +323,10 @@ final class HomepageService
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
|
||||
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
|
||||
return $this->getRisingLowSignalFromDb($limit);
|
||||
}
|
||||
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
@@ -348,6 +361,26 @@ final class HomepageService
|
||||
->all();
|
||||
}
|
||||
|
||||
private function getRisingLowSignalFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
||||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||||
})
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||
*
|
||||
@@ -466,26 +499,38 @@ final class HomepageService
|
||||
try {
|
||||
$since = now()->subWeek();
|
||||
|
||||
$rows = DB::table('artworks')
|
||||
->join('users as u', 'u.id', '=', 'artworks.user_id')
|
||||
$weeklyUploads = Artwork::query()
|
||||
->selectRaw('user_id, COUNT(*) as weekly_uploads')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->where('published_at', '>=', $since)
|
||||
->groupBy('user_id');
|
||||
|
||||
$rows = DB::table('users as u')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
|
||||
$join->on('weekly_uploads.user_id', '=', 'u.id');
|
||||
})
|
||||
->select(
|
||||
'u.id',
|
||||
'u.name',
|
||||
'u.username',
|
||||
'up.avatar_hash',
|
||||
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
|
||||
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
|
||||
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
|
||||
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
|
||||
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
|
||||
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
|
||||
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
|
||||
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
|
||||
)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->whereNotNull('artworks.published_at')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
|
||||
->whereNull('u.deleted_at')
|
||||
->where('u.is_active', true)
|
||||
->where(function ($query): void {
|
||||
$query->where('us.uploads_count', '>', 0)
|
||||
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
|
||||
})
|
||||
->orderByDesc('weekly_uploads')
|
||||
->orderByDesc('total_awards')
|
||||
->orderByDesc('total_views')
|
||||
@@ -494,18 +539,23 @@ final class HomepageService
|
||||
|
||||
$userIds = $rows->pluck('id')->all();
|
||||
|
||||
// Pick one random artwork thumbnail per creator for the card background.
|
||||
$thumbsByUser = Artwork::public()
|
||||
$latestArtworkIds = Artwork::public()
|
||||
->published()
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('hash')
|
||||
->whereNotNull('thumb_ext')
|
||||
->inRandomOrder()
|
||||
->selectRaw('MAX(id) as id')
|
||||
->groupBy('user_id')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$thumbsByUser = Artwork::query()
|
||||
->whereIn('id', $latestArtworkIds)
|
||||
->get(['id', 'user_id', 'hash', 'thumb_ext'])
|
||||
->groupBy('user_id');
|
||||
->keyBy('user_id');
|
||||
|
||||
return $rows->map(function ($u) use ($thumbsByUser) {
|
||||
$artworkForBg = $thumbsByUser->get($u->id)?->first();
|
||||
$artworkForBg = $thumbsByUser->get($u->id);
|
||||
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
||||
|
||||
return [
|
||||
@@ -792,6 +842,37 @@ final class HomepageService
|
||||
return $artworks;
|
||||
}
|
||||
|
||||
private function collectionHasNoRisingMomentum(Collection $items): bool
|
||||
{
|
||||
if ($items->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $items->every(function ($item): bool {
|
||||
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
|
||||
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
|
||||
|
||||
return $heat <= 0.0 && $velocity <= 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
private function risingRecentActivitySubquery()
|
||||
{
|
||||
$since = now()->startOfHour()->subHours(24);
|
||||
|
||||
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
|
||||
->selectRaw('rising_snapshots.artwork_id')
|
||||
->selectRaw('(
|
||||
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
|
||||
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
|
||||
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
|
||||
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
|
||||
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
|
||||
) as recent_signal_24h')
|
||||
->where('rising_snapshots.bucket_hour', '>=', $since)
|
||||
->groupBy('rising_snapshots.artwork_id');
|
||||
}
|
||||
|
||||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||||
{
|
||||
$thumbMd = $artwork->thumbUrl('md');
|
||||
|
||||
@@ -407,6 +407,7 @@ final class CreatorStudioContentService
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
||||
$status = (string) ($item['status'] ?? '');
|
||||
$isDraft = ($item['status'] ?? null) === 'draft';
|
||||
$missing = [];
|
||||
$score = 0;
|
||||
@@ -441,6 +442,16 @@ final class CreatorStudioContentService
|
||||
default => 'Needs more work',
|
||||
};
|
||||
|
||||
$readiness = $status === 'published'
|
||||
? null
|
||||
: [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
];
|
||||
|
||||
$workflowActions = match ((string) ($item['module'] ?? '')) {
|
||||
'artworks' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
@@ -466,13 +477,7 @@ final class CreatorStudioContentService
|
||||
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
|
||||
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
||||
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
||||
'readiness' => [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
],
|
||||
'readiness' => $readiness,
|
||||
'cross_module_actions' => $workflowActions,
|
||||
];
|
||||
|
||||
|
||||
@@ -50,9 +50,10 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
$query->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false)
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
@@ -92,16 +93,29 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->with([
|
||||
'stats',
|
||||
'categories',
|
||||
'tags',
|
||||
'features' => function ($query): void {
|
||||
$query->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
},
|
||||
])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
});
|
||||
$builder->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('artwork_status', 'scheduled');
|
||||
@@ -199,7 +213,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'schedule_timezone' => $artwork->artwork_timezone,
|
||||
'featured' => false,
|
||||
'featured' => $artwork->features->isNotEmpty(),
|
||||
'metrics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'appreciation' => (int) ($stats?->favorites ?? 0),
|
||||
|
||||
@@ -31,6 +31,8 @@ final class StudioBulkActionService
|
||||
$query = Artwork::where('user_id', $userId);
|
||||
if ($action === 'unarchive') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($action === 'delete') {
|
||||
$query->withTrashed();
|
||||
}
|
||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user