Save workspace changes
This commit is contained in:
256
app/Console/Commands/AiBiographyProvidersCommand.php
Normal file
256
app/Console/Commands/AiBiographyProvidersCommand.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class AiBiographyProvidersCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:providers
|
||||
{--provider= : Inspect only one provider (together|vision_gateway|vision|gemini|home)}
|
||||
{--limit=10 : Maximum number of models to display per provider}';
|
||||
|
||||
protected $description = 'Check AI biography provider health and list available models';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$provider = $this->resolveProviderOption();
|
||||
|
||||
if ($provider === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$providers = $provider !== null
|
||||
? [$provider]
|
||||
: ['together', 'vision_gateway', 'gemini', 'home'];
|
||||
|
||||
$inspections = array_map(fn (string $name): array => $this->inspectProvider($name, $limit), $providers);
|
||||
|
||||
$this->table(
|
||||
['Provider', 'Status', 'Configured model', 'Models endpoint'],
|
||||
array_map(fn (array $inspection): array => [
|
||||
$inspection['provider'],
|
||||
$inspection['status'],
|
||||
$inspection['configured_model'] ?: 'n/a',
|
||||
$inspection['models_endpoint'] ?: 'n/a',
|
||||
], $inspections)
|
||||
);
|
||||
|
||||
foreach ($inspections as $inspection) {
|
||||
$this->line('');
|
||||
$this->comment(strtoupper($inspection['provider']));
|
||||
$this->line(' Base URL : ' . ($inspection['base_url'] ?: 'n/a'));
|
||||
$this->line(' Configured : ' . ($inspection['configured'] ? 'yes' : 'no'));
|
||||
$this->line(' Status : ' . $inspection['status']);
|
||||
|
||||
if ($inspection['error'] !== null) {
|
||||
$this->line(' Error : ' . $inspection['error']);
|
||||
}
|
||||
|
||||
if ($inspection['models'] === []) {
|
||||
$this->line(' Models : none reported');
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(' Models');
|
||||
foreach ($inspection['models'] as $model) {
|
||||
$this->line(' - ' . $model);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function inspectProvider(string $provider, int $limit): array
|
||||
{
|
||||
$baseUrl = $this->providerBaseUrl($provider);
|
||||
$configuredModel = $this->configuredModel($provider);
|
||||
$modelsEndpoint = $this->modelsEndpoint($provider, $baseUrl);
|
||||
|
||||
if (! $this->isConfigured($provider, $baseUrl, $configuredModel)) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'not_configured',
|
||||
'configured' => false,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => 'Required configuration is missing.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->buildRequest($provider)->get($modelsEndpoint);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'offline',
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'http_' . $response->status(),
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => mb_substr(trim($response->body()), 0, 300),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'online',
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => array_slice($this->extractModels($provider, $response), 0, $limit),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveProviderOption(): string|false|null
|
||||
{
|
||||
$rawProvider = $this->option('provider');
|
||||
|
||||
if ($rawProvider === null || trim((string) $rawProvider) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $rawProvider))) {
|
||||
'vision_gateway', 'vision', 'local' => 'vision_gateway',
|
||||
'gemini' => 'gemini',
|
||||
'home', 'lmstudio', 'lm_studio' => 'home',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => $this->invalidProvider((string) $rawProvider),
|
||||
};
|
||||
}
|
||||
|
||||
private function invalidProvider(string $provider): false
|
||||
{
|
||||
$this->error("Invalid provider [{$provider}]. Supported values: together, vision_gateway, vision, gemini, home.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isConfigured(string $provider, string $baseUrl, string $configuredModel): bool
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => $baseUrl !== '' && trim((string) config('vision.gateway.api_key', '')) !== '',
|
||||
'gemini' => $baseUrl !== '' && trim((string) config('ai_biography.gemini.api_key', '')) !== '' && $configuredModel !== '',
|
||||
'home' => $baseUrl !== '' && $configuredModel !== '',
|
||||
'together' => trim((string) config('ai_biography.together.api_key', '')) !== '' && $configuredModel !== '',
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function configuredModel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
|
||||
'gemini' => trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest')),
|
||||
'home' => trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b')),
|
||||
'together' => trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it')),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function providerBaseUrl(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/'),
|
||||
'gemini' => rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/'),
|
||||
'home' => rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/'),
|
||||
'together' => rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/'),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function modelsEndpoint(string $provider, string $baseUrl): string
|
||||
{
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match ($provider) {
|
||||
'gemini' => $baseUrl . '/v1beta/models',
|
||||
default => $baseUrl . '/v1/models',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildRequest(string $provider): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()->contentType('application/json');
|
||||
|
||||
return match ($provider) {
|
||||
'vision_gateway' => $request
|
||||
->withHeaders(['X-API-Key' => (string) config('vision.gateway.api_key', '')])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30))),
|
||||
'gemini' => $request
|
||||
->withHeaders(['X-goog-api-key' => (string) config('ai_biography.gemini.api_key', '')])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30))),
|
||||
'together' => $request
|
||||
->withToken((string) config('ai_biography.together.api_key', ''))
|
||||
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
|
||||
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90)))),
|
||||
'home' => $this->buildHomeRequest($request),
|
||||
default => $request,
|
||||
};
|
||||
}
|
||||
|
||||
private function buildHomeRequest(PendingRequest $request): PendingRequest
|
||||
{
|
||||
$request = $request
|
||||
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
|
||||
|
||||
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
$apiKey = trim((string) config('ai_biography.home.api_key', ''));
|
||||
|
||||
return $apiKey !== '' ? $request->withToken($apiKey) : $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractModels(string $provider, Response $response): array
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
if ($provider === 'gemini') {
|
||||
return collect((array) ($json['models'] ?? []))
|
||||
->map(fn ($model) => is_array($model) ? (string) ($model['name'] ?? $model['displayName'] ?? '') : '')
|
||||
->filter(fn (string $name): bool => $name !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return collect((array) ($json['data'] ?? []))
|
||||
->map(fn ($model) => is_array($model) ? (string) ($model['id'] ?? '') : '')
|
||||
->filter(fn (string $name): bool => $name !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
271
app/Console/Commands/AuditLegacyArtworkUserIdsCommand.php
Normal file
271
app/Console/Commands/AuditLegacyArtworkUserIdsCommand.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AuditLegacyArtworkUserIdsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:audit-legacy-user-ids
|
||||
{--chunk=1000 : Number of legacy artwork rows to process per batch}
|
||||
{--show=100 : Maximum number of discrepancies to print}
|
||||
{--artwork-id= : Only compare one artwork id}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy artworks table name}
|
||||
{--new-table=artworks : Current artworks table name}
|
||||
{--json : Output the summary and discrepancies as JSON}';
|
||||
|
||||
protected $description = 'Compare legacy wallz.user_id values against artworks.user_id using shared artwork ids';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
$show = max(0, (int) $this->option('show'));
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
$newTable = (string) $this->option('new-table');
|
||||
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
|
||||
$json = (bool) $this->option('json');
|
||||
|
||||
if ($artworkId !== null && $artworkId <= 0) {
|
||||
$this->error('The --artwork-id option must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->tableExists($legacyConnection, $legacyTable)) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->tableExists(null, $newTable)) {
|
||||
$this->error("Current table {$newTable} does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->columnExists($legacyConnection, $legacyTable, 'user_id') || ! $this->columnExists($legacyConnection, $legacyTable, 'id')) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} must contain id and user_id columns.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->columnExists(null, $newTable, 'user_id') || ! $this->columnExists(null, $newTable, 'id')) {
|
||||
$this->error("Current table {$newTable} must contain id and user_id columns.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacyCountQuery = DB::connection($legacyConnection)->table($legacyTable);
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$legacyCountQuery->where('id', $artworkId);
|
||||
}
|
||||
|
||||
$total = (int) $legacyCountQuery->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$message = $artworkId === null
|
||||
? "No rows found in {$legacyConnection}.{$legacyTable}."
|
||||
: "Legacy artwork #{$artworkId} was not found in {$legacyConnection}.{$legacyTable}.";
|
||||
|
||||
$this->warn($message);
|
||||
|
||||
return $artworkId === null ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Comparing %d legacy %s.%s row(s) against %s in chunks of %d...',
|
||||
$total,
|
||||
$legacyConnection,
|
||||
$legacyTable,
|
||||
$newTable,
|
||||
$chunkSize,
|
||||
));
|
||||
|
||||
$summary = [
|
||||
'checked' => 0,
|
||||
'matched' => 0,
|
||||
'mismatched' => 0,
|
||||
'missing_in_new' => 0,
|
||||
];
|
||||
$discrepancies = [];
|
||||
|
||||
$legacyQuery = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->select(['id', 'user_id'])
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$legacyQuery->where('id', $artworkId);
|
||||
}
|
||||
|
||||
$legacyQuery->chunkById($chunkSize, function ($rows) use (&$summary, &$discrepancies, $show, $newTable): void {
|
||||
$artworkIds = $rows->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
|
||||
$newRows = DB::table($newTable)
|
||||
->select(['id', 'user_id', 'title'])
|
||||
->whereIn('id', $artworkIds)
|
||||
->get()
|
||||
->keyBy(static fn (object $row): int => (int) $row->id);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$summary['checked']++;
|
||||
|
||||
$legacyUserId = $this->normalizeNullableInt($row->user_id ?? null);
|
||||
$currentRow = $newRows->get((int) $row->id);
|
||||
|
||||
if ($currentRow === null) {
|
||||
$summary['missing_in_new']++;
|
||||
$this->rememberDiscrepancy(
|
||||
$discrepancies,
|
||||
$show,
|
||||
(int) $row->id,
|
||||
$legacyUserId,
|
||||
null,
|
||||
null,
|
||||
'missing_in_new',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$newUserId = $this->normalizeNullableInt($currentRow->user_id ?? null);
|
||||
|
||||
if ($legacyUserId === $newUserId) {
|
||||
$summary['matched']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['mismatched']++;
|
||||
$this->rememberDiscrepancy(
|
||||
$discrepancies,
|
||||
$show,
|
||||
(int) $row->id,
|
||||
$legacyUserId,
|
||||
$newUserId,
|
||||
(string) ($currentRow->title ?? ''),
|
||||
'user_id_mismatch',
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line(sprintf(
|
||||
' audited=%d matched=%d mismatched=%d missing_in_new=%d',
|
||||
$summary['checked'],
|
||||
$summary['matched'],
|
||||
$summary['mismatched'],
|
||||
$summary['missing_in_new'],
|
||||
));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
if ($json) {
|
||||
$this->line(json_encode([
|
||||
'summary' => $summary,
|
||||
'discrepancies' => $discrepancies,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return ($summary['mismatched'] === 0 && $summary['missing_in_new'] === 0)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Checked', 'Matched', 'Mismatched', 'Missing In New'],
|
||||
[[
|
||||
$summary['checked'],
|
||||
$summary['matched'],
|
||||
$summary['mismatched'],
|
||||
$summary['missing_in_new'],
|
||||
]],
|
||||
);
|
||||
|
||||
if ($discrepancies !== []) {
|
||||
$this->newLine();
|
||||
$this->warn(sprintf(
|
||||
'Showing %d discrepancy row(s)%s.',
|
||||
count($discrepancies),
|
||||
($summary['mismatched'] + $summary['missing_in_new']) > count($discrepancies)
|
||||
? sprintf(' out of %d total', $summary['mismatched'] + $summary['missing_in_new'])
|
||||
: '',
|
||||
));
|
||||
|
||||
$this->table(
|
||||
['Artwork ID', 'Legacy user_id', 'New user_id', 'Status', 'Title'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['artwork_id'],
|
||||
$row['legacy_user_id'],
|
||||
$row['new_user_id'],
|
||||
$row['status'],
|
||||
$row['title'],
|
||||
], $discrepancies),
|
||||
);
|
||||
} else {
|
||||
$this->info('No user_id mismatches were found.');
|
||||
}
|
||||
|
||||
return ($summary['mismatched'] === 0 && $summary['missing_in_new'] === 0)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
|
||||
private function tableExists(?string $connection, string $table): bool
|
||||
{
|
||||
try {
|
||||
return $connection === null
|
||||
? DB::getSchemaBuilder()->hasTable($table)
|
||||
: DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(?string $connection, string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
return $connection === null
|
||||
? DB::getSchemaBuilder()->hasColumn($table, $column)
|
||||
: DB::connection($connection)->getSchemaBuilder()->hasColumn($table, $column);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int, legacy_user_id:string, new_user_id:string, title:string, status:string}> $discrepancies
|
||||
*/
|
||||
private function rememberDiscrepancy(
|
||||
array &$discrepancies,
|
||||
int $show,
|
||||
int $artworkId,
|
||||
?int $legacyUserId,
|
||||
?int $newUserId,
|
||||
?string $title,
|
||||
string $status,
|
||||
): void {
|
||||
if (count($discrepancies) >= $show) {
|
||||
return;
|
||||
}
|
||||
|
||||
$discrepancies[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'legacy_user_id' => $legacyUserId === null ? '[null]' : (string) $legacyUserId,
|
||||
'new_user_id' => $newUserId === null ? '[missing]' : (string) $newUserId,
|
||||
'title' => $title ?? '',
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AuditMissingMigratedUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'users:audit-missing-migrated
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-users-table=users : Legacy users table name}
|
||||
{--new-connection= : New database connection name (defaults to the app default connection)}
|
||||
{--new-users-table=users : New users table name}
|
||||
{--sql-output= : Optional path for a transaction-wrapped SQL file with INSERTs for missing users}
|
||||
{--chunk=500 : Number of legacy users to process per chunk}';
|
||||
|
||||
protected $description = 'List legacy users flagged with should_migrate=1 that do not exist in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$legacyConnection = trim((string) $this->option('legacy-connection')) ?: 'legacy';
|
||||
$legacyUsersTable = trim((string) $this->option('legacy-users-table')) ?: 'users';
|
||||
$newConnection = trim((string) $this->option('new-connection')) ?: (string) config('database.default');
|
||||
$newUsersTable = trim((string) $this->option('new-users-table')) ?: 'users';
|
||||
$sqlOutputPath = trim((string) $this->option('sql-output')) ?: null;
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
|
||||
try {
|
||||
DB::connection($legacyConnection)->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection($newConnection)->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error("Cannot connect to new database connection [{$newConnection}]: " . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable($legacyUsersTable)) {
|
||||
$this->error("Legacy users table [{$legacyConnection}.{$legacyUsersTable}] does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection($newConnection)->getSchemaBuilder()->hasTable($newUsersTable)) {
|
||||
$this->error("New users table [{$newConnection}.{$newUsersTable}] does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Scanning %s.%s for should_migrate=1 and checking %s.%s...',
|
||||
$legacyConnection,
|
||||
$legacyUsersTable,
|
||||
$newConnection,
|
||||
$newUsersTable,
|
||||
));
|
||||
|
||||
$legacySelectColumns = $this->legacySelectColumns($legacyConnection, $legacyUsersTable);
|
||||
$scanned = 0;
|
||||
$existing = 0;
|
||||
$missing = 0;
|
||||
$sqlInsertCount = 0;
|
||||
$sqlContext = $sqlOutputPath !== null
|
||||
? $this->startSqlExport($sqlOutputPath, $legacyConnection, $legacyUsersTable, $newUsersTable)
|
||||
: null;
|
||||
|
||||
DB::connection($legacyConnection)
|
||||
->table($legacyUsersTable)
|
||||
->select($legacySelectColumns)
|
||||
->where('should_migrate', 1)
|
||||
->orderBy('user_id')
|
||||
->chunkById($chunkSize, function ($rows) use (&$scanned, &$existing, &$missing, &$sqlInsertCount, &$sqlContext, $newConnection, $newUsersTable): void {
|
||||
$legacyIds = $rows->pluck('user_id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->filter(static fn (int $value): bool => $value > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$existingIds = DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereIn('id', $legacyIds)
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->flip();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->user_id ?? 0);
|
||||
if ($legacyId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned++;
|
||||
|
||||
if ($existingIds->has($legacyId)) {
|
||||
$existing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing++;
|
||||
|
||||
$username = trim((string) ($row->uname ?? ''));
|
||||
$email = trim((string) ($row->email ?? ''));
|
||||
|
||||
$this->line(sprintf(
|
||||
'[missing] id=%d uname=%s email=%s',
|
||||
$legacyId,
|
||||
$username !== '' ? '@' . $username : '(none)',
|
||||
$email !== '' ? '<' . $email . '>' : '(none)',
|
||||
));
|
||||
|
||||
if ($sqlContext !== null) {
|
||||
$statement = $this->buildMissingUserInsertStatement($row, $legacyId, $newConnection, $newUsersTable, $sqlContext);
|
||||
$this->appendSqlExport($sqlContext['path'], $statement . PHP_EOL);
|
||||
$sqlInsertCount++;
|
||||
}
|
||||
}
|
||||
}, 'user_id', 'user_id');
|
||||
|
||||
if ($sqlContext !== null) {
|
||||
$this->finishSqlExport($sqlContext['path'], $sqlInsertCount);
|
||||
$this->info(sprintf('SQL export written to %s with %d INSERT statement(s).', $sqlContext['path'], $sqlInsertCount));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done. scanned=%d existing=%d missing=%d',
|
||||
$scanned,
|
||||
$existing,
|
||||
$missing,
|
||||
));
|
||||
|
||||
return $missing > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function legacySelectColumns(string $legacyConnection, string $legacyUsersTable): array
|
||||
{
|
||||
$available = [];
|
||||
foreach (DB::connection($legacyConnection)->getSchemaBuilder()->getColumnListing($legacyUsersTable) as $column) {
|
||||
$available[strtolower((string) $column)] = (string) $column;
|
||||
}
|
||||
|
||||
$select = [];
|
||||
foreach (['user_id', 'uname', 'email', 'real_name', 'joinDate', 'LastVisit', 'active'] as $wanted) {
|
||||
$key = strtolower($wanted);
|
||||
if (isset($available[$key])) {
|
||||
$select[] = $available[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $select;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string, generated_at:Carbon, reserved_usernames: array<string, true>, reserved_emails: array<string, true>}
|
||||
*/
|
||||
private function startSqlExport(string $sqlOutputPath, string $legacyConnection, string $legacyUsersTable, string $newUsersTable): array
|
||||
{
|
||||
$directory = dirname($sqlOutputPath);
|
||||
if ($directory !== '' && $directory !== '.' && ! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException(sprintf('Could not create SQL output directory [%s].', $directory));
|
||||
}
|
||||
|
||||
$generatedAt = now();
|
||||
|
||||
$header = [
|
||||
'-- Generated by users:audit-missing-migrated',
|
||||
sprintf('-- Generated at: %s', $generatedAt->toIso8601String()),
|
||||
sprintf('-- Legacy source: %s.%s', $legacyConnection, $legacyUsersTable),
|
||||
sprintf('-- Target table: %s', $newUsersTable),
|
||||
'START TRANSACTION;',
|
||||
'',
|
||||
];
|
||||
|
||||
$this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $header));
|
||||
|
||||
return [
|
||||
'path' => $sqlOutputPath,
|
||||
'generated_at' => $generatedAt,
|
||||
'reserved_usernames' => [],
|
||||
'reserved_emails' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function finishSqlExport(string $sqlOutputPath, int $insertCount): void
|
||||
{
|
||||
$footer = [
|
||||
'',
|
||||
sprintf('-- Total INSERT statements: %d', $insertCount),
|
||||
'COMMIT;',
|
||||
'',
|
||||
];
|
||||
|
||||
$this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $footer));
|
||||
}
|
||||
|
||||
private function appendSqlExport(string $sqlOutputPath, string $content): void
|
||||
{
|
||||
$result = @file_put_contents($sqlOutputPath, $content, FILE_APPEND);
|
||||
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException(sprintf('Could not write SQL output file [%s].', $sqlOutputPath));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path:string, generated_at:Carbon, reserved_usernames: array<string, true>, reserved_emails: array<string, true>} $sqlContext
|
||||
*/
|
||||
private function buildMissingUserInsertStatement(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array &$sqlContext): string
|
||||
{
|
||||
$generatedAt = $sqlContext['generated_at'];
|
||||
$username = $this->resolveSqlImportUsername($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_usernames']);
|
||||
$email = $this->resolveSqlImportEmail($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_emails']);
|
||||
$name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?: $username));
|
||||
$createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $generatedAt->copy();
|
||||
$lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit'));
|
||||
$isActive = (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1 ? 1 : 0;
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
|
||||
$sqlContext['reserved_usernames'][strtolower($username)] = true;
|
||||
$sqlContext['reserved_emails'][strtolower($email)] = true;
|
||||
|
||||
$columns = [
|
||||
'id',
|
||||
'username',
|
||||
'username_changed_at',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_active',
|
||||
'needs_password_reset',
|
||||
'role',
|
||||
'legacy_password_algo',
|
||||
'last_visit_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
$values = [
|
||||
$legacyId,
|
||||
$username,
|
||||
$generatedAt->format('Y-m-d H:i:s'),
|
||||
$name,
|
||||
$email,
|
||||
$passwordHash,
|
||||
$isActive,
|
||||
1,
|
||||
'user',
|
||||
null,
|
||||
$lastVisitAt?->format('Y-m-d H:i:s'),
|
||||
$createdAt->format('Y-m-d H:i:s'),
|
||||
$generatedAt->format('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
return sprintf(
|
||||
'INSERT INTO %s (%s) VALUES (%s);',
|
||||
$this->quoteIdentifier($newUsersTable),
|
||||
implode(', ', array_map(fn (string $column): string => $this->quoteIdentifier($column), $columns)),
|
||||
implode(', ', array_map(fn (mixed $value): string => $this->sqlLiteral($value), $values)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedUsernames
|
||||
*/
|
||||
private function resolveSqlImportUsername(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): string
|
||||
{
|
||||
$rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId));
|
||||
$candidate = UsernamePolicy::sanitizeLegacy($rawUsername);
|
||||
|
||||
if (! $this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$base = 'tmpu' . $legacyId;
|
||||
$candidate = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) {
|
||||
$suffixStr = (string) $suffix;
|
||||
$candidate = substr($base, 0, max(1, UsernamePolicy::max() - strlen($suffixStr))) . $suffixStr;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedUsernames
|
||||
*/
|
||||
private function sqlUsernameExists(string $username, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): bool
|
||||
{
|
||||
$normalized = strtolower(trim($username));
|
||||
if ($normalized === '' || isset($reservedUsernames[$normalized])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->where('id', '!=', $legacyId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedEmails
|
||||
*/
|
||||
private function resolveSqlImportEmail(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): string
|
||||
{
|
||||
$rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? '')));
|
||||
$seed = $rawEmail !== ''
|
||||
? $rawEmail
|
||||
: ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org');
|
||||
|
||||
$candidate = $seed;
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->sqlEmailExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedEmails)) {
|
||||
[$local, $domain] = array_pad(explode('@', $seed, 2), 2, 'users.skinbase.org');
|
||||
$candidate = $this->sanitizeEmailLocal($local) . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedEmails
|
||||
*/
|
||||
private function sqlEmailExists(string $email, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): bool
|
||||
{
|
||||
$normalized = strtolower(trim($email));
|
||||
if ($normalized === '' || isset($reservedEmails[$normalized])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereRaw('LOWER(email) = ?', [$normalized])
|
||||
->where('id', '!=', $legacyId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim(Str::ascii($value)));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
private function legacyField(object $legacyUser, string $field): mixed
|
||||
{
|
||||
if (property_exists($legacyUser, $field)) {
|
||||
return $legacyUser->{$field};
|
||||
}
|
||||
|
||||
foreach ((array) $legacyUser as $key => $value) {
|
||||
if (strcasecmp((string) $key, $field) === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseLegacyDate(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return implode('.', array_map(static fn (string $segment): string => '`' . str_replace('`', '``', $segment) . '`', explode('.', $identifier)));
|
||||
}
|
||||
|
||||
private function sqlLiteral(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
}
|
||||
474
app/Console/Commands/AuditOrphanedArtworksCommand.php
Normal file
474
app/Console/Commands/AuditOrphanedArtworksCommand.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuditOrphanedArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:audit-orphaned-artworks
|
||||
{--output= : Path to write CSV report (optional)}
|
||||
{--sql= : Path to write SQL import script for recoverable users (optional)}
|
||||
{--check-usernames : Compare usernames between legacy DB and new users table for the same IDs}
|
||||
{--hide-missing : Suppress MISS lines in --check-usernames output}
|
||||
{--username-output= : Path to write username-diff CSV (optional, used with --check-usernames)}
|
||||
{--username-fix-sql= : Path to write SQL UPDATE script to align new usernames to legacy (optional, used with --check-usernames)}
|
||||
{--chunk=500 : Chunk size for processing}';
|
||||
|
||||
protected $description = 'Find artworks whose user_id does not exist in the users table (also checks legacy DB)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Scanning for artworks with missing users…');
|
||||
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$outputPath = $this->option('output');
|
||||
$sqlPath = $this->option('sql');
|
||||
|
||||
// ── Step 1: find user_ids in artworks missing from the NEW users table ──
|
||||
$missingFromNew = DB::table('artworks')
|
||||
->select('user_id')
|
||||
->distinct()
|
||||
->whereNotIn('user_id', function ($sub) {
|
||||
$sub->select('id')->from('users');
|
||||
})
|
||||
->pluck('user_id')
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($missingFromNew->isEmpty()) {
|
||||
$this->info('✓ No orphaned artworks found — all user_ids exist in users.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->warn("Found {$missingFromNew->count()} user_id(s) in artworks missing from the new users table.");
|
||||
|
||||
// ── Step 2: cross-check against legacy DB ──
|
||||
$legacyAvailable = false;
|
||||
$legacyRows = collect();
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
$legacyAvailable = true;
|
||||
} catch (\Throwable) {
|
||||
$this->warn('Legacy DB connection is not available — skipping legacy cross-check.');
|
||||
}
|
||||
|
||||
if ($legacyAvailable) {
|
||||
$this->info('Cross-checking against legacy DB…');
|
||||
|
||||
// Legacy users table uses `user_id` as PK (not `id`).
|
||||
$legacyRows = DB::connection('legacy')
|
||||
->table('users')
|
||||
->whereIn('user_id', $missingFromNew->all())
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
$this->line(" • {$legacyRows->count()} of those exist in the legacy DB (recoverable).");
|
||||
$this->line(' • ' . $missingFromNew->diff($legacyRows->keys())->count() . ' do NOT exist in legacy DB either (truly orphaned).');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// ── Step 3: collect artwork rows ──
|
||||
$rows = [];
|
||||
|
||||
DB::table('artworks')
|
||||
->whereIn('user_id', $missingFromNew->all())
|
||||
->orderBy('user_id')
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($artworks) use ($legacyRows, &$rows) {
|
||||
foreach ($artworks as $artwork) {
|
||||
$inLegacy = $legacyRows->has($artwork->user_id);
|
||||
$rows[] = [
|
||||
'user_id' => $artwork->user_id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'artwork_slug' => $artwork->slug ?? '',
|
||||
'artwork_title' => $artwork->title ?? '',
|
||||
'status' => $artwork->status ?? '',
|
||||
'created_at' => $artwork->created_at ?? '',
|
||||
'in_legacy_db' => $inLegacy ? 'yes' : 'no',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 4: console table ──
|
||||
$tableRows = collect($rows)->map(fn($r) => [
|
||||
$r['user_id'],
|
||||
$r['artwork_id'],
|
||||
$r['artwork_slug'],
|
||||
mb_strimwidth((string) $r['artwork_title'], 0, 48, '…'),
|
||||
$r['status'],
|
||||
$r['created_at'],
|
||||
$r['in_legacy_db'],
|
||||
])->all();
|
||||
|
||||
$this->table(
|
||||
['user_id', 'artwork_id', 'slug', 'title', 'status', 'created_at', 'in_legacy_db'],
|
||||
$tableRows,
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// ── Step 5: per-user summary ──
|
||||
$countPerUser = collect($rows)->groupBy('user_id');
|
||||
$this->info('Summary per missing user:');
|
||||
foreach ($countPerUser as $userId => $group) {
|
||||
$inLegacy = $group->first()['in_legacy_db'];
|
||||
$this->line(sprintf(
|
||||
' user_id %-8s %3d artwork(s) legacy_db: %s',
|
||||
$userId,
|
||||
$group->count(),
|
||||
$inLegacy,
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('Total orphaned artworks: ' . count($rows));
|
||||
|
||||
// ── Step 6: optional CSV export ──
|
||||
if ($outputPath) {
|
||||
$fp = fopen($outputPath, 'w');
|
||||
if ($fp === false) {
|
||||
$this->error("Could not open output file: {$outputPath}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
fputcsv($fp, ['user_id', 'artwork_id', 'artwork_slug', 'artwork_title', 'status', 'created_at', 'in_legacy_db']);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fp, array_values($row));
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
$this->info("Report written to: {$outputPath}");
|
||||
}
|
||||
|
||||
// ── Step 7: optional SQL import script for recoverable users ──
|
||||
if ($sqlPath) {
|
||||
if (! $legacyAvailable) {
|
||||
$this->error('Cannot generate SQL: legacy DB connection is not available.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$result = $this->generateImportSql($sqlPath, $legacyRows, $missingFromNew);
|
||||
if ($result !== self::SUCCESS) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8: optional username diff between legacy and new DB ──
|
||||
if ($this->option('check-usernames')) {
|
||||
$this->checkUsernameDiff((int) $this->option('chunk'), $this->option('username-output'), $this->option('username-fix-sql'));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a self-contained SQL script that re-inserts recoverable users
|
||||
* (plus their user_profiles and user_statistics rows) into the new DB.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $legacyRows keyed by user_id
|
||||
* @param \Illuminate\Support\Collection $missingFromNew
|
||||
*/
|
||||
protected function generateImportSql(string $path, $legacyRows, $missingFromNew): int
|
||||
{
|
||||
// Also pull statistics from legacy for each recoverable user.
|
||||
$legacyStats = DB::connection('legacy')
|
||||
->table('users_statistics')
|
||||
->whereIn('user_id', $legacyRows->keys()->all())
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
$now = now()->format('Y-m-d H:i:s');
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '-- ============================================================';
|
||||
$lines[] = '-- Skinbase orphaned-artwork user recovery script';
|
||||
$lines[] = '-- Generated: ' . $now;
|
||||
$lines[] = '-- Source: legacy DB → new users / user_profiles / user_statistics';
|
||||
$lines[] = '-- REVIEW CAREFULLY before running on production.';
|
||||
$lines[] = '-- ============================================================';
|
||||
$lines[] = '';
|
||||
$lines[] = 'SET NAMES utf8mb4;';
|
||||
$lines[] = 'USE `' . config('database.connections.mysql.database') . '`;';
|
||||
$lines[] = 'START TRANSACTION;';
|
||||
$lines[] = '';
|
||||
|
||||
$recovered = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($legacyRows as $userId => $row) {
|
||||
$userId = (int) $userId;
|
||||
$stat = $legacyStats->get($userId);
|
||||
|
||||
// --- resolve username (mirrors ImportLegacyUsers: lowercase, alnum+dash+underscore, max 20) ---
|
||||
$rawUsername = trim((string) ($row->uname ?? ''));
|
||||
$username = $rawUsername !== '' ? $rawUsername : ('user' . $userId);
|
||||
$username = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', $username));
|
||||
$username = substr($username !== '' ? $username : ('user' . $userId), 0, 20);
|
||||
|
||||
// --- resolve email ---
|
||||
// Use a guaranteed-unique synthetic email for the INSERT so it never conflicts
|
||||
// with an existing account (email has a unique constraint). The real legacy email
|
||||
// is stored in a comment — patch it manually after verifying no conflict.
|
||||
$rawEmail = strtolower(trim((string) ($row->email ?? '')));
|
||||
$safeEmail = $userId . '@legacy.skinbase.org'; // collision-free, always unique
|
||||
|
||||
// --- dates ---
|
||||
$createdAt = $this->sqlDate($row->joinDate ?? null, $now);
|
||||
$lastVisitAt = $this->sqlDate($row->LastVisit ?? null, null);
|
||||
|
||||
// --- stats ---
|
||||
$uploads = $this->safeInt($stat->uploads ?? 0);
|
||||
$downloads = $this->safeInt($stat->downloads ?? 0);
|
||||
$pageviews = $this->safeInt($stat->pageviews ?? 0);
|
||||
$awards = $this->safeInt($stat->awards ?? 0);
|
||||
|
||||
// --- profile fields ---
|
||||
$about = $this->sqlString($row->about_me ?? $row->description ?? null);
|
||||
$country = $this->sqlString($row->country ?? null);
|
||||
$countryCode = $this->sqlString($row->country_code ? substr($row->country_code, 0, 2) : null);
|
||||
$language = $this->sqlString($row->lang ?? null);
|
||||
$website = $this->sqlString($row->web ?? null);
|
||||
$gender = $this->sqlString($this->normalizeLegacyGender($row->gender ?? null));
|
||||
$birthdate = $this->sqlDate($row->birth ?? null, null);
|
||||
$avatarLegacy = $this->sqlString($row->picture ?? null);
|
||||
$coverImage = $this->sqlString($row->cover_art ?? null);
|
||||
|
||||
$lines[] = "-- user_id={$userId} username={$username} real_email={$rawEmail} (password placeholder; user must reset)";
|
||||
$lines[] = "-- To restore real email after import: UPDATE \`users\` SET \`email\`=" . $this->sqlString($rawEmail) . " WHERE \`id\`={$userId} AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM \`users\` WHERE \`email\`=" . $this->sqlString($rawEmail) . " AND \`id\`!={$userId}) _c);";
|
||||
$lines[] = "SAVEPOINT sp_{$userId};";
|
||||
|
||||
// users: synthetic email guarantees no unique-constraint conflict on INSERT
|
||||
$name = $this->sqlString($row->real_name ?: $username);
|
||||
$usernameQ = $this->sqlString($username);
|
||||
$emailQ = $this->sqlString($safeEmail);
|
||||
$passwordQ = $this->sqlString('$2y$12$' . Str::random(53));
|
||||
$isActive = ($row->active ?? 1) ? '1' : '0';
|
||||
$lastVisitQ = $lastVisitAt ? "'$lastVisitAt'" : 'NULL';
|
||||
|
||||
$lines[] = "INSERT IGNORE INTO `users`"
|
||||
. " (`id`, `username`, `username_changed_at`, `name`, `email`, `password`, `role`,"
|
||||
. " `is_active`, `needs_password_reset`, `legacy_password_algo`, `last_visit_at`, `created_at`, `updated_at`)"
|
||||
. " VALUES ({$userId}, {$usernameQ}, '{$now}', {$name}, {$emailQ}, {$passwordQ},"
|
||||
. " 'user', {$isActive}, 1, NULL, {$lastVisitQ}, '{$createdAt}', '{$now}');";
|
||||
$lines[] = "UPDATE `users` SET `username`={$usernameQ}, `username_changed_at`='{$now}',"
|
||||
. " `name`={$name}, `updated_at`='{$now}' WHERE `id` = {$userId};";
|
||||
|
||||
// user_profiles: INSERT IGNORE (FK-safe — silently skipped if parent missing) + UPDATE
|
||||
$lines[] = "INSERT IGNORE INTO `user_profiles`"
|
||||
. " (`user_id`, `about`, `avatar_legacy`, `cover_image`,"
|
||||
. " `country`, `country_code`, `language`, `website`, `gender`, `birthdate`, `updated_at`)"
|
||||
. " VALUES ({$userId}, {$about}, {$avatarLegacy}, {$coverImage},"
|
||||
. " {$country}, {$countryCode}, {$language}, {$website}, {$gender},"
|
||||
. " " . ($birthdate ? "'$birthdate'" : 'NULL') . ", '{$now}');";
|
||||
$lines[] = "UPDATE `user_profiles` SET"
|
||||
. " `about`={$about}, `avatar_legacy`={$avatarLegacy}, `cover_image`={$coverImage},"
|
||||
. " `country`={$country}, `country_code`={$countryCode}, `language`={$language},"
|
||||
. " `website`={$website}, `updated_at`='{$now}' WHERE `user_id` = {$userId};";
|
||||
|
||||
// user_statistics: same INSERT IGNORE + UPDATE pattern
|
||||
$lines[] = "INSERT IGNORE INTO `user_statistics`"
|
||||
. " (`user_id`, `uploads_count`, `downloads_received_count`,"
|
||||
. " `artwork_views_received_count`, `awards_received_count`, `updated_at`)"
|
||||
. " VALUES ({$userId}, {$uploads}, {$downloads}, {$pageviews}, {$awards}, '{$now}');";
|
||||
$lines[] = "UPDATE `user_statistics` SET"
|
||||
. " `uploads_count`={$uploads}, `downloads_received_count`={$downloads},"
|
||||
. " `artwork_views_received_count`={$pageviews}, `awards_received_count`={$awards},"
|
||||
. " `updated_at`='{$now}' WHERE `user_id` = {$userId};";
|
||||
|
||||
$lines[] = '';
|
||||
$recovered++;
|
||||
}
|
||||
|
||||
// List truly-orphaned user_ids (not in legacy DB either) as comments.
|
||||
$trulyOrphaned = $missingFromNew->diff($legacyRows->keys());
|
||||
if ($trulyOrphaned->isNotEmpty()) {
|
||||
$lines[] = '-- ============================================================';
|
||||
$lines[] = '-- The following user_ids were NOT found in legacy DB.';
|
||||
$lines[] = '-- Their artworks are truly orphaned and cannot be auto-recovered.';
|
||||
$lines[] = '-- ============================================================';
|
||||
foreach ($trulyOrphaned as $uid) {
|
||||
$lines[] = "-- user_id={$uid}";
|
||||
$skipped++;
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
$lines[] = 'COMMIT;';
|
||||
$lines[] = '';
|
||||
$lines[] = "-- Recovered: {$recovered} | Truly orphaned (no SQL generated): {$skipped}";
|
||||
|
||||
$sql = implode("\n", $lines) . "\n";
|
||||
|
||||
if (file_put_contents($path, $sql) === false) {
|
||||
$this->error("Could not write SQL file: {$path}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("SQL import script written to: {$path} ({$recovered} users)");
|
||||
if ($skipped > 0) {
|
||||
$this->warn("{$skipped} user_id(s) not found in legacy DB — listed as comments at the bottom of the SQL file.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
protected function checkUsernameDiff(int $chunkSize, ?string $outputPath, ?string $fixSqlPath): void
|
||||
{
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable) {
|
||||
$this->error('--check-usernames: legacy DB connection is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info('Comparing usernames between legacy and new DB…');
|
||||
$this->newLine();
|
||||
|
||||
$diffs = [];
|
||||
$matches = 0;
|
||||
$missing = 0;
|
||||
|
||||
// Stream legacy users in chunks; for each one look up the new users table by same ID.
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->orderBy('user_id')
|
||||
->chunk($chunkSize, function ($legacyUsers) use (&$diffs, &$matches, &$missing) {
|
||||
$ids = $legacyUsers->pluck('user_id')->all();
|
||||
|
||||
$newUsers = DB::table('users')
|
||||
->whereIn('id', $ids)
|
||||
->pluck('username', 'id'); // keyed by id
|
||||
|
||||
foreach ($legacyUsers as $lu) {
|
||||
$id = (int) $lu->user_id;
|
||||
|
||||
if (! $newUsers->has($id)) {
|
||||
$legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? ''))));
|
||||
$legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20);
|
||||
if (! $this->option('hide-missing')) {
|
||||
$this->line(sprintf(
|
||||
' <fg=gray>MISS</> id=%-8d legacy=<fg=yellow>%s</>',
|
||||
$id, $legacyUsername,
|
||||
));
|
||||
}
|
||||
$missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? ''))));
|
||||
$legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20);
|
||||
$newUsername = (string) $newUsers->get($id);
|
||||
|
||||
if ($legacyUsername !== $newUsername) {
|
||||
$this->line(sprintf(
|
||||
' <fg=red>FAIL</> id=%-8d legacy=<fg=yellow>%-20s</> new=<fg=cyan>%s</>',
|
||||
$id, $legacyUsername, $newUsername,
|
||||
));
|
||||
$diffs[] = [
|
||||
'user_id' => $id,
|
||||
'legacy_username' => $legacyUsername,
|
||||
'new_username' => $newUsername,
|
||||
'legacy_email' => strtolower(trim((string) ($lu->email ?? ''))),
|
||||
];
|
||||
} else {
|
||||
$matches++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if (empty($diffs) && $missing === 0) {
|
||||
$this->info("✓ All {$matches} checked username(s) match.");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn(count($diffs) . ' FAIL / ' . $missing . ' MISSING / ' . $matches . ' OK');
|
||||
|
||||
if ($outputPath) {
|
||||
$fp = fopen($outputPath, 'w');
|
||||
if ($fp === false) {
|
||||
$this->error("Could not open output file: {$outputPath}");
|
||||
return;
|
||||
}
|
||||
fputcsv($fp, ['user_id', 'legacy_username', 'new_username', 'legacy_email']);
|
||||
foreach ($diffs as $d) {
|
||||
fputcsv($fp, array_values($d));
|
||||
}
|
||||
fclose($fp);
|
||||
$this->info("Username diff written to: {$outputPath}");
|
||||
}
|
||||
|
||||
if ($fixSqlPath && ! empty($diffs)) {
|
||||
$lines = [];
|
||||
$lines[] = "-- Username fix: update new DB usernames to match normalized legacy usernames";
|
||||
$lines[] = "-- Generated: " . now()->toDateTimeString();
|
||||
$lines[] = "-- " . count($diffs) . " row(s) affected";
|
||||
$lines[] = "SET NAMES utf8mb4;";
|
||||
$lines[] = "USE `projekti_2026_skinbase`;";
|
||||
$lines[] = "START TRANSACTION;";
|
||||
$lines[] = '';
|
||||
foreach ($diffs as $d) {
|
||||
$newUsername = addslashes($d['new_username']);
|
||||
$legacyUsername = addslashes($d['legacy_username']);
|
||||
$lines[] = "-- id={$d['user_id']} old='{$newUsername}' -> new='{$legacyUsername}'";
|
||||
$lines[] = "UPDATE `users` SET `username` = '{$legacyUsername}', `updated_at` = NOW() WHERE `id` = {$d['user_id']} AND `username` = '{$newUsername}';";
|
||||
}
|
||||
$lines[] = '';
|
||||
$lines[] = 'COMMIT;';
|
||||
|
||||
$written = file_put_contents($fixSqlPath, implode("\n", $lines) . "\n");
|
||||
if ($written === false) {
|
||||
$this->error("Could not write SQL fix file: {$fixSqlPath}");
|
||||
} else {
|
||||
$this->info("Username fix SQL written to: {$fixSqlPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function sqlString(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return 'NULL';
|
||||
}
|
||||
// Escape single-quotes and backslashes for SQL.
|
||||
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
|
||||
return "'{$escaped}'";
|
||||
}
|
||||
|
||||
private function sqlDate($value, ?string $fallback): ?string
|
||||
{
|
||||
if (! $value) {
|
||||
return $fallback;
|
||||
}
|
||||
try {
|
||||
return \Carbon\Carbon::parse($value)->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private function safeInt($value): int
|
||||
{
|
||||
$n = (int) $value;
|
||||
return $n < 0 ? 0 : $n;
|
||||
}
|
||||
|
||||
private function normalizeLegacyGender(?string $value): ?string
|
||||
{
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'm', 'male' => 'M',
|
||||
'f', 'female' => 'F',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class ConfigureMeilisearchIndex extends Command
|
||||
'has_missing_thumbnails',
|
||||
'category',
|
||||
'content_type',
|
||||
'published_as_type',
|
||||
'tags',
|
||||
'author_id',
|
||||
'orientation',
|
||||
@@ -69,6 +70,9 @@ class ConfigureMeilisearchIndex extends Command
|
||||
{
|
||||
$prefix = config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) $this->option('index');
|
||||
$indexSettings = $this->configuredIndexSettings($indexName);
|
||||
$sortableAttributes = $this->configuredSortableAttributes($indexSettings);
|
||||
$filterableAttributes = $this->configuredFilterableAttributes($indexSettings);
|
||||
|
||||
/** @var MeilisearchClient $client */
|
||||
$client = app(MeilisearchClient::class);
|
||||
@@ -79,12 +83,12 @@ class ConfigureMeilisearchIndex extends Command
|
||||
|
||||
// ── Sortable attributes ───────────────────────────────────────────────
|
||||
$this->line(' → Updating sortableAttributes…');
|
||||
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
|
||||
$task = $index->updateSortableAttributes($sortableAttributes);
|
||||
$this->line(" Task uid: {$task['taskUid']}");
|
||||
|
||||
// ── Filterable attributes ─────────────────────────────────────────────
|
||||
$this->line(' → Updating filterableAttributes…');
|
||||
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
|
||||
$task2 = $index->updateFilterableAttributes($filterableAttributes);
|
||||
$this->line(" Task uid: {$task2['taskUid']}");
|
||||
|
||||
$this->info('Done. Meilisearch will process these tasks asynchronously.');
|
||||
@@ -92,4 +96,46 @@ class ConfigureMeilisearchIndex extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function configuredIndexSettings(string $indexName): array
|
||||
{
|
||||
$settings = config('scout.meilisearch.index-settings', []);
|
||||
|
||||
if (! is_array($settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$configured = $settings[$indexName] ?? [];
|
||||
|
||||
return is_array($configured) ? $configured : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $indexSettings
|
||||
* @return list<string>
|
||||
*/
|
||||
private function configuredSortableAttributes(array $indexSettings): array
|
||||
{
|
||||
$configured = $indexSettings['sortableAttributes'] ?? null;
|
||||
|
||||
return is_array($configured) && $configured !== []
|
||||
? array_values($configured)
|
||||
: self::SORTABLE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $indexSettings
|
||||
* @return list<string>
|
||||
*/
|
||||
private function configuredFilterableAttributes(array $indexSettings): array
|
||||
{
|
||||
$configured = $indexSettings['filterableAttributes'] ?? null;
|
||||
|
||||
return is_array($configured) && $configured !== []
|
||||
? array_values($configured)
|
||||
: self::FILTERABLE_ATTRIBUTES;
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Console/Commands/ExportLegacyPasswordsCommand.php
Normal file
86
app/Console/Commands/ExportLegacyPasswordsCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExportLegacyPasswordsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:export-legacy-passwords
|
||||
{--sql= : Path to write SQL update script (optional)}
|
||||
{--chunk=500 : Chunk size for processing}';
|
||||
|
||||
protected $description = 'Export legacy password hashes from legacy DB into a SQL file to update the new users table.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sqlPath = $this->option('sql') ?? base_path('scripts/legacy-passwords-export.sql');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Legacy DB connection is not available: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now()->format('Y-m-d H:i:s');
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '-- Legacy password export';
|
||||
$lines[] = '-- Generated: ' . $now;
|
||||
$lines[] = '-- Source: legacy DB (read-only)';
|
||||
$lines[] = '';
|
||||
$lines[] = 'SET NAMES utf8mb4;';
|
||||
$lines[] = 'USE `' . DB::getDatabaseName() . '`;';
|
||||
$lines[] = 'START TRANSACTION;';
|
||||
$lines[] = '';
|
||||
|
||||
$exported = 0;
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->select(['user_id', 'password2', 'password'])
|
||||
->orderBy('user_id')
|
||||
->chunk($chunk, function ($rows) use (&$lines, &$exported, $now) {
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) ($r->user_id ?? 0);
|
||||
$hash = trim((string) ($r->password2 ?: $r->password ?: ''));
|
||||
if ($id === 0 || $hash === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$algo = 'unknown';
|
||||
if (preg_match('/^\$2[aby]\$/', $hash)) {
|
||||
$algo = 'bcrypt';
|
||||
} elseif (preg_match('/^\$argon2/', $hash)) {
|
||||
$algo = 'argon2';
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $hash);
|
||||
|
||||
$lines[] = "-- user_id={$id} legacy_algo={$algo}";
|
||||
$lines[] = "SAVEPOINT sp_{$id};";
|
||||
$lines[] = "UPDATE `users` SET `password` = '{$escaped}', `legacy_password_algo` = '{$algo}', `needs_password_reset` = 1, `updated_at` = '{$now}' WHERE `id` = {$id};";
|
||||
$lines[] = '';
|
||||
|
||||
$exported++;
|
||||
}
|
||||
});
|
||||
|
||||
$lines[] = 'COMMIT;';
|
||||
$lines[] = '';
|
||||
$lines[] = "-- Exported: {$exported} user(s)";
|
||||
|
||||
$sql = implode("\n", $lines) . "\n";
|
||||
|
||||
if (file_put_contents($sqlPath, $sql) === false) {
|
||||
$this->error('Could not write SQL file: ' . $sqlPath);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Wrote ' . $exported . ' rows to: ' . $sqlPath);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
126
app/Console/Commands/FlagLegacyUsersForMigrationCommand.php
Normal file
126
app/Console/Commands/FlagLegacyUsersForMigrationCommand.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FlagLegacyUsersForMigrationCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:flag-legacy-users
|
||||
{--dry-run : Show what would be updated without writing to DB}
|
||||
{--chunk=500 : Chunk size for processing}';
|
||||
|
||||
protected $description = 'Set should_migrate=1 on legacy users that have any activity across 14 tables (artworks_comments, artworks_downloads, blog_articles, chat, favourites, featured_works, forum_posts, forum_topics, friends_list, news, news_comment, users_comments, users_opinions, wallz)';
|
||||
|
||||
/**
|
||||
* Activity tables to check — all confirmed to have a `user_id` column.
|
||||
*/
|
||||
private const ACTIVITY_TABLES = [
|
||||
'artworks_comments',
|
||||
'artworks_downloads',
|
||||
'blog_articles',
|
||||
'chat',
|
||||
'favourites',
|
||||
'featured_works',
|
||||
'forum_posts',
|
||||
'forum_topics',
|
||||
'friends_list',
|
||||
'news',
|
||||
'news_comment',
|
||||
'users_comments',
|
||||
'users_opinions',
|
||||
'wallz',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable) {
|
||||
$this->error('Legacy DB connection is not available.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written to the database.');
|
||||
}
|
||||
|
||||
$this->info('Scanning legacy users for activity…');
|
||||
$this->newLine();
|
||||
|
||||
$totalFlagged = 0;
|
||||
$totalSkipped = 0;
|
||||
$processed = 0;
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->orderBy('user_id')
|
||||
->chunk($chunkSize, function ($users) use ($dryRun, &$totalFlagged, &$totalSkipped, &$processed) {
|
||||
$ids = $users->pluck('user_id')->map(fn ($v) => (int) $v)->all();
|
||||
|
||||
// Collect all user_ids that have at least one row in any activity table.
|
||||
$activeIds = collect();
|
||||
|
||||
foreach (self::ACTIVITY_TABLES as $table) {
|
||||
$found = DB::connection('legacy')
|
||||
->table($table)
|
||||
->whereIn('user_id', $ids)
|
||||
->distinct()
|
||||
->pluck('user_id')
|
||||
->map(fn ($v) => (int) $v);
|
||||
|
||||
$activeIds = $activeIds->merge($found);
|
||||
}
|
||||
|
||||
$activeIds = $activeIds->unique()->values();
|
||||
|
||||
// Report per-user
|
||||
foreach ($users as $u) {
|
||||
$id = (int) $u->user_id;
|
||||
$isActive = $activeIds->contains($id);
|
||||
|
||||
if ($isActive) {
|
||||
$this->line(sprintf(
|
||||
' <fg=green>ACTIVE</> id=%-8d uname=<fg=yellow>%s</>',
|
||||
$id,
|
||||
$u->uname ?? '(no username)',
|
||||
));
|
||||
$totalFlagged++;
|
||||
} else {
|
||||
$totalSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk update in one query
|
||||
if (! $dryRun && $activeIds->isNotEmpty()) {
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->whereIn('user_id', $activeIds->all())
|
||||
->update(['should_migrate' => 1]);
|
||||
}
|
||||
|
||||
$processed += count($ids);
|
||||
$this->line(sprintf(
|
||||
' … processed <fg=cyan>%d</> users so far (flagged: <fg=green>%d</>, skipped: %d)',
|
||||
$processed, $totalFlagged, $totalSkipped,
|
||||
));
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done. %d user(s) flagged as should_migrate=1 / %d user(s) left at 0.',
|
||||
$totalFlagged,
|
||||
$totalSkipped,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] Nothing was written to the database.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateAiBiographyJob;
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyInputBuilder;
|
||||
use App\Services\AiBiography\AiBiographyPromptBuilder;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Generate AI biographies for one creator or refresh stale ones.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:generate {user_id}
|
||||
* php artisan ai-biography:generate
|
||||
* php artisan ai-biography:generate --stale
|
||||
* php artisan ai-biography:generate --stale --limit=50 --queue
|
||||
*/
|
||||
final class GenerateAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:generate
|
||||
{user_id? : The ID of a single creator to generate a biography for}
|
||||
{--stale : Refresh all biographies whose source hash has changed}
|
||||
{--all : Alias for generating only missing biographies ordered by latest public upload}
|
||||
{--provider= : Override the configured LLM provider for this run (vision_gateway|vision|gemini|home)}
|
||||
{--prompt : Output the initial prompt payload that would be sent for each processed creator}
|
||||
{--result : Output the generated biography text to the console after a successful inline run}
|
||||
{--skip-existing : Skip creators who already have an active AI biography}
|
||||
{--limit=100 : Maximum number of creators to process in batch mode}
|
||||
{--chunk=50 : Query chunk size for batch operations}
|
||||
{--force : Overwrite user-edited biographies}
|
||||
{--queue : Dispatch jobs to the queue instead of running inline}
|
||||
{--dry-run : List candidates without generating}';
|
||||
|
||||
protected $description = 'Generate missing AI biographies or refresh stale ones';
|
||||
|
||||
public function handle(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
): int
|
||||
{
|
||||
if (! config('ai_biography.enabled', true)) {
|
||||
$this->warn('AI Biography is disabled (AI_BIOGRAPHY_ENABLED=false).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$provider = $this->resolveProviderOverride();
|
||||
|
||||
if ($provider === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (is_string($provider)) {
|
||||
config(['ai_biography.provider_override' => $provider]);
|
||||
config(['ai_biography.provider' => $provider]);
|
||||
$this->line("Using AI biography provider override: {$provider}");
|
||||
}
|
||||
|
||||
$userId = $this->argument('user_id');
|
||||
$stale = (bool) $this->option('stale');
|
||||
$all = (bool) $this->option('all');
|
||||
$prompt = (bool) $this->option('prompt');
|
||||
$result = (bool) $this->option('result');
|
||||
$skipExisting = (bool) $this->option('skip-existing');
|
||||
$force = (bool) $this->option('force');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->handleSingle((int) $userId, $biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($stale) {
|
||||
return $this->handleStale($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $chunk, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
$this->line('`--all` is now an alias for the default missing-only batch mode.');
|
||||
}
|
||||
|
||||
return $this->handleMissing($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
private function resolveProviderOverride(): string|false|null
|
||||
{
|
||||
$rawProvider = $this->option('provider');
|
||||
|
||||
if ($rawProvider === null || trim((string) $rawProvider) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = strtolower(trim((string) $rawProvider));
|
||||
|
||||
return match ($provider) {
|
||||
'vision_gateway', 'vision', 'local' => 'vision_gateway',
|
||||
'gemini' => 'gemini',
|
||||
'home', 'lmstudio', 'lm_studio' => 'home',
|
||||
default => $this->invalidProvider($provider),
|
||||
};
|
||||
}
|
||||
|
||||
private function invalidProvider(string $provider): false
|
||||
{
|
||||
$this->error("Invalid provider [{$provider}]. Supported values: vision_gateway, vision, gemini, home.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function handleSingle(
|
||||
int $userId,
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$user = User::query()->where('id', $userId)->where('is_active', true)->whereNull('deleted_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found or inactive.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line("Processing user #{$userId} ({$user->username})");
|
||||
|
||||
if ($skipExisting && $this->hasActiveBiography($userId)) {
|
||||
$this->info(' ↷ skipped_existing_active');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[dry-run] Would generate biography.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn('[--result] is only available for inline runs. The job was queued, so no biography text is available yet.');
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch($userId, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$this->info("Queued biography generation for #{$userId}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info(" ✓ {$result['action']}");
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography($userId);
|
||||
}
|
||||
} else {
|
||||
$this->warn(" ✗ {$result['action']}: " . implode(', ', $result['errors']));
|
||||
}
|
||||
|
||||
return $result['success'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function handleStale(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
int $chunk,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
if ($skipExisting) {
|
||||
$this->warn('`--skip-existing` is ignored with `--stale` because stale refresh only applies to creators who already have an active AI biography.');
|
||||
}
|
||||
|
||||
$this->info("Scanning for stale AI biographies (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->chunkById($chunk, function ($users) use ($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider, $showPrompt, $showResult, &$processed, &$queued, &$generated, &$skipped): bool {
|
||||
foreach ($users as $user) {
|
||||
if ($processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $biographies->isStale($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username} – stale");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleMissing(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$this->info("Generating missing AI biographies ordered by latest public upload (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$latestUploads = DB::table('artworks')
|
||||
->selectRaw('user_id, MAX(published_at) as latest_uploaded_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('user_id');
|
||||
|
||||
$users = User::query()
|
||||
->leftJoinSub($latestUploads, 'latest_public_artwork', function ($join): void {
|
||||
$join->on('latest_public_artwork.user_id', '=', 'users.id');
|
||||
})
|
||||
->select('users.*')
|
||||
->where('users.is_active', true)
|
||||
->whereNull('users.deleted_at')
|
||||
->whereNotExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->orderByDesc('latest_public_artwork.latest_uploaded_at')
|
||||
->orderByDesc('users.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username}");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->generate($user);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function outputPromptPreview(
|
||||
User $user,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
string $indent = ' ',
|
||||
): void {
|
||||
$input = $inputBuilder->build($user);
|
||||
$qualityTier = $inputBuilder->qualityTier($input);
|
||||
$meetsThreshold = $inputBuilder->meetsMinimumThreshold($input);
|
||||
|
||||
$this->line($indent . 'Prompt preview');
|
||||
$this->line($indent . ' Provider : ' . $this->resolvedProvider());
|
||||
$this->line($indent . ' Quality tier : ' . $qualityTier);
|
||||
$this->line($indent . ' Meets threshold: ' . ($meetsThreshold ? 'yes' : 'no'));
|
||||
|
||||
if (! $meetsThreshold) {
|
||||
$this->line($indent . ' No prompt will be sent because this profile is below the minimum generation threshold.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $promptBuilder->build($input, strict: false, sparse: $qualityTier === 'sparse');
|
||||
$systemPrompt = (string) ($payload['messages'][0]['content'] ?? '');
|
||||
$userPrompt = (string) ($payload['messages'][1]['content'] ?? '');
|
||||
|
||||
$this->line($indent . ' Prompt version : ' . (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION));
|
||||
$this->line($indent . ' Max tokens : ' . (string) ($payload['max_tokens'] ?? 'n/a'));
|
||||
$this->line($indent . ' Temperature : ' . (string) ($payload['temperature'] ?? 'n/a'));
|
||||
$this->line($indent . ' System prompt:');
|
||||
$this->writeIndentedBlock($systemPrompt, $indent . ' ');
|
||||
$this->line($indent . ' User prompt:');
|
||||
$this->writeIndentedBlock($userPrompt, $indent . ' ');
|
||||
}
|
||||
|
||||
private function writeIndentedBlock(string $text, string $indent): void
|
||||
{
|
||||
foreach (preg_split("/\r\n|\r|\n/", trim($text)) ?: [] as $line) {
|
||||
$this->line($indent . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvedProvider(): string
|
||||
{
|
||||
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
|
||||
|
||||
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if (trim((string) config('ai_biography.together.api_key', '')) !== ''
|
||||
&& trim((string) config('ai_biography.together.model', '')) !== '') {
|
||||
return 'together';
|
||||
}
|
||||
|
||||
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
|
||||
|
||||
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
|
||||
}
|
||||
|
||||
private function outputGeneratedBiography(int $userId, string $indent = ' '): void
|
||||
{
|
||||
$record = CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$text = trim((string) ($record?->text ?? ''));
|
||||
|
||||
if ($text === '') {
|
||||
$this->line($indent . 'Generated biography text: n/a');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line($indent . 'Generated biography text:');
|
||||
foreach (preg_split('/\r\n|\r|\n/', $text) ?: [] as $line) {
|
||||
$this->line($indent . ' ' . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveBiography(int $userId): bool
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
192
app/Console/Commands/GenerateArtworkAiSuggestionsCommand.php
Normal file
192
app/Console/Commands/GenerateArtworkAiSuggestionsCommand.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Services\Studio\StudioAiAssistService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class GenerateArtworkAiSuggestionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:ai-suggest
|
||||
{artwork_id? : Generate suggestions for a single artwork}
|
||||
{--after-id=0 : Skip artworks with ID <= this value}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=50 : Database chunk size}
|
||||
{--provider= : Override tag suggestion provider (lm_studio|together)}
|
||||
{--force : Regenerate even when suggestions already exist}
|
||||
{--queue : Queue generation instead of running inline}
|
||||
{--skip-existing : Skip artworks that already have stored tag suggestions}';
|
||||
|
||||
protected $description = 'Generate and store studio AI suggestions for artworks, including 10-15 suggested tags from the md thumbnail';
|
||||
|
||||
public function handle(StudioAiAssistService $aiAssist): int
|
||||
{
|
||||
$artworkId = $this->argument('artwork_id');
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunk = max(1, min(200, (int) $this->option('chunk')));
|
||||
$provider = $this->normalizeProviderOption($this->option('provider'));
|
||||
$force = (bool) $this->option('force');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$skipExisting = (bool) $this->option('skip-existing');
|
||||
|
||||
if ($provider === null && $this->option('provider') !== null) {
|
||||
$this->error('Invalid provider. Supported values: lm_studio, together.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query = Artwork::query()
|
||||
->with('artworkAiAssist')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('hash')
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->where('id', (int) $artworkId);
|
||||
} else {
|
||||
$query->where('id', '>', $afterId);
|
||||
}
|
||||
|
||||
$query->chunkById($chunk, function ($artworks) use (&$processed, &$generated, &$skipped, &$failed, $limit, $skipExisting, $force, $queue, $provider, $aiAssist) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
$hasStoredTags = $assist instanceof ArtworkAiAssist
|
||||
&& is_array($assist->tag_suggestions_json)
|
||||
&& $assist->tag_suggestions_json !== [];
|
||||
|
||||
if ($skipExisting && $hasStoredTags && ! $force) {
|
||||
$this->line("[#{$artwork->id}] skip existing suggestions");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line("[#{$artwork->id}] {$artwork->title}");
|
||||
|
||||
try {
|
||||
if ($queue) {
|
||||
$result = $aiAssist->queueAnalysis($artwork, $force, 'tags', $provider);
|
||||
$this->line(" queued ({$result->status})");
|
||||
} else {
|
||||
$result = $aiAssist->analyze($artwork, $force, 'tags', $provider);
|
||||
$this->line(" {$result->status}");
|
||||
$this->renderInlineResult($result);
|
||||
}
|
||||
|
||||
if ($result->status === ArtworkAiAssist::STATUS_FAILED) {
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$generated++;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error(' failed: ' . $exception->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done. processed={$processed} generated={$generated} skipped={$skipped} failed={$failed}");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeProviderOption(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderInlineResult(ArtworkAiAssist $assist): void
|
||||
{
|
||||
if ($assist->status === ArtworkAiAssist::STATUS_FAILED) {
|
||||
if (is_string($assist->error_message) && $assist->error_message !== '') {
|
||||
$this->error(' error: ' . $assist->error_message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($assist->status !== ArtworkAiAssist::STATUS_READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['request'] ?? []) : [];
|
||||
$tagGeneration = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['tag_generation'] ?? []) : [];
|
||||
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
|
||||
|
||||
$provider = $tagGeneration['provider'] ?? $request['provider'] ?? config('vision.tag_suggestions.provider');
|
||||
if (is_string($provider) && $provider !== '') {
|
||||
$this->line(' provider: ' . $provider);
|
||||
}
|
||||
|
||||
$titles = collect((array) $assist->title_suggestions_json)
|
||||
->pluck('text')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->take(3)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($titles !== []) {
|
||||
$this->line(' titles: ' . implode(' | ', $titles));
|
||||
}
|
||||
|
||||
$tags = collect((array) $assist->tag_suggestions_json)
|
||||
->pluck('tag')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->take(12)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($tags !== []) {
|
||||
$this->line(' tags: ' . implode(', ', $tags));
|
||||
}
|
||||
|
||||
$contentType = $categorySuggestions['content_type']['value'] ?? null;
|
||||
$category = $categorySuggestions['category']['value'] ?? null;
|
||||
|
||||
if (is_string($contentType) && $contentType !== '') {
|
||||
$line = ' content type: ' . $contentType;
|
||||
if (is_string($category) && $category !== '') {
|
||||
$line .= ' | category: ' . $category;
|
||||
}
|
||||
$this->line($line);
|
||||
} elseif (is_string($category) && $category !== '') {
|
||||
$this->line(' category: ' . $category);
|
||||
}
|
||||
|
||||
$description = collect((array) $assist->description_suggestions_json)
|
||||
->pluck('text')
|
||||
->first(fn (mixed $value): bool => is_string($value) && trim($value) !== '');
|
||||
|
||||
if (is_string($description) && $description !== '') {
|
||||
$this->line(' description: ' . Str::limit($description, 140, '...'));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
25
app/Console/Commands/HealthSchedulerTickCommand.php
Normal file
25
app/Console/Commands/HealthSchedulerTickCommand.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Throwable;
|
||||
|
||||
class HealthSchedulerTickCommand extends Command
|
||||
{
|
||||
protected $signature = 'health:tick';
|
||||
protected $description = 'Write a scheduler heartbeat timestamp to Redis for health:check --only=scheduler.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
Redis::setex('health:scheduler_last_tick', 300, time());
|
||||
} catch (Throwable $e) {
|
||||
// Non-fatal — never block the scheduler.
|
||||
$this->warn('health:tick could not write to Redis: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
97
app/Console/Commands/InspectAiBiographyCommand.php
Normal file
97
app/Console/Commands/InspectAiBiographyCommand.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Inspect the full AI biography record and input payload for a creator.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:inspect {user_id}
|
||||
*/
|
||||
final class InspectAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:inspect
|
||||
{user_id : The ID of the creator to inspect}';
|
||||
|
||||
protected $description = 'Inspect AI biography record and normalized input payload for a creator';
|
||||
|
||||
public function handle(AiBiographyService $biographies): int
|
||||
{
|
||||
$userId = (int) $this->argument('user_id');
|
||||
|
||||
$user = User::query()
|
||||
->where('id', $userId)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found or inactive.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$data = $biographies->adminInspect($user);
|
||||
|
||||
$this->line('');
|
||||
$this->info("=== AI Biography Inspect — User #{$userId} ({$user->username}) ===");
|
||||
$this->line('');
|
||||
|
||||
// ── Input quality ────────────────────────────────────────────────────
|
||||
$this->comment('── Input Quality ─────────────────────────');
|
||||
$this->line(' Quality tier : ' . ($data['quality_tier'] ?? 'N/A'));
|
||||
$this->line(' Meets threshold : ' . ($data['meets_threshold'] ? 'yes' : 'no'));
|
||||
$this->line(' Source hash live : ' . mb_substr((string) ($data['source_hash_live'] ?? ''), 0, 16) . '...');
|
||||
$this->line(' Is stale : ' . ($data['is_stale'] ? 'yes' : 'no'));
|
||||
$this->line('');
|
||||
|
||||
// ── Stored record ────────────────────────────────────────────────────
|
||||
$record = $data['record'];
|
||||
$this->comment('── Stored Biography Record ───────────────');
|
||||
if ($record === null) {
|
||||
$this->line(' No active biography stored.');
|
||||
} else {
|
||||
$this->line(' ID : ' . ($record['id'] ?? 'N/A'));
|
||||
$this->line(' Status : ' . ($record['status'] ?? 'N/A'));
|
||||
$this->line(' Prompt version : ' . ($record['prompt_version'] ?? 'N/A'));
|
||||
$this->line(' Input tier : ' . ($record['input_quality_tier'] ?? 'N/A'));
|
||||
$this->line(' Generation reason: ' . ($record['generation_reason'] ?? 'N/A'));
|
||||
$this->line(' Model : ' . ($record['model'] ?? 'N/A'));
|
||||
$this->line(' Is active : ' . ($record['is_active'] ? 'yes' : 'no'));
|
||||
$this->line(' Is hidden : ' . ($record['is_hidden'] ? 'yes' : 'no'));
|
||||
$this->line(' Is user-edited : ' . ($record['is_user_edited'] ? 'yes' : 'no'));
|
||||
$this->line(' Needs review : ' . ($record['needs_review'] ? 'YES' : 'no'));
|
||||
$this->line(' Source hash : ' . mb_substr((string) ($record['source_hash'] ?? ''), 0, 16) . '...');
|
||||
$this->line(' Generated at : ' . ($record['generated_at'] ?? 'N/A'));
|
||||
$this->line(' Last attempted : ' . ($record['last_attempted_at'] ?? 'N/A'));
|
||||
$this->line(' Last error code : ' . ($record['last_error_code'] ?? 'none'));
|
||||
$this->line(' Last error reason: ' . ($record['last_error_reason'] ?? 'none'));
|
||||
$this->line('');
|
||||
$this->comment('── Biography Text ────────────────────────');
|
||||
$this->line(' ' . wordwrap((string) ($record['text'] ?? '(empty)'), 100, "\n "));
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// ── Input payload ─────────────────────────────────────────────────────
|
||||
if ($this->option('verbose') || $this->output->isVerbose()) {
|
||||
$this->comment('── Normalized Input Payload ──────────────');
|
||||
$payload = $data['input_payload'] ?? [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$display = is_array($value) ? json_encode($value) : (string) $value;
|
||||
$this->line(sprintf(' %-26s: %s', $key, $display));
|
||||
}
|
||||
$this->line('');
|
||||
} else {
|
||||
$this->line(' Tip: run with -v to see the full normalized input payload.');
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -35,6 +32,12 @@ class PublishScheduledArtworksCommand extends Command
|
||||
|
||||
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkPublicationService $publicationService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@@ -42,13 +45,8 @@ class PublishScheduledArtworksCommand extends Command
|
||||
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = Artwork::query()
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||
$result = $this->publicationService->publishDueScheduled($limit, $now);
|
||||
$candidates = $result['candidates'];
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled artworks due for publishing.');
|
||||
@@ -67,50 +65,12 @@ class PublishScheduledArtworksCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $now, &$published) {
|
||||
// Re-fetch with lock to avoid double-publish in concurrent runs
|
||||
$artwork = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
// Already published or status changed – skip
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
|
||||
$artwork->published_at = $now;
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->save();
|
||||
|
||||
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||
if (method_exists($artwork, 'searchable')) {
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $artwork->user_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
|
||||
} catch (\Throwable) {}
|
||||
$artwork = $this->publicationService->publishIfDue($candidate, $now);
|
||||
|
||||
if ($artwork->artwork_status === 'published') {
|
||||
$published++;
|
||||
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||
});
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
|
||||
|
||||
96
app/Console/Commands/ReviewQueueAiBiographyCommand.php
Normal file
96
app/Console/Commands/ReviewQueueAiBiographyCommand.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* List AI biographies that are candidates for admin review.
|
||||
*
|
||||
* Review queue candidates include:
|
||||
* – Biographies with needs_review = true (new AI draft available for user-edited bio)
|
||||
* – Biographies with status = 'needs_review'
|
||||
* – Biographies with recent generation failures (last_error_code set)
|
||||
* – Biographies with input_quality_tier = 'sparse' that still generated
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:review-queue
|
||||
* php artisan ai-biography:review-queue --tier=sparse
|
||||
* php artisan ai-biography:review-queue --failed
|
||||
* php artisan ai-biography:review-queue --limit=50
|
||||
*/
|
||||
final class ReviewQueueAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:review-queue
|
||||
{--tier= : Filter by input_quality_tier (rich|medium|sparse)}
|
||||
{--failed : Show only records with a last_error_code}
|
||||
{--needs-review : Show only records flagged needs_review=true}
|
||||
{--limit=100 : Maximum records to display}';
|
||||
|
||||
protected $description = 'List AI biography records that are candidates for review or manual inspection';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$query = CreatorAiBiography::query()
|
||||
->with('user:id,username,email')
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($this->option('failed')) {
|
||||
$query->whereNotNull('last_error_code');
|
||||
} elseif ($this->option('needs-review')) {
|
||||
$query->where('needs_review', true);
|
||||
} else {
|
||||
// Default: union of all review-worthy states.
|
||||
$query->where(fn ($q) => $q
|
||||
->where('needs_review', true)
|
||||
->orWhere('status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
|
||||
->orWhereNotNull('last_error_code')
|
||||
->orWhere('input_quality_tier', CreatorAiBiography::TIER_SPARSE)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->option('tier')) {
|
||||
$query->where('input_quality_tier', $this->option('tier'));
|
||||
}
|
||||
|
||||
$records = $query->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
$this->info('No review-queue candidates found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info("=== AI Biography Review Queue ({$records->count()} records) ===");
|
||||
$this->line('');
|
||||
|
||||
$rows = $records->map(fn ($r) => [
|
||||
$r->id,
|
||||
$r->user?->username ?? "user#{$r->user_id}",
|
||||
$r->status,
|
||||
$r->input_quality_tier ?? '-',
|
||||
$r->is_user_edited ? 'edited' : '-',
|
||||
$r->needs_review ? 'YES' : '-',
|
||||
$r->last_error_code ?? '-',
|
||||
$r->updated_at?->diffForHumans() ?? '-',
|
||||
])->all();
|
||||
|
||||
$this->table(
|
||||
['ID', 'Username', 'Status', 'Tier', 'User-Edited', 'NeedsReview', 'LastError', 'Updated'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
$this->line('');
|
||||
$this->line('Tip: run `php artisan ai-biography:inspect {user_id}` for full details on any record.');
|
||||
$this->line(' run `php artisan ai-biography:generate {user_id} --force` to regenerate.');
|
||||
$this->line('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
138
app/Console/Commands/ValidateAiBiographyCommand.php
Normal file
138
app/Console/Commands/ValidateAiBiographyCommand.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyValidator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Validate stored AI biographies against the current validator rules.
|
||||
*
|
||||
* Useful after hardening the validator (e.g. v1.1 upgrade) to identify bios
|
||||
* generated under older, looser rules that no longer pass.
|
||||
*
|
||||
* Flagged biographies have needs_review=true set but are NOT hidden or deleted.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:validate
|
||||
* php artisan ai-biography:validate {user_id}
|
||||
* php artisan ai-biography:validate --dry-run
|
||||
*/
|
||||
final class ValidateAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:validate
|
||||
{user_id? : Validate biography for a single creator}
|
||||
{--dry-run : Report failures without updating records}
|
||||
{--limit=500 : Maximum number of records to check}';
|
||||
|
||||
protected $description = 'Validate stored AI biographies against current validator rules';
|
||||
|
||||
public function handle(AiBiographyValidator $validator): int
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->handleSingle((int) $userId, $validator, $dryRun);
|
||||
}
|
||||
|
||||
return $this->handleBatch($validator, $dryRun, $limit);
|
||||
}
|
||||
|
||||
private function handleSingle(int $userId, AiBiographyValidator $validator, bool $dryRun): int
|
||||
{
|
||||
$user = User::query()->where('id', $userId)->whereNull('deleted_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$record = CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', true)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($record === null) {
|
||||
$this->line("User #{$userId} ({$user->username}): no active biography.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($record->is_user_edited) {
|
||||
$this->line("User #{$userId} ({$user->username}): biography is user-edited — skipping.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$errors = $validator->validate(
|
||||
(string) $record->text,
|
||||
$record->input_quality_tier ?? 'rich',
|
||||
);
|
||||
|
||||
if ($errors === []) {
|
||||
$this->info("User #{$userId} ({$user->username}): ✓ valid");
|
||||
} else {
|
||||
$this->warn("User #{$userId} ({$user->username}): ✗ " . implode('; ', $errors));
|
||||
|
||||
if (! $dryRun) {
|
||||
$record->update(['needs_review' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleBatch(AiBiographyValidator $validator, bool $dryRun, int $limit): int
|
||||
{
|
||||
$this->info('Validating stored AI biographies against current rules...');
|
||||
|
||||
$checked = 0;
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
CreatorAiBiography::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('text')
|
||||
->where('is_user_edited', false)
|
||||
->whereIn('status', [
|
||||
CreatorAiBiography::STATUS_GENERATED,
|
||||
CreatorAiBiography::STATUS_APPROVED,
|
||||
])
|
||||
->limit($limit)
|
||||
->chunkById(100, function ($records) use ($validator, $dryRun, &$checked, &$passed, &$failed, &$skipped): void {
|
||||
foreach ($records as $record) {
|
||||
$checked++;
|
||||
|
||||
$errors = $validator->validate(
|
||||
(string) $record->text,
|
||||
$record->input_quality_tier ?? 'rich',
|
||||
);
|
||||
|
||||
if ($errors === []) {
|
||||
$passed++;
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn(" [user:{$record->user_id}] id:{$record->id} — " . implode('; ', $errors));
|
||||
|
||||
if (! $dryRun) {
|
||||
$record->update(['needs_review' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$dryTag = $dryRun ? ' [dry-run — no records updated]' : '';
|
||||
$this->info("Done. checked={$checked} passed={$passed} failed/flagged={$failed}{$dryTag}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\GenerateArtworkAiSuggestionsCommand;
|
||||
use App\Console\Commands\SyncCountriesCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\DispatchCollectionMaintenanceCommand;
|
||||
@@ -79,13 +80,22 @@ class Kernel extends ConsoleKernel
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
GenerateArtworkAiSuggestionsCommand::class,
|
||||
SyncCountriesCommand::class,
|
||||
\App\Console\Commands\AuditMissingMigratedUsersCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
MetricsSnapshotHourlyCommand::class,
|
||||
RecalculateHeatCommand::class,
|
||||
\App\Console\Commands\RebuildCreatorErasCommand::class,
|
||||
\App\Console\Commands\AuditOrphanedArtworksCommand::class,
|
||||
\App\Console\Commands\FlagLegacyUsersForMigrationCommand::class,
|
||||
\App\Console\Commands\ExportLegacyPasswordsCommand::class,
|
||||
\App\Console\Commands\GenerateAiBiographyCommand::class,
|
||||
\App\Console\Commands\InspectAiBiographyCommand::class,
|
||||
\App\Console\Commands\ReviewQueueAiBiographyCommand::class,
|
||||
\App\Console\Commands\ValidateAiBiographyCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -149,7 +159,7 @@ class Kernel extends ConsoleKernel
|
||||
// Step 1: compute per-artwork scores every hour at :05
|
||||
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->withoutOverlapping()->runInBackground();
|
||||
|
||||
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||
@@ -198,6 +208,14 @@ class Kernel extends ConsoleKernel
|
||||
->name('sync-countries')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Scheduler health heartbeat ──────────────────────────────────────
|
||||
// Stamps a Redis key each minute so `health:check --only=scheduler` can
|
||||
// verify cron is alive. The key expires after 5 minutes so a dead cron
|
||||
// will naturally cause the check to warn/fail.
|
||||
$schedule->command('health:tick')
|
||||
->everyMinute()
|
||||
->name('health-scheduler-tick');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
122
app/Http/Controllers/Api/AiBiographyController.php
Normal file
122
app/Http/Controllers/Api/AiBiographyController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\GenerateAiBiographyJob;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Creator-facing AI biography endpoints.
|
||||
*
|
||||
* All write routes require the authenticated user to be the profile owner.
|
||||
* Reads are restricted to the authenticated owner (public rendering is handled
|
||||
* via ProfileJourneyController / ProfileApiController payloads).
|
||||
*/
|
||||
final class AiBiographyController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/generate
|
||||
* Dispatch an async generation job for the authenticated user.
|
||||
*/
|
||||
public function generate(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, false)
|
||||
->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Biography generation queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/regenerate
|
||||
* Force-regenerate (replaces existing non-user-edited biography).
|
||||
*/
|
||||
public function regenerate(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, true)
|
||||
->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Biography regeneration queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/creator/profile/ai-biography
|
||||
* Creator edits their biography text.
|
||||
*/
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => ['required', 'string', 'min:30', 'max:1200'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->updateText($user, $validated['text']);
|
||||
|
||||
return response()->json(['message' => 'Biography updated.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/hide
|
||||
*/
|
||||
public function hide(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->hide($user);
|
||||
|
||||
return response()->json(['message' => 'Biography hidden.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/creator/profile/ai-biography/show
|
||||
*/
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$this->biographies->show($user);
|
||||
|
||||
return response()->json(['message' => 'Biography made visible.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/creator/profile/ai-biography
|
||||
* Return the authenticated creator's current biography status and metadata.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$payload = $this->biographies->creatorStatusPayload($user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,11 @@ final class LeaderboardController extends Controller
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function worlds(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_WORLD, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Http/Controllers/Api/ProfileAiBiographyController.php
Normal file
47
app/Http/Controllers/Api/ProfileAiBiographyController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Public read endpoint for a creator's AI biography.
|
||||
*
|
||||
* GET /api/profile/{username}/ai-biography
|
||||
*
|
||||
* Returns null data if no visible biography exists (hidden, failed, or not yet generated).
|
||||
* Never triggers generation — only serves stored text.
|
||||
*/
|
||||
final class ProfileAiBiographyController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
$user = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->biographies->publicPayload($user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $payload,
|
||||
'meta' => [
|
||||
'username' => (string) $user->username,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadController extends Controller
|
||||
@@ -559,7 +560,7 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -584,6 +585,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
@@ -660,6 +664,7 @@ final class UploadController extends Controller
|
||||
$artwork->save();
|
||||
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
|
||||
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
@@ -754,7 +759,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -776,6 +781,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
if (! ctype_digit($id)) {
|
||||
@@ -797,6 +805,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
$artwork = $reviews->submit($group, $artwork, $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class ArtworkDownloadController extends Controller
|
||||
@@ -155,10 +156,31 @@ final class ArtworkDownloadController extends Controller
|
||||
$name = 'artwork';
|
||||
}
|
||||
|
||||
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
|
||||
$name .= '.' . $ext;
|
||||
$baseName = pathinfo($name, PATHINFO_FILENAME);
|
||||
$baseName = trim((string) $baseName, ". \t\n\r\0\x0B");
|
||||
if ($baseName === '') {
|
||||
$baseName = 'artwork';
|
||||
}
|
||||
|
||||
return $name;
|
||||
$brandSuffix = $this->downloadBrandSuffix();
|
||||
|
||||
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
|
||||
$baseName .= ' (' . $brandSuffix . ')';
|
||||
}
|
||||
|
||||
return $baseName . '.' . $ext;
|
||||
}
|
||||
|
||||
private function downloadBrandSuffix(): string
|
||||
{
|
||||
$host = (string) parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$host = strtolower(trim($host));
|
||||
$host = preg_replace('/^www\./', '', $host) ?? '';
|
||||
|
||||
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
|
||||
return 'skinbase.top';
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user = $request->authenticatedUser();
|
||||
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
|
||||
$request->session()->put('username_login_upgrade', true);
|
||||
|
||||
return redirect()->route('setup.email.create')
|
||||
->with('status', 'Add and verify your email address to continue setup.');
|
||||
}
|
||||
|
||||
$request->session()->forget('username_login_upgrade');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Auth\DisposableEmailService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -101,45 +102,47 @@ class RegisteredUserController extends Controller
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($user && $user->email_verified_at !== null) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
if ($user && $user->hasCompletedOnboarding()) {
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['email' => 'An account with this email already exists.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$user = User::query()->create([
|
||||
$user = new User();
|
||||
$user->forceFill([
|
||||
'username' => null,
|
||||
'name' => Str::before($email, '@'),
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'is_active' => true,
|
||||
'email_verified_at' => null,
|
||||
'onboarding_step' => 'verified',
|
||||
'needs_password_reset' => true,
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
$user->save();
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'email_verified_at' => $user->email_verified_at,
|
||||
'is_active' => true,
|
||||
'onboarding_step' => strtolower((string) ($user->onboarding_step ?? '')) === 'password' ? 'password' : 'verified',
|
||||
'needs_password_reset' => strtolower((string) ($user->onboarding_step ?? '')) === 'password'
|
||||
? (bool) $user->needs_password_reset
|
||||
: true,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($this->isWithinEmailCooldown($user)) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
|
||||
Auth::login($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|
||||
|| (bool) $user->needs_password_reset;
|
||||
|
||||
$token = $this->verificationTokenService->createForUser((int) $user->id);
|
||||
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: (int) $user->id,
|
||||
ip: $ip
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
return redirect(route($needsPasswordSetup ? 'setup.password.create' : 'setup.username.create', absolute: false))
|
||||
->with('status', $needsPasswordSetup
|
||||
? 'Continue with password setup.'
|
||||
: 'Continue with username setup.');
|
||||
}
|
||||
|
||||
public function resendVerification(Request $request): RedirectResponse
|
||||
|
||||
72
app/Http/Controllers/Auth/SetupEmailController.php
Normal file
72
app/Http/Controllers/Auth/SetupEmailController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\EmailChangedSecurityAlertMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupEmailController extends Controller
|
||||
{
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user->hasCompletedOnboarding()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('auth.setup-email', [
|
||||
'email' => User::isEmailLoginUpgradePlaceholder((string) $user->email)
|
||||
? ''
|
||||
: strtolower(trim((string) $user->email)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestCode(Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$validated = $request->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class, 'email')->ignore((int) $user->id),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (User::isEmailLoginUpgradePlaceholder((string) $value)) {
|
||||
$fail('Please enter a real email address you can access.');
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
$newEmail = strtolower(trim((string) $validated['email']));
|
||||
$oldEmail = strtolower((string) ($user->email ?? ''));
|
||||
$nextStep = 'password';
|
||||
|
||||
DB::transaction(function () use ($user, $newEmail, &$nextStep): void {
|
||||
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
|
||||
$nextStep = (bool) $lockedUser->needs_password_reset ? 'verified' : 'password';
|
||||
$lockedUser->email = $newEmail;
|
||||
$lockedUser->email_verified_at = null;
|
||||
$lockedUser->onboarding_step = $nextStep;
|
||||
$lockedUser->save();
|
||||
});
|
||||
|
||||
if ($oldEmail !== '' && $oldEmail !== $newEmail && ! User::isEmailLoginUpgradePlaceholder($oldEmail)) {
|
||||
Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail));
|
||||
}
|
||||
|
||||
return redirect()->route($nextStep === 'verified' ? 'setup.password.create' : 'setup.username.create')
|
||||
->with('status', $nextStep === 'verified'
|
||||
? 'Email saved. Continue with password setup.'
|
||||
: 'Email saved. Continue with username setup.');
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ class SetupUsernameController extends Controller
|
||||
])->save();
|
||||
});
|
||||
|
||||
$request->session()->forget('username_login_upgrade');
|
||||
|
||||
return redirect('/@' . strtolower($candidate));
|
||||
}
|
||||
}
|
||||
|
||||
367
app/Http/Controllers/Settings/AiBiographyAdminController.php
Normal file
367
app/Http/Controllers/Settings/AiBiographyAdminController.php
Normal file
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AiBiographyAdminController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function __construct(private readonly AiBiographyService $biographies)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$records = $this->recordsQuery($filters)
|
||||
->paginate(self::PER_PAGE)
|
||||
->withQueryString()
|
||||
->through(fn (CreatorAiBiography $record): array => $this->mapRecord($record));
|
||||
|
||||
return Inertia::render('Moderation/AiBiographyAdmin', [
|
||||
'title' => 'AI Biography Review',
|
||||
'records' => $records,
|
||||
'filters' => $filters,
|
||||
'stats' => $this->stats(),
|
||||
'filterOptions' => [
|
||||
'status' => [
|
||||
['value' => 'all', 'label' => 'All statuses'],
|
||||
['value' => CreatorAiBiography::STATUS_GENERATED, 'label' => 'Generated'],
|
||||
['value' => CreatorAiBiography::STATUS_APPROVED, 'label' => 'Approved'],
|
||||
['value' => CreatorAiBiography::STATUS_EDITED, 'label' => 'Edited'],
|
||||
['value' => CreatorAiBiography::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||
['value' => CreatorAiBiography::STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => CreatorAiBiography::STATUS_SUPPRESSED, 'label' => 'Suppressed'],
|
||||
],
|
||||
'scope' => [
|
||||
['value' => 'all', 'label' => 'All records'],
|
||||
['value' => 'active', 'label' => 'Active only'],
|
||||
['value' => 'inactive', 'label' => 'Inactive only'],
|
||||
],
|
||||
'tier' => [
|
||||
['value' => 'all', 'label' => 'All tiers'],
|
||||
['value' => CreatorAiBiography::TIER_RICH, 'label' => 'Rich'],
|
||||
['value' => CreatorAiBiography::TIER_MEDIUM, 'label' => 'Medium'],
|
||||
['value' => CreatorAiBiography::TIER_SPARSE, 'label' => 'Sparse'],
|
||||
],
|
||||
'visibility' => [
|
||||
['value' => 'all', 'label' => 'All visibility'],
|
||||
['value' => 'visible', 'label' => 'Visible'],
|
||||
['value' => 'hidden', 'label' => 'Hidden'],
|
||||
],
|
||||
'review' => [
|
||||
['value' => 'all', 'label' => 'All review states'],
|
||||
['value' => 'needs_review', 'label' => 'Needs review'],
|
||||
['value' => 'failed', 'label' => 'Failed / errored'],
|
||||
['value' => 'user_edited', 'label' => 'User edited'],
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
'index' => route('cp.ai-biography.index'),
|
||||
'rebuildPattern' => route('cp.ai-biography.rebuild', ['user' => '__USER__']),
|
||||
'approvePattern' => route('cp.ai-biography.approve', ['biography' => '__BIOGRAPHY__']),
|
||||
'flagPattern' => route('cp.ai-biography.flag', ['biography' => '__BIOGRAPHY__']),
|
||||
'hidePattern' => route('cp.ai-biography.hide', ['biography' => '__BIOGRAPHY__']),
|
||||
'showPattern' => route('cp.ai-biography.show', ['biography' => '__BIOGRAPHY__']),
|
||||
],
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function rebuild(User $user): JsonResponse
|
||||
{
|
||||
if (! (bool) config('ai_biography.enabled', true)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI Biography generation is currently disabled.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$active = $this->activeRecordForUser($user);
|
||||
|
||||
if ($active !== null && $active->is_user_edited) {
|
||||
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
} elseif ($active !== null) {
|
||||
$result = $this->biographies->regenerate($user, true, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
} else {
|
||||
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => (bool) $result['success'],
|
||||
'message' => $this->rebuildMessage($result),
|
||||
'result' => $result,
|
||||
], $result['success'] ? 200 : 422);
|
||||
}
|
||||
|
||||
public function approve(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
$biography->update([
|
||||
'needs_review' => false,
|
||||
'status' => $biography->is_user_edited
|
||||
? CreatorAiBiography::STATUS_EDITED
|
||||
: CreatorAiBiography::STATUS_APPROVED,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography marked as reviewed.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function flag(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
$biography->update([
|
||||
'needs_review' => true,
|
||||
'status' => $biography->is_user_edited
|
||||
? CreatorAiBiography::STATUS_EDITED
|
||||
: CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography flagged for review.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function hide(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
if (! $biography->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Only active biographies can be hidden.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->biographies->hide($biography->user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography hidden from public view.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(CreatorAiBiography $biography): JsonResponse
|
||||
{
|
||||
if (! $biography->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Only active biographies can be made visible.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->biographies->show($biography->user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Biography is public again.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'q' => trim((string) $request->query('q', '')),
|
||||
'status' => $this->enumFilter(
|
||||
(string) $request->query('status', 'all'),
|
||||
[
|
||||
'all',
|
||||
CreatorAiBiography::STATUS_GENERATED,
|
||||
CreatorAiBiography::STATUS_APPROVED,
|
||||
CreatorAiBiography::STATUS_EDITED,
|
||||
CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
||||
CreatorAiBiography::STATUS_FAILED,
|
||||
CreatorAiBiography::STATUS_SUPPRESSED,
|
||||
],
|
||||
'all',
|
||||
),
|
||||
'scope' => $this->enumFilter((string) $request->query('scope', 'all'), ['all', 'active', 'inactive'], 'all'),
|
||||
'tier' => $this->enumFilter(
|
||||
(string) $request->query('tier', 'all'),
|
||||
['all', CreatorAiBiography::TIER_RICH, CreatorAiBiography::TIER_MEDIUM, CreatorAiBiography::TIER_SPARSE],
|
||||
'all',
|
||||
),
|
||||
'visibility' => $this->enumFilter((string) $request->query('visibility', 'all'), ['all', 'visible', 'hidden'], 'all'),
|
||||
'review' => $this->enumFilter((string) $request->query('review', 'all'), ['all', 'needs_review', 'failed', 'user_edited'], 'all'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $filters
|
||||
*/
|
||||
private function recordsQuery(array $filters): Builder
|
||||
{
|
||||
$query = CreatorAiBiography::query()
|
||||
->with('user:id,username,name,email,created_at')
|
||||
->latest('created_at')
|
||||
->latest('id');
|
||||
|
||||
if ($filters['q'] !== '') {
|
||||
$search = '%' . Str::lower($filters['q']) . '%';
|
||||
|
||||
$query->whereHas('user', function (Builder $userQuery) use ($search): void {
|
||||
$userQuery->where(function (Builder $matchQuery) use ($search): void {
|
||||
$matchQuery->whereRaw('LOWER(username) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(COALESCE(name, "")) LIKE ?', [$search])
|
||||
->orWhereRaw('LOWER(COALESCE(email, "")) LIKE ?', [$search]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($filters['status'] !== 'all') {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if ($filters['scope'] === 'active') {
|
||||
$query->where('is_active', true);
|
||||
} elseif ($filters['scope'] === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
}
|
||||
|
||||
if ($filters['tier'] !== 'all') {
|
||||
$query->where('input_quality_tier', $filters['tier']);
|
||||
}
|
||||
|
||||
if ($filters['visibility'] === 'visible') {
|
||||
$query->where('is_hidden', false);
|
||||
} elseif ($filters['visibility'] === 'hidden') {
|
||||
$query->where('is_hidden', true);
|
||||
}
|
||||
|
||||
if ($filters['review'] === 'needs_review') {
|
||||
$query->where('needs_review', true);
|
||||
} elseif ($filters['review'] === 'failed') {
|
||||
$query->where(function (Builder $failedQuery): void {
|
||||
$failedQuery->where('status', CreatorAiBiography::STATUS_FAILED)
|
||||
->orWhereNotNull('last_error_code');
|
||||
});
|
||||
} elseif ($filters['review'] === 'user_edited') {
|
||||
$query->where('is_user_edited', true);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function stats(): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_ai_biographies')) {
|
||||
return [
|
||||
'total_records' => 0,
|
||||
'active_records' => 0,
|
||||
'needs_review' => 0,
|
||||
'hidden_active' => 0,
|
||||
'failed' => 0,
|
||||
'user_edited_active' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_records' => (int) CreatorAiBiography::query()->count(),
|
||||
'active_records' => (int) CreatorAiBiography::query()->where('is_active', true)->count(),
|
||||
'needs_review' => (int) CreatorAiBiography::query()->where('needs_review', true)->count(),
|
||||
'hidden_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_hidden', true)->count(),
|
||||
'failed' => (int) CreatorAiBiography::query()->where(function (Builder $query): void {
|
||||
$query->where('status', CreatorAiBiography::STATUS_FAILED)
|
||||
->orWhereNotNull('last_error_code');
|
||||
})->count(),
|
||||
'user_edited_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_user_edited', true)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapRecord(CreatorAiBiography $record): array
|
||||
{
|
||||
$user = $record->user;
|
||||
$username = (string) ($user?->username ?? '');
|
||||
$isStale = $record->is_active && $user !== null ? $this->biographies->isStale($user) : false;
|
||||
|
||||
return [
|
||||
'id' => (int) $record->id,
|
||||
'user_id' => (int) $record->user_id,
|
||||
'user' => [
|
||||
'id' => $user?->id,
|
||||
'username' => $username,
|
||||
'display_name' => $user?->name ?: ($username !== '' ? '@' . $username : 'Unknown creator'),
|
||||
'email' => $user?->email,
|
||||
'profile_url' => $username !== '' ? route('profile.show', ['username' => Str::lower($username)]) : null,
|
||||
'gallery_url' => $username !== '' ? route('profile.gallery', ['username' => Str::lower($username)]) : null,
|
||||
],
|
||||
'text' => $record->text,
|
||||
'excerpt' => Str::limit((string) $record->text, 220),
|
||||
'status' => (string) $record->status,
|
||||
'is_active' => (bool) $record->is_active,
|
||||
'is_hidden' => (bool) $record->is_hidden,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'needs_review' => (bool) $record->needs_review,
|
||||
'is_stale' => $isStale,
|
||||
'source_hash' => $record->source_hash,
|
||||
'model' => $record->model,
|
||||
'prompt_version' => $record->prompt_version,
|
||||
'input_quality_tier' => $record->input_quality_tier,
|
||||
'generation_reason' => $record->generation_reason,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'approved_at' => $record->approved_at?->toIso8601String(),
|
||||
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
|
||||
'last_error_code' => $record->last_error_code,
|
||||
'last_error_reason' => $record->last_error_reason,
|
||||
'created_at' => $record->created_at?->toIso8601String(),
|
||||
'updated_at' => $record->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function activeRecordForUser(User $user): ?CreatorAiBiography
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{success: bool, action: string, errors: list<string>} $result
|
||||
*/
|
||||
private function rebuildMessage(array $result): string
|
||||
{
|
||||
if ($result['success']) {
|
||||
return match ($result['action']) {
|
||||
'draft_stored' => 'New AI draft stored while preserving the active user-edited biography.',
|
||||
default => 'Biography rebuild completed.',
|
||||
};
|
||||
}
|
||||
|
||||
return $result['errors'][0] ?? 'Biography rebuild failed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowed
|
||||
*/
|
||||
private function enumFilter(string $value, array $allowed, string $fallback): string
|
||||
{
|
||||
$normalized = trim(Str::lower($value));
|
||||
|
||||
return in_array($normalized, $allowed, true) ? $normalized : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,24 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
public function analyze(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
|
||||
$direct = (bool) $request->boolean('direct');
|
||||
$intent = $request->validate([
|
||||
$payload = $request->validate([
|
||||
'direct' => ['sometimes', 'boolean'],
|
||||
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
|
||||
])['intent'] ?? null;
|
||||
'provider' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
$direct = (bool) ($payload['direct'] ?? false);
|
||||
$intent = $payload['intent'] ?? null;
|
||||
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
|
||||
|
||||
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid provider. Supported values: lm_studio, together.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($direct) {
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent);
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -48,7 +58,7 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent);
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -60,14 +70,24 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
public function regenerate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
|
||||
$direct = (bool) $request->boolean('direct');
|
||||
$intent = $request->validate([
|
||||
$payload = $request->validate([
|
||||
'direct' => ['sometimes', 'boolean'],
|
||||
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
|
||||
])['intent'] ?? null;
|
||||
'provider' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
$direct = (bool) ($payload['direct'] ?? false);
|
||||
$intent = $payload['intent'] ?? null;
|
||||
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
|
||||
|
||||
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid provider. Supported values: lm_studio, together.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($direct) {
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent);
|
||||
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -77,7 +97,7 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent);
|
||||
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent, $provider);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -114,4 +134,17 @@ final class StudioArtworkAiAssistApiController extends Controller
|
||||
|
||||
return response()->json(['success' => true], 201);
|
||||
}
|
||||
|
||||
private function normalizeProviderOption(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -125,7 +126,7 @@ final class StudioArtworksApiController extends Controller
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
@@ -154,6 +155,9 @@ final class StudioArtworksApiController extends Controller
|
||||
'contributor_credits.*.user_id' => 'required|integer|min:1',
|
||||
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
|
||||
'contributor_credits.*.is_primary' => 'nullable|boolean',
|
||||
'world_submissions' => 'sometimes|array|max:12',
|
||||
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
|
||||
'world_submissions.*.note' => 'nullable|string|max:1000',
|
||||
'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',
|
||||
@@ -166,6 +170,7 @@ final class StudioArtworksApiController extends Controller
|
||||
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|
||||
|| array_key_exists('evolution_relation_type', $validated)
|
||||
|| array_key_exists('evolution_note', $validated);
|
||||
$worldSubmissionPayload = $validated['world_submissions'] ?? null;
|
||||
|
||||
$attributionPayload = [
|
||||
'group' => $validated['group'] ?? $artwork->group?->slug,
|
||||
@@ -208,7 +213,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'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'], $validated['world_submissions']);
|
||||
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||
|
||||
$validated['visibility'] = $visibility;
|
||||
@@ -271,6 +276,14 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($worldSubmissionPayload !== null) {
|
||||
try {
|
||||
$submissions->syncForArtwork($artwork->fresh(), $request->user(), (array) $worldSubmissionPayload);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||
@@ -316,6 +329,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
'world_submission_options' => $submissions->artworkSubmissionOptions($artwork->fresh(['worldSubmissions.world', 'worldSubmissions.reviewer']), $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -493,6 +494,7 @@ final class StudioController extends Controller
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'groupOptions' => $availableGroups,
|
||||
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
||||
|
||||
294
app/Http/Controllers/Studio/StudioWorldController.php
Normal file
294
app/Http/Controllers/Studio/StudioWorldController.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Worlds\StoreWorldRequest;
|
||||
use App\Http\Requests\Worlds\UpdateWorldRequest;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class StudioWorldController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorldService $worlds,
|
||||
private readonly WorldSubmissionService $submissions,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorize('manage', World::class);
|
||||
|
||||
return Inertia::render('Studio/StudioWorldsIndex', [
|
||||
'title' => 'Worlds',
|
||||
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
|
||||
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'createUrl' => route('studio.worlds.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (! $request->user()?->can('create', World::class)) {
|
||||
return redirect()->route('worlds.index');
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioWorldEditor', [
|
||||
'title' => 'Create world',
|
||||
'description' => 'Build a curated campaign destination with themed visuals, ordered sections, and explicit content attachments.',
|
||||
'world' => null,
|
||||
'themeOptions' => $this->worlds->themeOptions(),
|
||||
'sectionOptions' => $this->worlds->sectionOptions(),
|
||||
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'storeUrl' => route('studio.worlds.store'),
|
||||
'entitySearchUrl' => route('studio.worlds.entity-search'),
|
||||
'duplicateActions' => null,
|
||||
'mediaSupport' => [
|
||||
'picker_available' => false,
|
||||
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
|
||||
'upload_url' => route('api.studio.worlds.media.upload'),
|
||||
'delete_url' => route('api.studio.worlds.media.destroy'),
|
||||
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||
'max_file_size_mb' => 6,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreWorldRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
|
||||
$world = $this->worlds->store($request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $world])->with('success', 'World draft created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, World $world): Response
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
return Inertia::render('Studio/StudioWorldEditor', [
|
||||
'title' => 'Edit world',
|
||||
'description' => 'Tune the world identity, adjust section order, and refine the curated attachments.',
|
||||
'world' => $this->worlds->mapStudioWorld($world, $request->user()),
|
||||
'themeOptions' => $this->worlds->themeOptions(),
|
||||
'sectionOptions' => $this->worlds->sectionOptions(),
|
||||
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
|
||||
'typeOptions' => [
|
||||
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
|
||||
['value' => World::TYPE_EVENT, 'label' => 'Event'],
|
||||
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
|
||||
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
|
||||
],
|
||||
'statusOptions' => [
|
||||
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
|
||||
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
|
||||
],
|
||||
'updateUrl' => route('studio.worlds.update', ['world' => $world]),
|
||||
'previewUrl' => route('studio.worlds.preview', ['world' => $world]),
|
||||
'publishUrl' => route('studio.worlds.publish', ['world' => $world]),
|
||||
'archiveUrl' => route('studio.worlds.archive', ['world' => $world]),
|
||||
'entitySearchUrl' => route('studio.worlds.entity-search'),
|
||||
'duplicateActions' => [
|
||||
'duplicateUrl' => route('studio.worlds.duplicate', ['world' => $world]),
|
||||
'newEditionUrl' => route('studio.worlds.new-edition', ['world' => $world]),
|
||||
'canCreateEdition' => $this->worlds->canCreateNewEdition($world),
|
||||
],
|
||||
'mediaSupport' => [
|
||||
'picker_available' => false,
|
||||
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
|
||||
'upload_url' => route('api.studio.worlds.media.upload'),
|
||||
'delete_url' => route('api.studio.worlds.media.destroy'),
|
||||
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||
'max_file_size_mb' => 6,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateWorldRequest $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->update($world, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'World updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->publish($world);
|
||||
|
||||
return back()->with('success', 'World published.');
|
||||
}
|
||||
|
||||
public function archive(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$this->worlds->archive($world);
|
||||
|
||||
return back()->with('success', 'World archived.');
|
||||
}
|
||||
|
||||
public function duplicate(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$duplicate = $this->worlds->duplicate($world, $request->user(), false);
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $duplicate])->with('success', 'World duplicated into a new draft.');
|
||||
}
|
||||
|
||||
public function newEdition(Request $request, World $world): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', World::class);
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$edition = $this->worlds->duplicate($world, $request->user(), true);
|
||||
|
||||
return redirect()->route('studio.worlds.edit', ['world' => $edition])->with('success', 'Next edition draft created.');
|
||||
}
|
||||
|
||||
public function entitySearch(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage', World::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', 'string'],
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->worlds->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission approved and is now live.');
|
||||
}
|
||||
|
||||
public function removeSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission removed from the world.');
|
||||
}
|
||||
|
||||
public function blockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_BLOCKED, 'Submission blocked from this world.');
|
||||
}
|
||||
|
||||
public function unblockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission unblocked. It can now be restored or re-added later.');
|
||||
}
|
||||
|
||||
public function restoreSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission restored to live.');
|
||||
}
|
||||
|
||||
public function featureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->toggleFeaturedSubmission($request, $world, $submission, true, 'Submission featured in the public community section.');
|
||||
}
|
||||
|
||||
public function unfeatureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->toggleFeaturedSubmission($request, $world, $submission, false, 'Submission removed from featured community placement.');
|
||||
}
|
||||
|
||||
public function pendingSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
|
||||
{
|
||||
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_PENDING, 'Submission returned to pending.');
|
||||
}
|
||||
|
||||
public function preview(Request $request, World $world): \Inertia\Response
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
|
||||
route('studio.worlds.preview', ['world' => $world]),
|
||||
$world->ogImageUrl(),
|
||||
false,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
'previewMode' => true,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
private function transitionSubmission(Request $request, World $world, WorldSubmission $submission, string $status, string $flashMessage): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
abort_unless((int) $submission->world_id === (int) $world->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'review_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->submissions->transition($submission, $request->user(), $status, $validated['review_note'] ?? null);
|
||||
|
||||
return back()->with('success', $flashMessage);
|
||||
}
|
||||
|
||||
private function toggleFeaturedSubmission(Request $request, World $world, WorldSubmission $submission, bool $featured, string $flashMessage): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $world);
|
||||
|
||||
abort_unless((int) $submission->world_id === (int) $world->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'review_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->submissions->setFeatured($submission, $request->user(), $featured, $validated['review_note'] ?? null);
|
||||
|
||||
return back()->with('success', $flashMessage);
|
||||
}
|
||||
}
|
||||
255
app/Http/Controllers/Studio/StudioWorldMediaApiController.php
Normal file
255
app/Http/Controllers/Studio/StudioWorldMediaApiController.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class StudioWorldMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const SLOT_CONFIG = [
|
||||
'cover' => [
|
||||
'max_width' => 2200,
|
||||
'max_height' => 1400,
|
||||
'min_width' => 1200,
|
||||
'min_height' => 630,
|
||||
],
|
||||
'og' => [
|
||||
'max_width' => 1600,
|
||||
'max_height' => 1000,
|
||||
'min_width' => 1200,
|
||||
'min_height' => 630,
|
||||
],
|
||||
];
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slot' => ['required', 'string', 'in:cover,og'],
|
||||
'image' => [
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
|
||||
]);
|
||||
|
||||
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
|
||||
|
||||
if ($world instanceof World) {
|
||||
$this->authorize('update', $world);
|
||||
} else {
|
||||
$this->authorize('create', World::class);
|
||||
}
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
$slot = (string) $validated['slot'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file, $slot);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'slot' => $slot,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Validation failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('World media upload failed', [
|
||||
'user_id' => (int) ($request->user()?->id ?? 0),
|
||||
'world_id' => $world?->id,
|
||||
'slot' => $slot,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Upload failed',
|
||||
'message' => 'Could not upload image right now.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
|
||||
]);
|
||||
|
||||
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
|
||||
|
||||
if ($world instanceof World) {
|
||||
$this->authorize('update', $world);
|
||||
} else {
|
||||
$this->authorize('create', World::class);
|
||||
}
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string,width:int,height:int,size_bytes:int}
|
||||
*/
|
||||
private function storeMediaFile(UploadedFile $file, string $slot): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$config = self::SLOT_CONFIG[$slot] ?? self::SLOT_CONFIG['cover'];
|
||||
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($uploadPath === '' || ! is_readable($uploadPath)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded image path.');
|
||||
}
|
||||
|
||||
$raw = file_get_contents($uploadPath);
|
||||
if ($raw === false || $raw === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image.');
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($raw));
|
||||
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported image mime type.');
|
||||
}
|
||||
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
|
||||
$width = (int) ($size[0] ?? 0);
|
||||
$height = (int) ($size[1] ?? 0);
|
||||
|
||||
if ($width < $config['min_width'] || $height < $config['min_height']) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Image is too small. Minimum required size is %dx%d.',
|
||||
$config['min_width'],
|
||||
$config['min_height'],
|
||||
));
|
||||
}
|
||||
|
||||
$image = $this->manager->read($raw)->scaleDown(width: $config['max_width'], height: $config['max_height']);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(85));
|
||||
|
||||
$hash = hash('sha256', $encoded);
|
||||
$path = $this->mediaPath($slot, $hash);
|
||||
$disk = Storage::disk($this->mediaDiskName());
|
||||
|
||||
$written = $disk->put($path, $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store image in object storage.');
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'width' => (int) $image->width(),
|
||||
'height' => (int) $image->height(),
|
||||
'size_bytes' => strlen($encoded),
|
||||
];
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('covers.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $slot, string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'worlds/media/%s/%s/%s/%s.webp',
|
||||
$slot,
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'worlds/media/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production world media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,7 @@ class ProfileController extends Controller
|
||||
public function editSettings(Request $request)
|
||||
{
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$emailLoginUpgradeRequired = $user->requiresEmailLoginUpgrade();
|
||||
$cooldownDays = $this->usernameCooldownDays();
|
||||
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
|
||||
$usernameCooldownRemainingDays = 0;
|
||||
@@ -351,6 +352,8 @@ class ProfileController extends Controller
|
||||
'usernameCooldownDays' => $cooldownDays,
|
||||
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
|
||||
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
|
||||
'emailLoginUpgradeRequired' => $emailLoginUpgradeRequired,
|
||||
'forcedSection' => $emailLoginUpgradeRequired ? 'account' : null,
|
||||
'countries' => $countries,
|
||||
'flash' => [
|
||||
'status' => session('status'),
|
||||
@@ -518,8 +521,13 @@ class ProfileController extends Controller
|
||||
|
||||
DB::transaction(function () use ($user, $change, $newEmail): void {
|
||||
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
|
||||
$completesLegacyUpgrade = $lockedUser->requiresEmailLoginUpgrade();
|
||||
|
||||
$lockedUser->email = $newEmail;
|
||||
$lockedUser->email_verified_at = now();
|
||||
if ($completesLegacyUpgrade) {
|
||||
$lockedUser->onboarding_step = 'complete';
|
||||
}
|
||||
$lockedUser->save();
|
||||
|
||||
DB::table('email_changes')
|
||||
|
||||
@@ -47,7 +47,11 @@ final class ArtworkPageController extends Controller
|
||||
return response(view('errors.410'), 410);
|
||||
}
|
||||
|
||||
if (! $raw->is_public || ! $raw->is_approved) {
|
||||
if (! $raw->is_public
|
||||
|| ! $raw->is_approved
|
||||
|| (string) ($raw->visibility ?? '') === Artwork::VISIBILITY_PRIVATE
|
||||
|| $raw->published_at === null
|
||||
|| $raw->published_at->isFuture()) {
|
||||
// Artwork exists but is private/unapproved → 403 Forbidden.
|
||||
// Show other public artworks by the same creator as recovery suggestions.
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
@@ -63,8 +67,7 @@ final class ArtworkPageController extends Controller
|
||||
->with('user')
|
||||
->where('user_id', $raw->user_id)
|
||||
->where('id', '!=', $raw->id)
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Artwork $a) {
|
||||
@@ -185,6 +188,9 @@ final class ArtworkPageController extends Controller
|
||||
'id' => (int) $item->id,
|
||||
'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_id' => (int) ($item->user?->id ?? 0),
|
||||
'publisher_type' => $item->group ? 'group' : 'user',
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||
|
||||
@@ -88,12 +88,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember(
|
||||
"browse.all.{$sort}.{$page}",
|
||||
"browse.all.catalog-visible.v2.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
|
||||
@@ -150,12 +150,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
$artworks = Cache::remember(
|
||||
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
|
||||
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
||||
@@ -197,12 +197,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
->implode(' OR ');
|
||||
|
||||
$artworks = Cache::remember(
|
||||
'gallery.cat.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => Artwork::search('')->options([
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
|
||||
|
||||
@@ -171,8 +171,7 @@ final class DiscoverController extends Controller
|
||||
$today = now();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -209,16 +208,14 @@ final class DiscoverController extends Controller
|
||||
|
||||
if ($hasStats) {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published')
|
||||
->groupBy('artworks.user_id');
|
||||
} else {
|
||||
$sub = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->where('published_at', '>=', now()->subDays(90))
|
||||
->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published')
|
||||
->groupBy('user_id');
|
||||
@@ -346,8 +343,7 @@ final class DiscoverController extends Controller
|
||||
|
||||
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderMissingThumbnailsLast()
|
||||
@@ -414,8 +410,7 @@ final class DiscoverController extends Controller
|
||||
private function fallbackFreshFromDatabase(int $perPage)
|
||||
{
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -434,8 +429,7 @@ final class DiscoverController extends Controller
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -460,8 +454,7 @@ final class DiscoverController extends Controller
|
||||
$cutoff = now()->subDays($windowDays)->startOfDay();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -488,8 +481,7 @@ final class DiscoverController extends Controller
|
||||
$recentActivity = $this->risingRecentActivitySubquery();
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
@@ -552,6 +544,7 @@ final class DiscoverController extends Controller
|
||||
}
|
||||
|
||||
$byId = Artwork::query()
|
||||
->catalogVisible()
|
||||
->whereIn('id', $ids)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
@@ -571,6 +564,10 @@ final class DiscoverController extends Controller
|
||||
return $this->presentArtwork($full);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'id' => $item->id ?? 0,
|
||||
'name' => $item->title ?? $item->name ?? 'Untitled',
|
||||
@@ -588,7 +585,7 @@ final class DiscoverController extends Controller
|
||||
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
||||
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
|
||||
];
|
||||
})
|
||||
})->filter()->values()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,8 +677,7 @@ final class DiscoverController extends Controller
|
||||
}
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->catalogVisible()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash', 'stats:artwork_id,views,favorites,comments_count,heat_score'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
@@ -77,10 +79,12 @@ final class ExploreController extends Controller
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$filter = $this->buildExploreFilterExpression($request);
|
||||
$cacheSuffix = $this->requestCacheSuffix($request);
|
||||
|
||||
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
|
||||
$this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
@@ -148,13 +152,10 @@ final class ExploreController extends Controller
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$filter = $this->buildExploreFilterExpression($request, $isAll ? null : $resolvedTypeSlug);
|
||||
$cacheSuffix = $this->requestCacheSuffix($request);
|
||||
|
||||
$filter = 'is_public = true AND is_approved = true';
|
||||
if (!$isAll) {
|
||||
$filter .= ' AND content_type = "' . $type . '"';
|
||||
}
|
||||
|
||||
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
|
||||
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
|
||||
$this->search->searchWithThumbnailPreference([
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
@@ -288,11 +289,122 @@ final class ExploreController extends Controller
|
||||
return max(12, min($v, 80));
|
||||
}
|
||||
|
||||
private function requestCacheSuffix(Request $request): string
|
||||
{
|
||||
$query = $request->query();
|
||||
unset($query['grid'], $query['page']);
|
||||
ksort($query);
|
||||
|
||||
return md5(json_encode($query, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function cacheVersion(): int
|
||||
{
|
||||
return max(1, (int) Cache::get('explore.cache.version', 1));
|
||||
}
|
||||
|
||||
private function buildExploreFilterExpression(Request $request, ?string $contentType = null): string
|
||||
{
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
];
|
||||
|
||||
if ($contentType !== null && $contentType !== '') {
|
||||
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
|
||||
}
|
||||
|
||||
$orientation = strtolower(trim((string) $request->query('orientation', '')));
|
||||
if (in_array($orientation, ['landscape', 'portrait', 'square'], true)) {
|
||||
$filterParts[] = 'orientation = "' . addslashes($orientation) . '"';
|
||||
}
|
||||
|
||||
$resolution = $this->resolutionFilterValue((string) $request->query('resolution', ''));
|
||||
if ($resolution !== null) {
|
||||
$filterParts[] = 'resolution = "' . addslashes($resolution) . '"';
|
||||
}
|
||||
|
||||
$dateFrom = $this->normalizeDateQuery((string) $request->query('date_from', ''));
|
||||
if ($dateFrom !== null) {
|
||||
$filterParts[] = 'created_at >= "' . $dateFrom . '"';
|
||||
}
|
||||
|
||||
$dateTo = $this->normalizeDateQuery((string) $request->query('date_to', ''));
|
||||
if ($dateTo !== null) {
|
||||
$filterParts[] = 'created_at <= "' . $dateTo . '"';
|
||||
}
|
||||
|
||||
$authorFilter = $this->authorFilterExpression((string) $request->query('author', ''));
|
||||
if ($authorFilter !== null) {
|
||||
$filterParts[] = $authorFilter;
|
||||
}
|
||||
|
||||
return implode(' AND ', $filterParts);
|
||||
}
|
||||
|
||||
private function resolutionFilterValue(string $resolution): ?string
|
||||
{
|
||||
return match (strtolower(trim($resolution))) {
|
||||
'hd' => '1280x720',
|
||||
'fhd' => '1920x1080',
|
||||
'2k' => '2560x1440',
|
||||
'4k' => '3840x2160',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeDateQuery(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function authorFilterExpression(string $author): ?string
|
||||
{
|
||||
$author = trim($author);
|
||||
|
||||
if ($author === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userIds = User::query()
|
||||
->where(function ($query) use ($author): void {
|
||||
$query->where('username', 'like', '%' . $author . '%')
|
||||
->orWhere('name', 'like', '%' . $author . '%');
|
||||
})
|
||||
->limit(20)
|
||||
->pluck('id');
|
||||
|
||||
$groupIds = Group::query()
|
||||
->where(function ($query) use ($author): void {
|
||||
$query->where('name', 'like', '%' . $author . '%')
|
||||
->orWhere('slug', 'like', '%' . $author . '%');
|
||||
})
|
||||
->limit(20)
|
||||
->pluck('id');
|
||||
|
||||
$clauses = [];
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$clauses[] = '(author_id = ' . (int) $userId . ' AND published_as_type = "user")';
|
||||
}
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$clauses[] = '(author_id = ' . (int) $groupId . ' AND published_as_type = "group")';
|
||||
}
|
||||
|
||||
if ($clauses === []) {
|
||||
return 'id = 0';
|
||||
}
|
||||
|
||||
return '(' . implode(' OR ', $clauses) . ')';
|
||||
}
|
||||
|
||||
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
|
||||
{
|
||||
$paginator->setCollection(
|
||||
|
||||
@@ -32,6 +32,7 @@ final class HelpCenterPageController extends Controller
|
||||
'links' => [
|
||||
'studio_help' => route('help.studio'),
|
||||
'upload_help' => route('help.upload'),
|
||||
'help_worlds' => route('help.worlds'),
|
||||
'groups_documentation' => route('help.groups'),
|
||||
'groups_quickstart' => route('help.groups.quickstart'),
|
||||
'groups_faq' => route('help.groups.faq'),
|
||||
@@ -42,10 +43,14 @@ final class HelpCenterPageController extends Controller
|
||||
'studio_home' => route('studio.index'),
|
||||
'studio_content' => route('studio.content'),
|
||||
'studio_artworks' => route('studio.artworks'),
|
||||
'studio_worlds' => route('studio.worlds.index'),
|
||||
'studio_worlds_create' => route('studio.worlds.create'),
|
||||
'studio_cards' => route('studio.cards.index'),
|
||||
'studio_drafts' => route('studio.drafts'),
|
||||
'cards_create' => route('studio.cards.create'),
|
||||
'upload' => route('upload'),
|
||||
'worlds_index' => route('worlds.index'),
|
||||
'create_world' => route('worlds.create.redirect'),
|
||||
'cards_index' => route('cards.index'),
|
||||
'help_cards' => route('help.cards'),
|
||||
'help_profile' => route('help.profile'),
|
||||
|
||||
@@ -21,6 +21,7 @@ class LeaderboardPageController extends Controller
|
||||
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
|
||||
'groups', Leaderboard::TYPE_GROUP => Leaderboard::TYPE_GROUP,
|
||||
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
|
||||
'worlds', Leaderboard::TYPE_WORLD => Leaderboard::TYPE_WORLD,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ class LeaderboardPageController extends Controller
|
||||
Leaderboard::TYPE_GROUP => 'Top Groups Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_STORY => 'Top Stories Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_ARTWORK => 'Top Artworks Leaderboard — Skinbase',
|
||||
Leaderboard::TYPE_WORLD => 'Top Worlds Leaderboard — Skinbase',
|
||||
default => 'Top Creators & Artworks Leaderboard — Skinbase',
|
||||
};
|
||||
|
||||
@@ -35,7 +37,8 @@ class LeaderboardPageController extends Controller
|
||||
Leaderboard::TYPE_GROUP => 'Track the leading groups across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_STORY => 'Track the leading stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_ARTWORK => 'Track the leading artworks across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
default => 'Track the leading creators, groups, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
Leaderboard::TYPE_WORLD => 'Track the leading Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
default => 'Track the leading creators, groups, artworks, stories, and Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
};
|
||||
|
||||
return Inertia::render('Leaderboard/LeaderboardPage', [
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
@@ -62,9 +64,18 @@ final class SearchController extends Controller
|
||||
$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();
|
||||
|
||||
$galleryItems = method_exists($artworks, 'getCollection')
|
||||
? $artworks->getCollection()
|
||||
: new EloquentCollection(collect($artworks)->all());
|
||||
|
||||
$galleryItems->loadMissing(['user.profile', 'group', 'categories.contentType']);
|
||||
|
||||
$galleryArtworks = $galleryItems
|
||||
->map(fn ($artwork) => (new ArtworkListResource($artwork))->resolve($request))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
|
||||
return view('search.index', [
|
||||
@@ -87,28 +98,4 @@ final class SearchController extends Controller
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Http/Controllers/Web/WorldController.php
Normal file
51
app/Http/Controllers/Web/WorldController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class WorldController extends Controller
|
||||
{
|
||||
public function __construct(private readonly WorldService $worlds)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$payload = $this->worlds->publicIndexPayload($request->user());
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
'Worlds — Skinbase Nova',
|
||||
$payload['description'],
|
||||
route('worlds.index'),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldIndex', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, World $world): Response
|
||||
{
|
||||
abort_unless($world->isPubliclyVisible(), 404);
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
route('worlds.show', ['world' => $world->slug]),
|
||||
$world->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Web/WorldsHelpPageController.php
Normal file
51
app/Http/Controllers/Web/WorldsHelpPageController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class WorldsHelpPageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$canonical = route('help.worlds');
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Worlds Help — Skinbase',
|
||||
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
$seo['og_type'] = 'article';
|
||||
|
||||
return Inertia::render('Help/WorldsHelpPage', [
|
||||
'title' => 'Worlds Help',
|
||||
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
|
||||
'seo' => $seo,
|
||||
'links' => [
|
||||
'help_home' => route('help'),
|
||||
'studio_help' => route('help.studio'),
|
||||
'upload_help' => route('help.upload'),
|
||||
'help_cards' => route('help.cards'),
|
||||
'groups_help' => route('help.groups'),
|
||||
'worlds_index' => route('worlds.index'),
|
||||
'create_world' => route('worlds.create.redirect'),
|
||||
'studio_worlds' => route('studio.worlds.index'),
|
||||
'studio_worlds_create' => route('studio.worlds.create'),
|
||||
'open_studio' => route('studio.index'),
|
||||
'contact_support' => route('contact.show'),
|
||||
'report_issue' => route('bug-report'),
|
||||
],
|
||||
'auth' => [
|
||||
'signed_in' => $request->user() !== null,
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@ final class EnsureAdminOrModerator
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$role = strtolower((string) ($user->role ?? ''));
|
||||
|
||||
if (! in_array($role, ['admin', 'moderator'], true)) {
|
||||
if (! $user || (! $user->isAdmin() && ! $user->isModerator())) {
|
||||
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
|
||||
}
|
||||
|
||||
|
||||
56
app/Http/Middleware/EnsureEmailLoginUpgradeComplete.php
Normal file
56
app/Http/Middleware/EnsureEmailLoginUpgradeComplete.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureEmailLoginUpgradeComplete
|
||||
{
|
||||
private const SESSION_KEY = 'username_login_upgrade';
|
||||
|
||||
private const ALWAYS_ALLOW = [
|
||||
'logout',
|
||||
'setup/*',
|
||||
'up',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $request->hasSession()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($user->hasCompletedOnboarding()) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $request->session()->get(self::SESSION_KEY, false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->is(self::ALWAYS_ALLOW)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$step = strtolower(trim((string) ($user->onboarding_step ?? '')));
|
||||
$route = match ($step) {
|
||||
'verified' => route('setup.password.create'),
|
||||
'password' => route('setup.username.create'),
|
||||
default => route('setup.email.create'),
|
||||
};
|
||||
|
||||
return redirect($route)
|
||||
->with('status', 'Continue onboarding to finish switching this account to email-based login.');
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class EnsureOnboardingComplete
|
||||
}
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
if ($step === 'complete') {
|
||||
if ($step === '' || $step === 'complete') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
private ?User $authenticatedUser = null;
|
||||
|
||||
private string $authenticatedVia = 'email';
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
@@ -27,7 +33,7 @@ class LoginRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'email' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
@@ -41,7 +47,25 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
$identifier = strtolower(trim((string) $this->input('email')));
|
||||
$password = (string) $this->input('password');
|
||||
$user = User::query()
|
||||
->whereRaw('LOWER(email) = ?', [$identifier])
|
||||
->first();
|
||||
$authenticatedVia = 'email';
|
||||
|
||||
if (! $user) {
|
||||
$candidate = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$identifier])
|
||||
->first();
|
||||
|
||||
if ($candidate?->supportsUsernameLogin()) {
|
||||
$user = $candidate;
|
||||
$authenticatedVia = 'username';
|
||||
}
|
||||
}
|
||||
|
||||
if (! $user || ! Hash::check($password, (string) $user->password)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@@ -49,9 +73,23 @@ class LoginRequest extends FormRequest
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::login($user, $this->boolean('remember'));
|
||||
$this->authenticatedUser = $user;
|
||||
$this->authenticatedVia = $authenticatedVia;
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
public function authenticatedUser(): ?User
|
||||
{
|
||||
return $this->authenticatedUser;
|
||||
}
|
||||
|
||||
public function authenticatedViaUsername(): bool
|
||||
{
|
||||
return $this->authenticatedVia === 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,12 @@ class RequestEmailChangeRequest extends FormRequest
|
||||
'max:255',
|
||||
Rule::unique(User::class, 'email')->ignore((int) $this->user()->id),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (User::isEmailLoginUpgradePlaceholder((string) $value)) {
|
||||
$fail('Please enter a real email address you can access.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (strtolower((string) $value) === strtolower((string) $this->user()->email)) {
|
||||
$fail('Please enter a different email address.');
|
||||
}
|
||||
|
||||
141
app/Http/Requests/Worlds/StoreWorldRequest.php
Normal file
141
app/Http/Requests/Worlds/StoreWorldRequest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Worlds;
|
||||
|
||||
use App\Models\World;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreWorldRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function reservedSlugs(): array
|
||||
{
|
||||
return ['create'];
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$sectionKeys = array_keys((array) config('worlds.sections', []));
|
||||
$relationTypes = array_keys((array) config('worlds.relation_types', []));
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:180'],
|
||||
'slug' => ['nullable', 'string', 'max:180', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::notIn($this->reservedSlugs())],
|
||||
'tagline' => ['nullable', 'string', 'max:220'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'description' => ['nullable', 'string', 'max:20000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
'theme_key' => ['nullable', 'string', 'max:80'],
|
||||
'accent_color' => ['nullable', 'string', 'max:16'],
|
||||
'accent_color_secondary' => ['nullable', 'string', 'max:16'],
|
||||
'background_motif' => ['nullable', 'string', 'max:80'],
|
||||
'icon_name' => ['nullable', 'string', 'max:120'],
|
||||
'status' => ['required', Rule::in([World::STATUS_DRAFT, World::STATUS_PUBLISHED, World::STATUS_ARCHIVED])],
|
||||
'type' => ['required', Rule::in([World::TYPE_SEASONAL, World::TYPE_EVENT, World::TYPE_CAMPAIGN, World::TYPE_TRIBUTE])],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'accepts_submissions' => ['nullable', 'boolean'],
|
||||
'participation_mode' => ['nullable', Rule::in([
|
||||
World::PARTICIPATION_MODE_MANUAL_APPROVAL,
|
||||
World::PARTICIPATION_MODE_AUTO_ADD,
|
||||
World::PARTICIPATION_MODE_CLOSED,
|
||||
])],
|
||||
'submission_starts_at' => ['nullable', 'date'],
|
||||
'submission_ends_at' => ['nullable', 'date', 'after_or_equal:submission_starts_at'],
|
||||
'submission_note_enabled' => ['nullable', 'boolean'],
|
||||
'community_section_enabled' => ['nullable', 'boolean'],
|
||||
'allow_readd_after_removal' => ['nullable', 'boolean'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'is_recurring' => ['nullable', 'boolean'],
|
||||
'recurrence_key' => ['nullable', 'string', 'max:120'],
|
||||
'recurrence_rule' => ['nullable', 'string', 'max:160'],
|
||||
'edition_year' => ['nullable', 'integer', 'between:2000,2100'],
|
||||
'cta_label' => ['nullable', 'string', 'max:120'],
|
||||
'cta_url' => ['nullable', 'url', 'max:2048'],
|
||||
'badge_label' => ['nullable', 'string', 'max:120'],
|
||||
'badge_description' => ['nullable', 'string', 'max:2000'],
|
||||
'submission_guidelines' => ['nullable', 'string', 'max:5000'],
|
||||
'badge_url' => ['nullable', 'url', 'max:2048'],
|
||||
'seo_title' => ['nullable', 'string', 'max:255'],
|
||||
'seo_description' => ['nullable', 'string', 'max:300'],
|
||||
'og_image_path' => ['nullable', 'string', 'max:2048'],
|
||||
'related_tags_json' => ['nullable', 'array', 'max:12'],
|
||||
'related_tags_json.*' => ['string', 'max:40'],
|
||||
'section_order_json' => ['nullable', 'array'],
|
||||
'section_order_json.*' => ['string', Rule::in($sectionKeys)],
|
||||
'section_visibility_json' => ['nullable', 'array'],
|
||||
'section_visibility_json.*' => ['boolean'],
|
||||
'parent_world_id' => ['nullable', 'integer', 'exists:worlds,id'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'relations' => ['nullable', 'array', 'max:60'],
|
||||
'relations.*.section_key' => ['required_with:relations', 'string', Rule::in($sectionKeys)],
|
||||
'relations.*.related_type' => ['required_with:relations', 'string', Rule::in($relationTypes)],
|
||||
'relations.*.related_id' => ['required_with:relations', 'integer', 'min:1'],
|
||||
'relations.*.context_label' => ['nullable', 'string', 'max:120'],
|
||||
'relations.*.sort_order' => ['nullable', 'integer', 'min:0'],
|
||||
'relations.*.is_featured' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
$sections = (array) config('worlds.sections', []);
|
||||
|
||||
if ($this->boolean('is_recurring')) {
|
||||
if (trim((string) $this->input('recurrence_key', '')) === '') {
|
||||
$validator->errors()->add('recurrence_key', 'Recurring worlds need a recurrence key such as halloween or retro-month.');
|
||||
}
|
||||
|
||||
if (! is_numeric($this->input('edition_year'))) {
|
||||
$validator->errors()->add('edition_year', 'Recurring worlds need an edition year.');
|
||||
}
|
||||
}
|
||||
|
||||
$recurrenceKey = trim((string) $this->input('recurrence_key', ''));
|
||||
$editionYear = $this->input('edition_year');
|
||||
|
||||
if ($recurrenceKey !== '' && ! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $recurrenceKey)) {
|
||||
$validator->errors()->add('recurrence_key', 'Use lowercase letters, numbers, and dashes only.');
|
||||
}
|
||||
|
||||
if ($this->boolean('is_recurring') && $recurrenceKey !== '' && is_numeric($editionYear)) {
|
||||
$worldId = $this->route('world')?->id;
|
||||
$exists = World::query()
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->where('edition_year', (int) $editionYear)
|
||||
->when($worldId, fn (Builder $builder) => $builder->where('id', '!=', $worldId))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$validator->errors()->add('edition_year', 'That recurrence key already has an edition for this year.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((array) $this->input('relations', []) as $index => $relation) {
|
||||
$sectionKey = (string) ($relation['section_key'] ?? '');
|
||||
$relatedType = (string) ($relation['related_type'] ?? '');
|
||||
$allowed = (array) ($sections[$sectionKey]['relation_types'] ?? []);
|
||||
|
||||
if ($sectionKey !== '' && $relatedType !== '' && $allowed !== [] && ! in_array($relatedType, $allowed, true)) {
|
||||
$validator->errors()->add("relations.{$index}.related_type", 'That entity type cannot be attached to the selected section.');
|
||||
}
|
||||
}
|
||||
|
||||
if ((string) $this->input('participation_mode') === World::PARTICIPATION_MODE_CLOSED && $this->boolean('accepts_submissions')) {
|
||||
$validator->errors()->add('accepts_submissions', 'Closed worlds cannot accept creator submissions.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
9
app/Http/Requests/Worlds/UpdateWorldRequest.php
Normal file
9
app/Http/Requests/Worlds/UpdateWorldRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Worlds;
|
||||
|
||||
class UpdateWorldRequest extends StoreWorldRequest
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtworkListResource extends JsonResource
|
||||
{
|
||||
@@ -53,12 +54,16 @@ class ArtworkListResource extends JsonResource
|
||||
$slugVal = $get('slug');
|
||||
$hash = (string) ($get('hash') ?? '');
|
||||
$thumbExt = (string) ($get('thumb_ext') ?? '');
|
||||
$canonicalSlug = Str::slug((string) ($slugVal ?: $get('title') ?: 'artwork'));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) ($get('id') ?? 'artwork');
|
||||
}
|
||||
$webUrl = $contentTypeSlug && $categoryPath && $slugVal
|
||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
||||
: null;
|
||||
|
||||
$artId = $get('id');
|
||||
$directUrl = $artId && $slugVal ? '/art/' . $artId . '/' . $slugVal : null;
|
||||
$directUrl = $artId ? '/art/' . $artId . '/' . $canonicalSlug : null;
|
||||
|
||||
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
@@ -102,10 +107,11 @@ class ArtworkListResource extends JsonResource
|
||||
'content_type_name' => $decode($contentTypeName),
|
||||
'url' => $webUrl,
|
||||
] : null,
|
||||
'canonical_url' => $directUrl,
|
||||
'urls' => [
|
||||
'web' => $webUrl ?? $directUrl,
|
||||
'direct' => $directUrl,
|
||||
'canonical' => $webUrl ?? $directUrl,
|
||||
'canonical' => $directUrl ?? $webUrl,
|
||||
],
|
||||
], $this->resource, $request->user());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
@@ -16,7 +18,7 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']);
|
||||
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
@@ -52,6 +54,7 @@ class ArtworkResource extends JsonResource
|
||||
$isFollowing = false;
|
||||
$isFollowingGroup = false;
|
||||
$viewerAward = null;
|
||||
$isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0);
|
||||
|
||||
$bookmarksCount = Schema::hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
|
||||
@@ -213,8 +216,12 @@ class ArtworkResource extends JsonResource
|
||||
'is_following_group' => $isFollowingGroup,
|
||||
'is_following_publisher' => $publisher['type'] === 'group' ? $isFollowingGroup : $isFollowing,
|
||||
'is_authenticated' => $viewerId > 0,
|
||||
'is_owner' => $isOwner,
|
||||
'id' => $viewerId > 0 ? $viewerId : null,
|
||||
],
|
||||
'management' => [
|
||||
'analytics_url' => $isOwner ? route('studio.artworks.analytics', ['id' => (int) $this->id]) : null,
|
||||
],
|
||||
'stats' => [
|
||||
'bookmarks' => $bookmarksCount,
|
||||
'views' => (int) ($this->stats?->views ?? 0),
|
||||
@@ -245,6 +252,7 @@ class ArtworkResource extends JsonResource
|
||||
],
|
||||
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
|
||||
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
|
||||
'world_participation' => $this->resolveWorldParticipation(),
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
@@ -324,6 +332,77 @@ class ArtworkResource extends JsonResource
|
||||
return ContentSanitizer::render($rawDescription);
|
||||
}
|
||||
|
||||
private function resolveWorldParticipation(): array
|
||||
{
|
||||
$items = collect();
|
||||
|
||||
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
|
||||
$items = $items->concat(
|
||||
WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
|
||||
return [
|
||||
'world_id' => (int) $relation->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => 'Part of ' . $world->title,
|
||||
'status' => 'curated',
|
||||
'status_label' => 'Curated',
|
||||
'tone' => 'curated',
|
||||
'sort_priority' => 1,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('world_submissions')) {
|
||||
$items = $items->concat(
|
||||
$this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
|
||||
return [
|
||||
'world_id' => (int) $submission->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
|
||||
'tone' => $isFeatured ? 'featured' : 'community',
|
||||
'sort_priority' => $isFeatured ? 0 : 2,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return $items
|
||||
->sortBy('sort_priority')
|
||||
->groupBy('world_id')
|
||||
->map(function ($group): array {
|
||||
$item = $group->first();
|
||||
unset($item['sort_priority']);
|
||||
|
||||
return $item;
|
||||
})
|
||||
->sortBy(fn (array $item): string => strtolower((string) $item['world_title']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function authorCanPublishLinks(): bool
|
||||
{
|
||||
$level = (int) ($this->user?->level ?? 1);
|
||||
|
||||
@@ -26,6 +26,8 @@ final class AnalyzeArtworkAiAssistJob implements ShouldQueue
|
||||
public function __construct(
|
||||
private readonly int $artworkId,
|
||||
private readonly bool $force = false,
|
||||
private readonly ?string $intent = null,
|
||||
private readonly ?string $provider = null,
|
||||
) {
|
||||
$queue = (string) config('vision.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
@@ -45,6 +47,6 @@ final class AnalyzeArtworkAiAssistJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$aiAssist->analyze($artwork, $this->force);
|
||||
$aiAssist->analyze($artwork, $this->force, $this->intent, $this->provider);
|
||||
}
|
||||
}
|
||||
68
app/Jobs/GenerateAiBiographyJob.php
Normal file
68
app/Jobs/GenerateAiBiographyJob.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Generates or refreshes an AI biography for a creator asynchronously.
|
||||
*
|
||||
* Dispatched by:
|
||||
* - Manual generate / regenerate API endpoints
|
||||
* - Admin batch commands
|
||||
* - Stale-detection refresh passes
|
||||
*/
|
||||
final class GenerateAiBiographyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
|
||||
public int $timeout = 90;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $userId,
|
||||
public readonly bool $force = false,
|
||||
public readonly ?string $provider = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(AiBiographyService $biographyService): void
|
||||
{
|
||||
if ($this->provider !== null && $this->provider !== '') {
|
||||
config(['ai_biography.provider_override' => $this->provider]);
|
||||
config(['ai_biography.provider' => $this->provider]);
|
||||
}
|
||||
|
||||
$user = User::query()
|
||||
->where('id', $this->userId)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
Log::warning('GenerateAiBiographyJob: user not found or inactive', ['user_id' => $this->userId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $biographyService->regenerate($user, $this->force);
|
||||
|
||||
if (! $result['success']) {
|
||||
Log::warning('GenerateAiBiographyJob: generation failed', [
|
||||
'user_id' => $this->userId,
|
||||
'action' => $result['action'],
|
||||
'errors' => $result['errors'],
|
||||
'provider' => $this->provider,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,11 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\RankList;
|
||||
use App\Services\RankingService;
|
||||
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\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -33,174 +30,40 @@ class RankBuildListsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 1800;
|
||||
public int $tries = 2;
|
||||
public int $timeout = 300;
|
||||
public int $tries = 1;
|
||||
|
||||
private const LIST_TYPES = ['trending', 'new_hot', 'best'];
|
||||
|
||||
public function handle(RankingService $ranking): void
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = config('ranking.model_version', 'rank_v1');
|
||||
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
|
||||
$listSize = (int) config('ranking.diversity.list_size', 50);
|
||||
$candidatePool = (int) config('ranking.diversity.candidate_pool', 200);
|
||||
$listsBuilt = 0;
|
||||
$scopesDispatched = 0;
|
||||
|
||||
// ── 1. Global ──────────────────────────────────────────────────────
|
||||
foreach (self::LIST_TYPES as $listType) {
|
||||
$this->buildAndStore(
|
||||
$ranking, $listType, 'global', 0,
|
||||
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
|
||||
);
|
||||
$listsBuilt++;
|
||||
}
|
||||
RankBuildScopeListsJob::dispatch('global', 0);
|
||||
$scopesDispatched++;
|
||||
|
||||
// ── 2. Per category ────────────────────────────────────────────────
|
||||
Category::query()
|
||||
->select(['id'])
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->chunk(200, function ($categories) use (
|
||||
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
|
||||
): void {
|
||||
->chunk(200, function ($categories) use (&$scopesDispatched): void {
|
||||
foreach ($categories as $cat) {
|
||||
foreach (self::LIST_TYPES as $listType) {
|
||||
$this->buildAndStore(
|
||||
$ranking, $listType, 'category', $cat->id,
|
||||
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
|
||||
);
|
||||
$listsBuilt++;
|
||||
}
|
||||
RankBuildScopeListsJob::dispatch('category', (int) $cat->id);
|
||||
$scopesDispatched++;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 3. Per content type ────────────────────────────────────────────
|
||||
ContentType::query()
|
||||
->select(['id'])
|
||||
->orderBy('id')
|
||||
->chunk(50, function ($ctypes) use (
|
||||
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
|
||||
): void {
|
||||
->chunk(50, function ($ctypes) use (&$scopesDispatched): void {
|
||||
foreach ($ctypes as $ct) {
|
||||
foreach (self::LIST_TYPES as $listType) {
|
||||
$this->buildAndStore(
|
||||
$ranking, $listType, 'content_type', $ct->id,
|
||||
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
|
||||
);
|
||||
$listsBuilt++;
|
||||
}
|
||||
RankBuildScopeListsJob::dispatch('content_type', (int) $ct->id);
|
||||
$scopesDispatched++;
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('RankBuildListsJob: finished', [
|
||||
'lists_built' => $listsBuilt,
|
||||
'model_version' => $modelVersion,
|
||||
Log::info('RankBuildListsJob: dispatched scope rebuild jobs', [
|
||||
'scopes_dispatched' => $scopesDispatched,
|
||||
'model_version' => config('ranking.model_version', 'rank_v1'),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch candidates, apply diversity, and upsert the resulting list.
|
||||
*/
|
||||
private function buildAndStore(
|
||||
RankingService $ranking,
|
||||
string $listType,
|
||||
string $scopeType,
|
||||
int $scopeId,
|
||||
string $modelVersion,
|
||||
int $maxPerAuthor,
|
||||
int $listSize,
|
||||
int $candidatePool
|
||||
): void {
|
||||
$scoreCol = $this->scoreColumn($listType);
|
||||
$candidates = $this->fetchCandidates(
|
||||
$scopeType, $scopeId, $scoreCol, $candidatePool, $modelVersion
|
||||
);
|
||||
|
||||
$diverse = $ranking->applyDiversity(
|
||||
$candidates->all(), $maxPerAuthor, $listSize
|
||||
);
|
||||
|
||||
$ids = array_map(
|
||||
fn ($item) => (int) ($item->artwork_id ?? $item['artwork_id']),
|
||||
$diverse
|
||||
);
|
||||
|
||||
// Upsert the list (unique: scope_type + scope_id + list_type + model_version)
|
||||
DB::table('rank_lists')->upsert(
|
||||
[[
|
||||
'scope_type' => $scopeType,
|
||||
'scope_id' => $scopeId,
|
||||
'list_type' => $listType,
|
||||
'model_version' => $modelVersion,
|
||||
'artwork_ids' => json_encode($ids),
|
||||
'computed_at' => now()->toDateTimeString(),
|
||||
]],
|
||||
['scope_type', 'scope_id', 'list_type', 'model_version'],
|
||||
['artwork_ids', 'computed_at']
|
||||
);
|
||||
|
||||
// Bust Redis cache so next request picks up the new list
|
||||
$ranking->bustCache($scopeType, $scopeId === 0 ? null : $scopeId, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch top N candidates (with user_id) for a given scope/score column.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, object>
|
||||
*/
|
||||
private function fetchCandidates(
|
||||
string $scopeType,
|
||||
int $scopeId,
|
||||
string $scoreCol,
|
||||
int $limit,
|
||||
string $modelVersion
|
||||
): \Illuminate\Support\Collection {
|
||||
$query = DB::table('rank_artwork_scores as ras')
|
||||
->select(['ras.artwork_id', 'a.user_id', "ras.{$scoreCol}"])
|
||||
->join('artworks as a', function ($join): void {
|
||||
$join->on('a.id', '=', 'ras.artwork_id')
|
||||
->where('a.is_public', 1)
|
||||
->where('a.is_approved', 1)
|
||||
->whereNull('a.deleted_at');
|
||||
})
|
||||
->where('ras.model_version', $modelVersion)
|
||||
->orderByDesc("ras.{$scoreCol}")
|
||||
->limit($limit);
|
||||
|
||||
if ($scopeType === 'category' && $scopeId > 0) {
|
||||
$query->join(
|
||||
'artwork_category as ac',
|
||||
fn ($j) => $j->on('ac.artwork_id', '=', 'a.id')
|
||||
->where('ac.category_id', $scopeId)
|
||||
);
|
||||
}
|
||||
|
||||
if ($scopeType === 'content_type' && $scopeId > 0) {
|
||||
$query->join(
|
||||
'artwork_category as ac',
|
||||
'ac.artwork_id', '=', 'a.id'
|
||||
)->join(
|
||||
'categories as cat',
|
||||
fn ($j) => $j->on('cat.id', '=', 'ac.category_id')
|
||||
->where('cat.content_type_id', $scopeId)
|
||||
->whereNull('cat.deleted_at')
|
||||
);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map list_type to the rank_artwork_scores column name.
|
||||
*/
|
||||
private function scoreColumn(string $listType): string
|
||||
{
|
||||
return match ($listType) {
|
||||
'new_hot' => 'score_new_hot',
|
||||
'best' => 'score_best',
|
||||
default => 'score_trending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
150
app/Jobs/RankBuildScopeListsJob.php
Normal file
150
app/Jobs/RankBuildScopeListsJob.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\RankingService;
|
||||
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\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RankBuildScopeListsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
public int $tries = 2;
|
||||
|
||||
private const LIST_TYPES = ['trending', 'new_hot', 'best'];
|
||||
|
||||
public function __construct(
|
||||
public readonly string $scopeType,
|
||||
public readonly int $scopeId,
|
||||
) {}
|
||||
|
||||
public function handle(RankingService $ranking): void
|
||||
{
|
||||
$modelVersion = config('ranking.model_version', 'rank_v1');
|
||||
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
|
||||
$listSize = (int) config('ranking.diversity.list_size', 50);
|
||||
$candidatePool = (int) config('ranking.diversity.candidate_pool', 200);
|
||||
|
||||
foreach (self::LIST_TYPES as $listType) {
|
||||
$this->buildAndStore(
|
||||
$ranking,
|
||||
$listType,
|
||||
$this->scopeType,
|
||||
$this->scopeId,
|
||||
$modelVersion,
|
||||
$maxPerAuthor,
|
||||
$listSize,
|
||||
$candidatePool
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('RankBuildScopeListsJob: finished', [
|
||||
'scope_type' => $this->scopeType,
|
||||
'scope_id' => $this->scopeId,
|
||||
'model_version' => $modelVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildAndStore(
|
||||
RankingService $ranking,
|
||||
string $listType,
|
||||
string $scopeType,
|
||||
int $scopeId,
|
||||
string $modelVersion,
|
||||
int $maxPerAuthor,
|
||||
int $listSize,
|
||||
int $candidatePool
|
||||
): void {
|
||||
$scoreCol = $this->scoreColumn($listType);
|
||||
$candidates = $this->fetchCandidates(
|
||||
$scopeType, $scopeId, $scoreCol, $candidatePool, $modelVersion
|
||||
);
|
||||
|
||||
$diverse = $ranking->applyDiversity(
|
||||
$candidates->all(), $maxPerAuthor, $listSize
|
||||
);
|
||||
|
||||
$ids = array_map(
|
||||
fn ($item) => (int) ($item->artwork_id ?? $item['artwork_id']),
|
||||
$diverse
|
||||
);
|
||||
|
||||
DB::table('rank_lists')->upsert(
|
||||
[[
|
||||
'scope_type' => $scopeType,
|
||||
'scope_id' => $scopeId,
|
||||
'list_type' => $listType,
|
||||
'model_version' => $modelVersion,
|
||||
'artwork_ids' => json_encode($ids),
|
||||
'computed_at' => now()->toDateTimeString(),
|
||||
]],
|
||||
['scope_type', 'scope_id', 'list_type', 'model_version'],
|
||||
['artwork_ids', 'computed_at']
|
||||
);
|
||||
|
||||
$ranking->bustCache($scopeType, $scopeId === 0 ? null : $scopeId, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, object>
|
||||
*/
|
||||
private function fetchCandidates(
|
||||
string $scopeType,
|
||||
int $scopeId,
|
||||
string $scoreCol,
|
||||
int $limit,
|
||||
string $modelVersion
|
||||
): \Illuminate\Support\Collection {
|
||||
$query = DB::table('rank_artwork_scores as ras')
|
||||
->select(['ras.artwork_id', 'a.user_id', "ras.{$scoreCol}"])
|
||||
->join('artworks as a', function ($join): void {
|
||||
$join->on('a.id', '=', 'ras.artwork_id')
|
||||
->where('a.is_public', 1)
|
||||
->where('a.is_approved', 1)
|
||||
->whereNull('a.deleted_at');
|
||||
})
|
||||
->where('ras.model_version', $modelVersion)
|
||||
->orderByDesc("ras.{$scoreCol}")
|
||||
->limit($limit);
|
||||
|
||||
if ($scopeType === 'category' && $scopeId > 0) {
|
||||
$query->join(
|
||||
'artwork_category as ac',
|
||||
fn ($j) => $j->on('ac.artwork_id', '=', 'a.id')
|
||||
->where('ac.category_id', $scopeId)
|
||||
);
|
||||
}
|
||||
|
||||
if ($scopeType === 'content_type' && $scopeId > 0) {
|
||||
$query->join(
|
||||
'artwork_category as ac',
|
||||
'ac.artwork_id', '=', 'a.id'
|
||||
)->join(
|
||||
'categories as cat',
|
||||
fn ($j) => $j->on('cat.id', '=', 'ac.category_id')
|
||||
->where('cat.content_type_id', $scopeId)
|
||||
->whereNull('cat.deleted_at')
|
||||
);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function scoreColumn(string $listType): string
|
||||
{
|
||||
return match ($listType) {
|
||||
'new_hot' => 'score_new_hot',
|
||||
'best' => 'score_best',
|
||||
default => 'score_trending',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,11 @@ class Artwork extends Model
|
||||
return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function worldSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function isPublishedByGroup(): bool
|
||||
{
|
||||
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
|
||||
@@ -516,6 +521,20 @@ class Artwork extends Model
|
||||
->where("{$table}.published_at", '<=', now());
|
||||
}
|
||||
|
||||
public function scopeCatalogVisible(Builder $query): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
|
||||
return $query
|
||||
->approved()
|
||||
->where("{$table}.is_public", true)
|
||||
->where(function (Builder $visibilityQuery) use ($table): void {
|
||||
$visibilityQuery->whereNull("{$table}.visibility")
|
||||
->orWhere("{$table}.visibility", self::VISIBILITY_PUBLIC);
|
||||
})
|
||||
->published();
|
||||
}
|
||||
|
||||
public function scopeSafeForGeneralAudience(Builder $query): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
|
||||
100
app/Models/CreatorAiBiography.php
Normal file
100
app/Models/CreatorAiBiography.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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|null $text
|
||||
* @property string|null $source_hash
|
||||
* @property string|null $model
|
||||
* @property string|null $prompt_version
|
||||
* @property string|null $input_quality_tier
|
||||
* @property string|null $generation_reason
|
||||
* @property string $status
|
||||
* @property bool $is_active
|
||||
* @property bool $is_hidden
|
||||
* @property bool $is_user_edited
|
||||
* @property bool $needs_review
|
||||
* @property \Carbon\Carbon|null $generated_at
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property \Carbon\Carbon|null $last_attempted_at
|
||||
* @property string|null $last_error_code
|
||||
* @property string|null $last_error_reason
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
final class CreatorAiBiography extends Model
|
||||
{
|
||||
public const STATUS_GENERATED = 'generated';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_EDITED = 'edited';
|
||||
public const STATUS_HIDDEN = 'hidden';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_NEEDS_REVIEW = 'needs_review';
|
||||
public const STATUS_SUPPRESSED = 'suppressed_low_signal';
|
||||
|
||||
public const TIER_RICH = 'rich';
|
||||
public const TIER_MEDIUM = 'medium';
|
||||
public const TIER_SPARSE = 'sparse';
|
||||
|
||||
public const REASON_INITIAL_GENERATE = 'initial_generate';
|
||||
public const REASON_MANUAL_REGENERATE = 'manual_regenerate';
|
||||
public const REASON_STALE_REFRESH = 'stale_refresh';
|
||||
public const REASON_MILESTONE_CHANGE = 'milestone_change';
|
||||
public const REASON_ERA_CHANGE = 'era_change';
|
||||
public const REASON_FEATURED_CHANGE = 'featured_change';
|
||||
public const REASON_ADMIN_BATCH = 'admin_batch';
|
||||
public const REASON_RETRY_AFTER_FAILURE = 'retry_after_validation_failure';
|
||||
|
||||
protected $table = 'creator_ai_biographies';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'text',
|
||||
'source_hash',
|
||||
'model',
|
||||
'prompt_version',
|
||||
'input_quality_tier',
|
||||
'generation_reason',
|
||||
'status',
|
||||
'is_active',
|
||||
'is_hidden',
|
||||
'is_user_edited',
|
||||
'needs_review',
|
||||
'generated_at',
|
||||
'approved_at',
|
||||
'last_attempted_at',
|
||||
'last_error_code',
|
||||
'last_error_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'is_hidden' => 'boolean',
|
||||
'is_user_edited' => 'boolean',
|
||||
'needs_review' => 'boolean',
|
||||
'generated_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'last_attempted_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
return $this->is_active
|
||||
&& ! $this->is_hidden
|
||||
&& in_array($this->status, [self::STATUS_GENERATED, self::STATUS_APPROVED, self::STATUS_EDITED, self::STATUS_NEEDS_REVIEW], true)
|
||||
&& $this->text !== null
|
||||
&& trim($this->text) !== '';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class Leaderboard extends Model
|
||||
public const TYPE_ARTWORK = 'artwork';
|
||||
public const TYPE_GROUP = 'group';
|
||||
public const TYPE_STORY = 'story';
|
||||
public const TYPE_WORLD = 'world';
|
||||
|
||||
public const PERIOD_DAILY = 'daily';
|
||||
public const PERIOD_WEEKLY = 'weekly';
|
||||
|
||||
@@ -29,6 +29,11 @@ use Laravel\Scout\Searchable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
private const EMAIL_LOGIN_UPGRADE_PLACEHOLDER_DOMAINS = [
|
||||
'users.skinbase.org',
|
||||
'legacy.skinbase.org',
|
||||
];
|
||||
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
use Searchable {
|
||||
@@ -114,6 +119,40 @@ class User extends Authenticatable
|
||||
];
|
||||
}
|
||||
|
||||
public function hasCompletedOnboarding(): bool
|
||||
{
|
||||
return strtolower(trim((string) ($this->onboarding_step ?? ''))) === 'complete';
|
||||
}
|
||||
|
||||
public function requiresEmailLoginUpgrade(): bool
|
||||
{
|
||||
return ! $this->hasCompletedOnboarding()
|
||||
&& self::isEmailLoginUpgradePlaceholder($this->email);
|
||||
}
|
||||
|
||||
public static function isEmailLoginUpgradePlaceholder(?string $email): bool
|
||||
{
|
||||
$email = strtolower(trim((string) ($email ?? '')));
|
||||
|
||||
if ($email === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$atPos = strrpos($email, '@');
|
||||
if ($atPos === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$domain = substr($email, $atPos + 1);
|
||||
|
||||
return in_array($domain, self::EMAIL_LOGIN_UPGRADE_PLACEHOLDER_DOMAINS, true);
|
||||
}
|
||||
|
||||
public function supportsUsernameLogin(): bool
|
||||
{
|
||||
return ! $this->hasCompletedOnboarding();
|
||||
}
|
||||
|
||||
public function novaCards(): HasMany
|
||||
{
|
||||
return $this->hasMany(NovaCard::class);
|
||||
@@ -297,6 +336,15 @@ class User extends Authenticatable
|
||||
return strtolower((string) ($this->role ?? '')) === strtolower($role);
|
||||
}
|
||||
|
||||
private function hasLegacyPrivilegeFlag(string $attribute): bool
|
||||
{
|
||||
if (! array_key_exists($attribute, $this->getAttributes())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->getAttribute($attribute);
|
||||
}
|
||||
|
||||
// ─── Follow helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -334,12 +382,12 @@ class User extends Authenticatable
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
return $this->hasRole('admin') || $this->hasLegacyPrivilegeFlag('isAdmin');
|
||||
}
|
||||
|
||||
public function isModerator(): bool
|
||||
{
|
||||
return $this->hasRole('moderator');
|
||||
return $this->hasRole('moderator') || $this->hasLegacyPrivilegeFlag('isModerator');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
|
||||
290
app/Models/World.php
Normal file
290
app/Models/World.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class World extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const TYPE_SEASONAL = 'seasonal';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_CAMPAIGN = 'campaign';
|
||||
public const TYPE_TRIBUTE = 'tribute';
|
||||
|
||||
public const PARTICIPATION_MODE_MANUAL_APPROVAL = 'manual_approval';
|
||||
public const PARTICIPATION_MODE_AUTO_ADD = 'auto_add';
|
||||
public const PARTICIPATION_MODE_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'tagline',
|
||||
'summary',
|
||||
'description',
|
||||
'cover_path',
|
||||
'theme_key',
|
||||
'accent_color',
|
||||
'accent_color_secondary',
|
||||
'background_motif',
|
||||
'icon_name',
|
||||
'status',
|
||||
'type',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'submission_starts_at',
|
||||
'submission_ends_at',
|
||||
'is_featured',
|
||||
'accepts_submissions',
|
||||
'participation_mode',
|
||||
'submission_note_enabled',
|
||||
'community_section_enabled',
|
||||
'allow_readd_after_removal',
|
||||
'is_recurring',
|
||||
'recurrence_key',
|
||||
'recurrence_rule',
|
||||
'edition_year',
|
||||
'cta_label',
|
||||
'cta_url',
|
||||
'badge_label',
|
||||
'badge_description',
|
||||
'submission_guidelines',
|
||||
'badge_url',
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'og_image_path',
|
||||
'related_tags_json',
|
||||
'section_order_json',
|
||||
'section_visibility_json',
|
||||
'parent_world_id',
|
||||
'created_by_user_id',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'submission_starts_at' => 'datetime',
|
||||
'submission_ends_at' => 'datetime',
|
||||
'published_at' => 'datetime',
|
||||
'is_featured' => 'boolean',
|
||||
'accepts_submissions' => 'boolean',
|
||||
'allow_readd_after_removal' => 'boolean',
|
||||
'submission_note_enabled' => 'boolean',
|
||||
'community_section_enabled' => 'boolean',
|
||||
'is_recurring' => 'boolean',
|
||||
'edition_year' => 'integer',
|
||||
'related_tags_json' => 'array',
|
||||
'section_order_json' => 'array',
|
||||
'section_visibility_json' => 'array',
|
||||
];
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function parentWorld(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_world_id');
|
||||
}
|
||||
|
||||
public function archiveEditions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_world_id')->orderByDesc('edition_year')->orderByDesc('starts_at');
|
||||
}
|
||||
|
||||
public function worldRelations(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldRelation::class)->orderBy('section_key')->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
public function worldSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', self::STATUS_PUBLISHED)
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopePubliclyVisible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereIn('status', [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED])
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->published()
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->published()
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '>', now());
|
||||
}
|
||||
|
||||
public function scopeArchive(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->publiclyVisible()
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('status', self::STATUS_ARCHIVED)
|
||||
->orWhere(function (Builder $expired): void {
|
||||
$expired->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function isPubliclyVisible(): bool
|
||||
{
|
||||
if (! in_array($this->status, [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->published_at && $this->published_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
if (! $this->isPubliclyVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isAcceptingSubmissions(): bool
|
||||
{
|
||||
if (! $this->isPubliclyVisible() || ! $this->accepts_submissions || ! $this->allowsCreatorParticipation()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->submission_starts_at && $this->submission_starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->submission_ends_at && $this->submission_ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function allowsCreatorParticipation(): bool
|
||||
{
|
||||
return in_array((string) $this->participation_mode, [
|
||||
self::PARTICIPATION_MODE_MANUAL_APPROVAL,
|
||||
self::PARTICIPATION_MODE_AUTO_ADD,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function submissionStartsAsLive(): bool
|
||||
{
|
||||
return (string) $this->participation_mode === self::PARTICIPATION_MODE_AUTO_ADD;
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
$path = trim((string) $this->cover_path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function ogImageUrl(): ?string
|
||||
{
|
||||
$path = trim((string) ($this->og_image_path ?: $this->cover_path ?: ''));
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return route('worlds.show', ['world' => $this->slug]);
|
||||
}
|
||||
|
||||
public function sectionOrder(): array
|
||||
{
|
||||
$defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string'));
|
||||
$custom = array_values(array_filter($this->section_order_json ?? [], 'is_string'));
|
||||
|
||||
return array_values(array_unique(array_merge($custom, $defaults)));
|
||||
}
|
||||
|
||||
public function sectionVisibility(): array
|
||||
{
|
||||
$defaults = collect(array_keys((array) config('worlds.sections', [])))
|
||||
->mapWithKeys(fn (string $key): array => [$key => true])
|
||||
->all();
|
||||
|
||||
$custom = collect((array) $this->section_visibility_json)
|
||||
->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value])
|
||||
->all();
|
||||
|
||||
return array_merge($defaults, $custom);
|
||||
}
|
||||
}
|
||||
45
app/Models/WorldRelation.php
Normal file
45
app/Models/WorldRelation.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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 WorldRelation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_ARTWORK = 'artwork';
|
||||
public const TYPE_COLLECTION = 'collection';
|
||||
public const TYPE_USER = 'user';
|
||||
public const TYPE_GROUP = 'group';
|
||||
public const TYPE_NEWS = 'news';
|
||||
public const TYPE_CHALLENGE = 'challenge';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_RELEASE = 'release';
|
||||
public const TYPE_CARD = 'card';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'section_key',
|
||||
'context_label',
|
||||
'sort_order',
|
||||
'is_featured',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'related_id' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'is_featured' => 'boolean',
|
||||
];
|
||||
|
||||
public function world(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(World::class);
|
||||
}
|
||||
}
|
||||
74
app/Models/WorldSubmission.php
Normal file
74
app/Models/WorldSubmission.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?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 WorldSubmission extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_LIVE = 'live';
|
||||
public const STATUS_REMOVED = 'removed';
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'artwork_id',
|
||||
'submitted_by_user_id',
|
||||
'status',
|
||||
'is_featured',
|
||||
'mode_snapshot',
|
||||
'note',
|
||||
'reviewer_note',
|
||||
'moderation_reason',
|
||||
'reviewed_by_user_id',
|
||||
'reviewed_at',
|
||||
'removed_at',
|
||||
'blocked_at',
|
||||
'featured_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'reviewed_at' => 'datetime',
|
||||
'removed_at' => 'datetime',
|
||||
'blocked_at' => 'datetime',
|
||||
'featured_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function canBeReadded(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_REMOVED;
|
||||
}
|
||||
|
||||
public function isBlockingResubmission(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_BLOCKED;
|
||||
}
|
||||
|
||||
public function world(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(World::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function submittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_user_id');
|
||||
}
|
||||
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by_user_id');
|
||||
}
|
||||
}
|
||||
40
app/Policies/WorldPolicy.php
Normal file
40
app/Policies/WorldPolicy.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
|
||||
class WorldPolicy
|
||||
{
|
||||
public function view(?User $user, World $world): bool
|
||||
{
|
||||
if ($user && ($user->isAdmin() || $user->isModerator())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $world->isPubliclyVisible();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isModerator();
|
||||
}
|
||||
|
||||
public function update(User $user, World $world): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isModerator();
|
||||
}
|
||||
|
||||
public function delete(User $user, World $world): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isModerator();
|
||||
}
|
||||
|
||||
public function manage(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isModerator();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Models\Group;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\World;
|
||||
use App\Policies\ArtworkPolicy;
|
||||
use App\Policies\ArtworkAwardPolicy;
|
||||
use App\Policies\ArtworkCommentPolicy;
|
||||
@@ -19,6 +20,7 @@ use App\Policies\GroupPolicy;
|
||||
use App\Policies\NovaCardPolicy;
|
||||
use App\Policies\PostPolicy;
|
||||
use App\Policies\PostCommentPolicy;
|
||||
use App\Policies\WorldPolicy;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -31,6 +33,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
NovaCard::class => NovaCardPolicy::class,
|
||||
Post::class => PostPolicy::class,
|
||||
PostComment::class => PostCommentPolicy::class,
|
||||
World::class => WorldPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal file
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Coordinates prompt building, Vision gateway call, and result validation.
|
||||
*
|
||||
* v1.1 changes:
|
||||
* – Accepts quality tier so the prompt builder can choose the right template.
|
||||
* – One controlled retry on validation failure, using strict/conservative mode.
|
||||
* – Returns prompt_version and was_retried in the result.
|
||||
* – Logging improved to include retry reason and quality tier.
|
||||
*
|
||||
* Does NOT read or write to the database.
|
||||
* Does NOT know about user-edit flags or storage decisions.
|
||||
* Those responsibilities belong to AiBiographyService.
|
||||
*/
|
||||
class AiBiographyGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiBiographyPromptBuilder $promptBuilder,
|
||||
private readonly VisionLlmClient $llmClient,
|
||||
private readonly AiBiographyValidator $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a biography from a normalized input payload.
|
||||
*
|
||||
* On validation failure a single controlled retry is performed with a
|
||||
* stricter/more conservative prompt. If the retry also fails, the result
|
||||
* reports the final combined errors alongside was_retried=true.
|
||||
*
|
||||
* Returns:
|
||||
* success bool
|
||||
* text string|null
|
||||
* errors list<string>
|
||||
* model string|null
|
||||
* prompt_version string
|
||||
* was_retried bool
|
||||
*
|
||||
* @param array<string, mixed> $input from AiBiographyInputBuilder::build()
|
||||
* @param string $qualityTier 'rich'|'medium'|'sparse'
|
||||
* @return array{success: bool, text: string|null, errors: list<string>, model: string|null, prompt_version: string, was_retried: bool}
|
||||
*/
|
||||
public function generate(array $input, string $qualityTier = 'rich'): array
|
||||
{
|
||||
Log::info('AiBiographyGenerator: generation started', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'quality_tier' => $qualityTier,
|
||||
]);
|
||||
|
||||
$result = $this->attempt($input, $qualityTier, strict: false);
|
||||
|
||||
if ($result['success']) {
|
||||
Log::info('AiBiographyGenerator: generation succeeded', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'quality_tier' => $qualityTier,
|
||||
]);
|
||||
|
||||
return array_merge($result, ['was_retried' => false]);
|
||||
}
|
||||
|
||||
// ── One retry with stricter prompt ───────────────────────────────────
|
||||
Log::info('AiBiographyGenerator: first attempt failed; retrying with strict prompt', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'quality_tier' => $qualityTier,
|
||||
'first_errors' => $result['errors'],
|
||||
]);
|
||||
|
||||
$retryResult = $this->attempt($input, $qualityTier, strict: true);
|
||||
|
||||
if ($retryResult['success']) {
|
||||
Log::info('AiBiographyGenerator: retry succeeded', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'prompt_version' => $retryResult['prompt_version'],
|
||||
]);
|
||||
|
||||
return array_merge($retryResult, ['was_retried' => true]);
|
||||
}
|
||||
|
||||
Log::warning('AiBiographyGenerator: retry also failed', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'first_errors' => $result['errors'],
|
||||
'retry_errors' => $retryResult['errors'],
|
||||
]);
|
||||
|
||||
return array_merge($retryResult, ['was_retried' => true]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Single generation attempt.
|
||||
*
|
||||
* @return array{success: bool, text: string|null, errors: list<string>, model: string|null, prompt_version: string}
|
||||
*/
|
||||
private function attempt(array $input, string $qualityTier, bool $strict): array
|
||||
{
|
||||
$isSparse = $qualityTier === 'sparse';
|
||||
$payload = $this->promptBuilder->build($input, strict: $strict, sparse: $isSparse);
|
||||
$promptVersion = (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION);
|
||||
|
||||
// Strip prompt_version before sending to the gateway (not a standard LLM field).
|
||||
$gatewayPayload = array_diff_key($payload, ['prompt_version' => true]);
|
||||
|
||||
try {
|
||||
$rawText = $this->llmClient->chat($gatewayPayload);
|
||||
} catch (VisionLlmException $e) {
|
||||
Log::warning('AiBiographyGenerator: gateway failure', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'text' => null,
|
||||
'errors' => [$e->getMessage()],
|
||||
'model' => null,
|
||||
'prompt_version' => $promptVersion,
|
||||
];
|
||||
}
|
||||
|
||||
$text = $this->normalizeOutput($rawText);
|
||||
|
||||
$errors = $this->validator->validate($text, $qualityTier);
|
||||
|
||||
if ($errors !== []) {
|
||||
Log::info('AiBiographyGenerator: validation rejected generated text', [
|
||||
'user_id' => $input['user_id'] ?? null,
|
||||
'quality_tier' => $qualityTier,
|
||||
'strict' => $strict,
|
||||
'errors' => $errors,
|
||||
'excerpt' => mb_substr($text, 0, 120),
|
||||
'prompt_version' => $promptVersion,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'text' => null,
|
||||
'errors' => $errors,
|
||||
'model' => null,
|
||||
'prompt_version' => $promptVersion,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'text' => $text,
|
||||
'errors' => [],
|
||||
'model' => $this->llmClient->configuredModel(),
|
||||
'prompt_version' => $promptVersion,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip model artifacts and normalize whitespace.
|
||||
*/
|
||||
private function normalizeOutput(string $rawText): string
|
||||
{
|
||||
// Strip chain-of-thought reasoning blocks emitted by some models (e.g. <think>...</think>).
|
||||
$rawText = (string) preg_replace('/<think>.*?<\/think>/si', '', $rawText);
|
||||
|
||||
// Strip common markdown formatting the model may add despite instructions.
|
||||
$rawText = (string) preg_replace('/\*\*([^*]+)\*\*/', '$1', $rawText); // **bold**
|
||||
$rawText = (string) preg_replace('/\*([^*\n]+)\*/', '$1', $rawText); // *italic*
|
||||
$rawText = (string) preg_replace('/^#{1,6}\s+/m', '', $rawText); // ## headings
|
||||
$rawText = (string) preg_replace('/`([^`]*)`/', '$1', $rawText); // `code`
|
||||
|
||||
// Normalize: collapse multiple consecutive newlines into a single space.
|
||||
$text = trim((string) preg_replace('/\n{2,}/', ' ', $rawText));
|
||||
$text = trim((string) preg_replace('/\s{2,}/', ' ', $text));
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use App\Models\ArtworkRelation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Builds a normalized, public-safe input payload from creator data.
|
||||
*
|
||||
* Data sources: user record, user_profiles, creator_milestones, creator_eras,
|
||||
* artworks (public only), artwork_features, artwork_relations.
|
||||
*
|
||||
* Privacy rules:
|
||||
* – Only public, approved, non-deleted artworks are used.
|
||||
* – No private milestones (is_public = false).
|
||||
* – No moderation, staff, or hidden data.
|
||||
* – No personal attributes (age, gender, location, religion, etc.).
|
||||
*/
|
||||
final class AiBiographyInputBuilder
|
||||
{
|
||||
/**
|
||||
* Build and return the normalized input array for a creator.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(User $user): array
|
||||
{
|
||||
$userId = (int) $user->id;
|
||||
|
||||
$memberSinceYear = (int) $user->created_at->format('Y');
|
||||
$yearsOnSkinbase = (int) now()->format('Y') - $memberSinceYear;
|
||||
|
||||
$uploadsCount = $this->publicUploadsCount($userId);
|
||||
$featuredCount = $this->featuredCount($userId);
|
||||
$downloadsCount = $this->totalDownloads($userId);
|
||||
$topCategories = $this->topCategories($userId);
|
||||
$topTags = $this->topTags($userId);
|
||||
$bestWork = $this->bestPerformingWork($userId);
|
||||
$mostProductiveYear = $this->mostProductiveYear($userId);
|
||||
$evolutionCount = $this->evolutionCount($userId);
|
||||
$activityStatus = $this->activityStatus($userId);
|
||||
$milestones = $this->publicMilestoneSignals($userId);
|
||||
$eras = $this->publicEras($userId);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'username' => (string) $user->username,
|
||||
'member_since_year' => $memberSinceYear,
|
||||
'years_on_skinbase' => max(0, $yearsOnSkinbase),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'featured_count' => $featuredCount,
|
||||
'downloads_count' => $downloadsCount,
|
||||
'top_categories' => $topCategories,
|
||||
'top_tags' => $topTags,
|
||||
'best_performing_work' => $bestWork,
|
||||
'most_productive_year' => $mostProductiveYear,
|
||||
'evolution_count' => $evolutionCount,
|
||||
'current_activity_status' => $activityStatus,
|
||||
'milestones' => $milestones,
|
||||
'eras' => $eras,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic SHA-256 hash from the normalized input.
|
||||
* Changing any meaningful field changes the hash, enabling stale detection.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function sourceHash(array $input): string
|
||||
{
|
||||
// Exclude fields that should not affect staleness:
|
||||
// – user_id / username: identity, not profile signal
|
||||
// – downloads_count: noisy micro-increments that change frequently without
|
||||
// meaningfully altering what the biography should say
|
||||
$excluded = ['user_id', 'username', 'downloads_count'];
|
||||
$significant = array_diff_key($input, array_flip($excluded));
|
||||
|
||||
return hash('sha256', json_encode($significant, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the creator's data richness for prompt and threshold decisions.
|
||||
*
|
||||
* rich – long history, featured work, milestones/eras/evolution
|
||||
* medium – some uploads, limited signal depth
|
||||
* sparse – very little data; may not warrant generation at all
|
||||
*
|
||||
* @param array<string, mixed> $input from build()
|
||||
*/
|
||||
public function qualityTier(array $input): string
|
||||
{
|
||||
$uploads = (int) ($input['uploads_count'] ?? 0);
|
||||
$featured = (int) ($input['featured_count'] ?? 0);
|
||||
$years = (int) ($input['years_on_skinbase'] ?? 0);
|
||||
$milestones = (array) ($input['milestones'] ?? []);
|
||||
$eras = (array) ($input['eras'] ?? []);
|
||||
$evolution = (int) ($input['evolution_count'] ?? 0);
|
||||
$hasComeBack = ! empty($milestones['has_comeback']);
|
||||
$hasStreak = (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3;
|
||||
|
||||
$richSignals = ($featured >= 1 ? 1 : 0)
|
||||
+ ($uploads >= 30 ? 1 : 0)
|
||||
+ ($hasComeBack || $hasStreak ? 1 : 0)
|
||||
+ (count($eras) >= 2 ? 1 : 0)
|
||||
+ ($evolution >= 2 ? 1 : 0);
|
||||
|
||||
if ($uploads >= 20 && $years >= 2 && $richSignals >= 2) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
if ($uploads >= 5 || $featured >= 1 || ($years >= 1 && $richSignals >= 1)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the creator has enough public data to warrant biography generation.
|
||||
*
|
||||
* Returns false for brand-new or essentially empty profiles where any
|
||||
* generated output would be generic or misleading.
|
||||
*
|
||||
* @param array<string, mixed> $input from build()
|
||||
*/
|
||||
public function meetsMinimumThreshold(array $input): bool
|
||||
{
|
||||
$uploads = (int) ($input['uploads_count'] ?? 0);
|
||||
$featured = (int) ($input['featured_count'] ?? 0);
|
||||
$categories = (array) ($input['top_categories'] ?? []);
|
||||
$milestones = (array) ($input['milestones'] ?? []);
|
||||
$years = (int) ($input['years_on_skinbase'] ?? 0);
|
||||
|
||||
return $uploads >= 3
|
||||
|| $featured >= 1
|
||||
|| ! empty($milestones['has_comeback'])
|
||||
|| (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3
|
||||
|| (count($categories) >= 1 && $uploads >= 1 && $years >= 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers – public data only
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function publicUploadsCount(int $userId): int
|
||||
{
|
||||
return (int) DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function featuredCount(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_features')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('artwork_features')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_features.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function totalDownloads(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_stats')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('artworks')
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->sum('artwork_stats.downloads');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topCategories(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_category') || ! Schema::hasTable('categories')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_category')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('categories.id', 'categories.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('categories.name')
|
||||
->limit(3)
|
||||
->pluck('categories.name')
|
||||
->map(fn ($n) => (string) $n)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topTags(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_tag') || ! Schema::hasTable('tags')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_tag')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
|
||||
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('tags.id', 'tags.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('tags.name')
|
||||
->limit(5)
|
||||
->pluck('tags.name')
|
||||
->map(fn ($n) => (string) $n)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, year: int}|null
|
||||
*/
|
||||
private function bestPerformingWork(int $userId): ?array
|
||||
{
|
||||
$query = DB::table('artworks')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->limit(1)
|
||||
->select('artworks.title', 'artworks.published_at');
|
||||
|
||||
if (Schema::hasTable('artwork_stats')) {
|
||||
$query
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByRaw('(COALESCE(artwork_stats.downloads, 0) + COALESCE(artwork_stats.views, 0) + COALESCE(artwork_stats.favorites, 0)) DESC');
|
||||
} else {
|
||||
$query->orderByDesc('artworks.published_at');
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => (string) $row->title,
|
||||
'year' => (int) date('Y', strtotime((string) $row->published_at)),
|
||||
];
|
||||
}
|
||||
|
||||
private function mostProductiveYear(int $userId): ?int
|
||||
{
|
||||
// Use strftime for SQLite compatibility; MySQL also supports strftime via
|
||||
// a compatibility shim, but we use a driver-agnostic expression here.
|
||||
$driver = DB::getDriverName();
|
||||
$yearExpr = $driver === 'sqlite'
|
||||
? "strftime('%Y', published_at)"
|
||||
: 'YEAR(published_at)';
|
||||
|
||||
$row = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
|
||||
->groupByRaw($yearExpr)
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $row !== null ? (int) $row->yr : null;
|
||||
}
|
||||
|
||||
private function evolutionCount(int $userId): int
|
||||
{
|
||||
if (! Schema::hasTable('artwork_relations')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$evolutionTypes = [
|
||||
ArtworkRelation::TYPE_REMASTER_OF,
|
||||
ArtworkRelation::TYPE_REMAKE_OF,
|
||||
ArtworkRelation::TYPE_REVISION_OF,
|
||||
];
|
||||
|
||||
return (int) DB::table('artwork_relations')
|
||||
->join('artworks as src', 'src.id', '=', 'artwork_relations.source_artwork_id')
|
||||
->where('src.user_id', $userId)
|
||||
->whereIn('artwork_relations.relation_type', $evolutionTypes)
|
||||
->whereNull('src.deleted_at')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function activityStatus(int $userId): string
|
||||
{
|
||||
$latestPublished = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->max('published_at');
|
||||
|
||||
if ($latestPublished === null) {
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
$daysSinceLast = now()->diffInDays(date('Y-m-d', strtotime((string) $latestPublished)));
|
||||
|
||||
if ($daysSinceLast <= 60) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if ($daysSinceLast <= 365) {
|
||||
return 'recently_active';
|
||||
}
|
||||
|
||||
// Check for comeback: a gap > 180 days before the latest upload.
|
||||
$previousPublished = DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->where('published_at', '<', $latestPublished)
|
||||
->max('published_at');
|
||||
|
||||
if ($previousPublished !== null) {
|
||||
$gapDays = (int) (strtotime((string) $latestPublished) - strtotime((string) $previousPublished)) / 86400;
|
||||
if ($gapDays >= 180) {
|
||||
return 'returning';
|
||||
}
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{has_comeback: bool, best_upload_streak_months: int}
|
||||
*/
|
||||
private function publicMilestoneSignals(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_milestones')) {
|
||||
return ['has_comeback' => false, 'best_upload_streak_months' => 0];
|
||||
}
|
||||
|
||||
$types = DB::table('creator_milestones')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
$hasComeback = in_array('comeback_detected', $types, true);
|
||||
|
||||
$streakRow = DB::table('creator_milestones')
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->whereIn('type', ['upload_streak_3', 'upload_streak_6', 'upload_streak_9', 'upload_streak_12'])
|
||||
->orderByRaw('priority DESC')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
$bestStreakMonths = 0;
|
||||
if ($streakRow !== null) {
|
||||
$streakMap = [
|
||||
'upload_streak_3' => 3,
|
||||
'upload_streak_6' => 6,
|
||||
'upload_streak_9' => 9,
|
||||
'upload_streak_12' => 12,
|
||||
];
|
||||
$bestStreakMonths = $streakMap[$streakRow->type] ?? 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'has_comeback' => $hasComeback,
|
||||
'best_upload_streak_months' => $bestStreakMonths,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, starts_at: string, ends_at: string|null}>
|
||||
*/
|
||||
private function publicEras(int $userId): array
|
||||
{
|
||||
if (! Schema::hasTable('creator_eras')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('creator_eras')
|
||||
->where('user_id', $userId)
|
||||
->orderBy('starts_at')
|
||||
->get(['title', 'starts_at', 'ends_at'])
|
||||
->map(fn ($row): array => [
|
||||
'title' => (string) $row->title,
|
||||
'starts_at' => (string) $row->starts_at,
|
||||
'ends_at' => $row->ends_at !== null ? (string) $row->ends_at : null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
/**
|
||||
* Builds the LLM prompt payload from a normalized creator input.
|
||||
*
|
||||
* v1.1 changes:
|
||||
* – PROMPT_VERSION constant tracks the active template family.
|
||||
* – Improved system prompt discourages formulaic openings and stat-dumps.
|
||||
* – Sparse profile branch uses a lighter, safer template.
|
||||
* – Strict mode is used on retry; produces a more conservative output.
|
||||
*
|
||||
* Prompt rules:
|
||||
* – Only include facts that are actually present in the input.
|
||||
* – Never instruct the model to invent details or speculate.
|
||||
* – Always require a single paragraph output with no markdown.
|
||||
* – Keep max_tokens tight to enforce the word cap.
|
||||
*/
|
||||
final class AiBiographyPromptBuilder
|
||||
{
|
||||
public const PROMPT_VERSION = 'v1.1';
|
||||
private const MIN_WORDS = 30;
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are a concise writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
|
||||
|
||||
Rules:
|
||||
- Use only the provided data. Do not invent achievements, personal details, visual style claims, or platform fame.
|
||||
- Do not write bullet points, headings, or markdown.
|
||||
- Output exactly one paragraph.
|
||||
- Do not exceed 140 words.
|
||||
- Avoid hype language: do not use "world-class", "iconic", "legendary", "renowned", "celebrated", "masterpiece", or "beloved".
|
||||
- Do not speculate about personality, age, gender, politics, religion, or private life.
|
||||
- Do not mention data points that are not provided or are zero/empty.
|
||||
- Do not open with "has been part of Skinbase since" or similar formulaic phrases. Vary the opening.
|
||||
- Mention only the 2 to 3 most meaningful signals. Do not list every available stat.
|
||||
- Do not write "creator journey shows..." — describe what the data reflects directly.
|
||||
- Prefer natural narrative flow over data listing.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, safe creator biography using only the facts provided. Be conservative.
|
||||
|
||||
Rules:
|
||||
- Use only the provided facts. Do not invent or speculate.
|
||||
- Output exactly one paragraph, no markdown, no headings, no bullets.
|
||||
- Maximum 100 words.
|
||||
- Mention only 1 or 2 standout facts. Do not list all available data.
|
||||
- Avoid any superlatives, praise, or style claims.
|
||||
- Do not mention missing or zero-value fields.
|
||||
- Keep the tone neutral, simple, and factual.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided.
|
||||
|
||||
Rules:
|
||||
- Use only the facts provided.
|
||||
- Output exactly one paragraph, no markdown, no bullets.
|
||||
- Write between 35 and 60 words.
|
||||
- Minimum 30 words.
|
||||
- Keep it simple. Mention member-since year and upload count if available.
|
||||
- Add one category or another factual signal when available so the paragraph has enough substance.
|
||||
- Do not invent anything. Do not praise. Do not speculate.
|
||||
- If data is very limited, use two short factual sentences rather than a fragment.
|
||||
PROMPT;
|
||||
|
||||
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
|
||||
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
|
||||
|
||||
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.
|
||||
|
||||
Rules:
|
||||
- Use only the facts provided.
|
||||
- Output exactly one paragraph, no markdown, no bullets.
|
||||
- Write between 35 and 50 words.
|
||||
- Minimum 30 words.
|
||||
- Prefer two short factual sentences.
|
||||
- Mention member-since year, upload count, and one category when available.
|
||||
- Do not invent anything. Do not praise. Do not speculate.
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Build the full messages payload for the LLM.
|
||||
*
|
||||
* @param array<string, mixed> $input normalized creator input from AiBiographyInputBuilder
|
||||
* @param bool $strict true on retry — forces more conservative output
|
||||
* @param bool $sparse true for sparse-profile creators
|
||||
* @return array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool, prompt_version: string}
|
||||
*/
|
||||
public function build(array $input, bool $strict = false, bool $sparse = false): array
|
||||
{
|
||||
if ($sparse && $strict) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_SPARSE_STRICT;
|
||||
$userPrompt = $this->buildSparseUserPrompt($input, strict: true);
|
||||
$maxTokens = 240;
|
||||
$temperature = 0.2;
|
||||
} elseif ($sparse) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_SPARSE;
|
||||
$userPrompt = $this->buildSparseUserPrompt($input, strict: false);
|
||||
$maxTokens = 320;
|
||||
$temperature = 0.3;
|
||||
} elseif ($strict) {
|
||||
$systemPrompt = self::SYSTEM_PROMPT_STRICT;
|
||||
$userPrompt = $this->buildUserPrompt($input, strict: true);
|
||||
$maxTokens = 450;
|
||||
$temperature = 0.25;
|
||||
} else {
|
||||
$systemPrompt = self::SYSTEM_PROMPT;
|
||||
$userPrompt = $this->buildUserPrompt($input, strict: false);
|
||||
$maxTokens = 600;
|
||||
$temperature = 0.45;
|
||||
}
|
||||
|
||||
return [
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'stream' => false,
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUserPrompt(array $input, bool $strict): string
|
||||
{
|
||||
$wordTarget = $strict ? '60 to 100' : '70 to 130';
|
||||
|
||||
$lines = [
|
||||
"Write a creator biography in {$wordTarget} words using only the facts below. Output one paragraph only.",
|
||||
'',
|
||||
];
|
||||
|
||||
$username = (string) ($input['username'] ?? '');
|
||||
if ($username !== '') {
|
||||
$lines[] = "- Creator: {$username}";
|
||||
}
|
||||
|
||||
$memberYear = $input['member_since_year'] ?? null;
|
||||
$years = $input['years_on_skinbase'] ?? null;
|
||||
if ($memberYear !== null && (int) $memberYear > 0) {
|
||||
$label = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
|
||||
$lines[] = "- Member since: {$memberYear}{$label}";
|
||||
}
|
||||
|
||||
$uploads = $input['uploads_count'] ?? 0;
|
||||
if ((int) $uploads > 0) {
|
||||
$lines[] = "- Total public uploads: {$uploads}";
|
||||
}
|
||||
|
||||
$featured = $input['featured_count'] ?? 0;
|
||||
if ((int) $featured > 0) {
|
||||
$lines[] = "- Featured artworks: {$featured}";
|
||||
}
|
||||
|
||||
$downloads = $input['downloads_count'] ?? 0;
|
||||
if ((int) $downloads > 5000) {
|
||||
$lines[] = sprintf('- Total downloads: %s', number_format((int) $downloads));
|
||||
}
|
||||
|
||||
$categories = $input['top_categories'] ?? [];
|
||||
if ($categories !== []) {
|
||||
$lines[] = '- Top categories: ' . implode(', ', array_slice((array) $categories, 0, 2));
|
||||
}
|
||||
|
||||
// On strict retry, trim tags to keep prompt tight and reduce hallucination surface.
|
||||
if (! $strict) {
|
||||
$tags = $input['top_tags'] ?? [];
|
||||
if ($tags !== []) {
|
||||
$lines[] = '- Common themes: ' . implode(', ', array_slice((array) $tags, 0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
$bestWork = $input['best_performing_work'] ?? null;
|
||||
if (is_array($bestWork) && isset($bestWork['title'], $bestWork['year'])) {
|
||||
$lines[] = "- Best-performing work: {$bestWork['title']} ({$bestWork['year']})";
|
||||
}
|
||||
|
||||
$productiveYear = $input['most_productive_year'] ?? null;
|
||||
if ($productiveYear !== null && (int) $productiveYear > 0) {
|
||||
$lines[] = "- Most productive year: {$productiveYear}";
|
||||
}
|
||||
|
||||
$status = $input['current_activity_status'] ?? null;
|
||||
if ($status !== null && $status !== '') {
|
||||
$statusLabels = [
|
||||
'active' => 'currently active',
|
||||
'recently_active' => 'recently active',
|
||||
'returning' => 'returning creator',
|
||||
'legacy' => 'long-standing creator',
|
||||
'inactive' => null,
|
||||
];
|
||||
$statusLabel = $statusLabels[$status] ?? null;
|
||||
if ($statusLabel !== null) {
|
||||
$lines[] = "- Activity: {$statusLabel}";
|
||||
}
|
||||
}
|
||||
|
||||
$milestones = $input['milestones'] ?? [];
|
||||
if (is_array($milestones)) {
|
||||
if (! empty($milestones['has_comeback'])) {
|
||||
$lines[] = '- Notable milestone: returned after a significant break';
|
||||
}
|
||||
$streak = (int) ($milestones['best_upload_streak_months'] ?? 0);
|
||||
if ($streak >= 3 && ! $strict) {
|
||||
$lines[] = "- Upload streak: {$streak} consecutive months";
|
||||
}
|
||||
}
|
||||
|
||||
// Include eras and evolution only when not on strict retry.
|
||||
if (! $strict) {
|
||||
$eras = $input['eras'] ?? [];
|
||||
if (is_array($eras) && count($eras) >= 2) {
|
||||
$eraTitles = array_column($eras, 'title');
|
||||
$lines[] = '- Creator eras: ' . implode(' → ', $eraTitles);
|
||||
}
|
||||
|
||||
$evolutionCount = $input['evolution_count'] ?? 0;
|
||||
if ((int) $evolutionCount > 0) {
|
||||
$lines[] = "- Remastered/evolved works: {$evolutionCount}";
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = 'Avoid hype. Do not open with a formulaic phrase. Do not list every stat. Output one paragraph only. No markdown.';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildSparseUserPrompt(array $input, bool $strict = false): string
|
||||
{
|
||||
$wordTarget = $strict ? '35 to 50' : '35 to 60';
|
||||
$lines = [
|
||||
"Write a brief, modest creator introduction in {$wordTarget} words using only these facts. Output one paragraph only.",
|
||||
'',
|
||||
];
|
||||
|
||||
$username = (string) ($input['username'] ?? '');
|
||||
if ($username !== '') {
|
||||
$lines[] = "- Creator: {$username}";
|
||||
}
|
||||
|
||||
$memberYear = $input['member_since_year'] ?? null;
|
||||
$years = $input['years_on_skinbase'] ?? null;
|
||||
if ($memberYear !== null && (int) $memberYear > 0) {
|
||||
$yearsLabel = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
|
||||
$lines[] = "- Member since: {$memberYear}{$yearsLabel}";
|
||||
}
|
||||
|
||||
$uploads = $input['uploads_count'] ?? 0;
|
||||
if ((int) $uploads > 0) {
|
||||
$lines[] = "- Public uploads: {$uploads}";
|
||||
}
|
||||
|
||||
$categories = $input['top_categories'] ?? [];
|
||||
if ($categories !== []) {
|
||||
$lines[] = '- Categories: ' . implode(', ', array_slice((array) $categories, 0, $strict ? 1 : 2));
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = 'Keep it simple and factual. Write at least ' . self::MIN_WORDS . ' words. Prefer two short sentences if needed. No praise. No markdown.';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
490
app/Services/AiBiography/AiBiographyService.php
Normal file
490
app/Services/AiBiography/AiBiographyService.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Orchestrates AI biography generation, storage, retrieval, and creator controls.
|
||||
*
|
||||
* v1.1 additions:
|
||||
* – Quality tier classification and minimum-threshold gating before generation.
|
||||
* – Sparse profiles below threshold are suppressed (or produce a safe fallback).
|
||||
* – All new metadata columns (prompt_version, input_quality_tier, generation_reason,
|
||||
* needs_review, last_attempted_at, last_error_code, last_error_reason) are written.
|
||||
* – Stale detection for user-edited biographies: sets needs_review=true instead of
|
||||
* silently overwriting, and stores a draft.
|
||||
* – Hidden biographies remain hidden unless explicitly shown again.
|
||||
* – adminInspect() returns full metadata for artisan/admin tooling.
|
||||
*
|
||||
* Public API:
|
||||
* generate(User, reason): array – generate and store a new biography
|
||||
* regenerate(User, force, reason): array – force-regenerate, respects user-edit lock
|
||||
* updateText(User, string): void – creator edits their biography
|
||||
* hide(User): void – creator hides their AI bio
|
||||
* show(User): void – creator re-enables their AI bio
|
||||
* publicPayload(User): array|null – public profile rendering payload
|
||||
* creatorStatusPayload(User): array – authenticated creator status (more fields)
|
||||
* adminInspect(User): array – full metadata for admin/artisan tooling
|
||||
* isStale(User): bool – source-hash staleness check
|
||||
*/
|
||||
final class AiBiographyService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiBiographyInputBuilder $inputBuilder,
|
||||
private readonly AiBiographyGenerator $generator,
|
||||
) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a biography for the user.
|
||||
*
|
||||
* 1. Classify quality tier.
|
||||
* 2. Check minimum-data threshold; suppress if below.
|
||||
* 3. If existing active bio is user-edited, store draft + flag needs_review.
|
||||
* 4. Otherwise generate and activate.
|
||||
*
|
||||
* @param string $reason why generation was triggered (CreatorAiBiography::REASON_*)
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
public function generate(User $user, string $reason = CreatorAiBiography::REASON_INITIAL_GENERATE): array
|
||||
{
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
$qualityTier = $this->inputBuilder->qualityTier($input);
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
Log::info('AiBiographyService: generate requested', [
|
||||
'user_id' => (int) $user->id,
|
||||
'quality_tier' => $qualityTier,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
// ── Minimum threshold check ──────────────────────────────────────────
|
||||
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
|
||||
Log::info('AiBiographyService: suppressed — below minimum data threshold', [
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'suppressed_low_signal',
|
||||
'errors' => ['Creator profile does not have enough public data for biography generation.'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── User-edited protection ────────────────────────────────────────────
|
||||
if ($existing !== null && $existing->is_user_edited) {
|
||||
return $this->storeDraftForUserEdited($user, $input, $sourceHash, $qualityTier, $reason, $existing);
|
||||
}
|
||||
|
||||
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-regenerate, respecting user-edit lock unless $force=true.
|
||||
*
|
||||
* @param string $reason
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
public function regenerate(
|
||||
User $user,
|
||||
bool $force = false,
|
||||
string $reason = CreatorAiBiography::REASON_MANUAL_REGENERATE,
|
||||
): array {
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
$qualityTier = $this->inputBuilder->qualityTier($input);
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
// ── Minimum threshold check ──────────────────────────────────────────
|
||||
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
|
||||
Log::info('AiBiographyService: regenerate suppressed — below minimum data threshold', [
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'suppressed_low_signal',
|
||||
'errors' => ['Creator profile does not have enough public data for biography generation.'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($existing !== null && $existing->is_user_edited && ! $force) {
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'user_edited_locked',
|
||||
'errors' => ['Existing biography is user-edited. Pass force=true to overwrite.'],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Creator controls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function updateText(User $user, string $text): void
|
||||
{
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->update([
|
||||
'text' => $text,
|
||||
'is_user_edited' => true,
|
||||
'needs_review' => false,
|
||||
'status' => CreatorAiBiography::STATUS_EDITED,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $text,
|
||||
'source_hash' => null,
|
||||
'model' => null,
|
||||
'prompt_version' => null,
|
||||
'input_quality_tier' => null,
|
||||
'generation_reason' => null,
|
||||
'status' => CreatorAiBiography::STATUS_EDITED,
|
||||
'is_active' => true,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => true,
|
||||
'needs_review' => false,
|
||||
'generated_at' => now(),
|
||||
'approved_at' => now(),
|
||||
'last_attempted_at' => null,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the biography. Hidden state persists until explicitly shown.
|
||||
*/
|
||||
public function hide(User $user): void
|
||||
{
|
||||
$this->activeRecord($user)?->update(['is_hidden' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show (un-hide) the biography. Requires explicit creator action.
|
||||
*/
|
||||
public function show(User $user): void
|
||||
{
|
||||
$this->activeRecord($user)?->update(['is_hidden' => false]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the public-facing payload for the profile API.
|
||||
* Returns null if no visible biography exists.
|
||||
*
|
||||
* @return array{text: string, is_visible: bool, is_user_edited: bool, generated_at: string|null, status: string}|null
|
||||
*/
|
||||
public function publicPayload(User $user): ?array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null || ! $record->isVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => (string) $record->text,
|
||||
'is_visible' => true,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'status' => (string) $record->status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated creator's full status payload.
|
||||
* Includes generation metadata not shown publicly.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function creatorStatusPayload(User $user): array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null) {
|
||||
return [
|
||||
'has_biography' => false,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'status' => null,
|
||||
'prompt_version' => null,
|
||||
'input_quality_tier' => null,
|
||||
'generation_reason' => null,
|
||||
'generated_at' => null,
|
||||
'last_attempted_at' => null,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'has_biography' => true,
|
||||
'is_visible' => $record->isVisible(),
|
||||
'is_hidden' => (bool) $record->is_hidden,
|
||||
'is_user_edited' => (bool) $record->is_user_edited,
|
||||
'needs_review' => (bool) $record->needs_review,
|
||||
'status' => (string) $record->status,
|
||||
'prompt_version' => $record->prompt_version,
|
||||
'input_quality_tier' => $record->input_quality_tier,
|
||||
'generation_reason' => $record->generation_reason,
|
||||
'generated_at' => $record->generated_at?->toIso8601String(),
|
||||
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
|
||||
'last_error_code' => $record->last_error_code,
|
||||
'last_error_reason' => $record->last_error_reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full metadata record for admin/artisan inspection.
|
||||
* Includes normalized input payload.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function adminInspect(User $user): array
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
$input = $this->inputBuilder->build($user);
|
||||
|
||||
return [
|
||||
'record' => $record?->toArray(),
|
||||
'input_payload' => $input,
|
||||
'quality_tier' => $this->inputBuilder->qualityTier($input),
|
||||
'meets_threshold' => $this->inputBuilder->meetsMinimumThreshold($input),
|
||||
'source_hash_live' => $this->inputBuilder->sourceHash($input),
|
||||
'is_stale' => $this->isStale($user),
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stale check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function isStale(User $user): bool
|
||||
{
|
||||
$record = $this->activeRecord($user);
|
||||
|
||||
if ($record === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$input = $this->inputBuilder->build($user);
|
||||
$sourceHash = $this->inputBuilder->sourceHash($input);
|
||||
|
||||
return $record->source_hash !== $sourceHash;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function activeRecord(User $user): ?CreatorAiBiography
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
private function generateAndActivate(
|
||||
User $user,
|
||||
array $input,
|
||||
string $sourceHash,
|
||||
string $qualityTier,
|
||||
string $reason,
|
||||
): array {
|
||||
$now = now();
|
||||
$result = $this->generator->generate($input, $qualityTier);
|
||||
|
||||
// ── Record attempt regardless of outcome ─────────────────────────────
|
||||
if (! $result['success']) {
|
||||
// Update last-attempt metadata on the existing active record if present,
|
||||
// or create a failed record for observability.
|
||||
$existing = $this->activeRecord($user);
|
||||
|
||||
$failedAttrs = [
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => 'generation_failed',
|
||||
'last_error_reason' => implode('; ', $result['errors']),
|
||||
];
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->update($failedAttrs);
|
||||
} else {
|
||||
CreatorAiBiography::create(array_merge([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => null,
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => null,
|
||||
'prompt_version' => $result['prompt_version'] ?? null,
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_FAILED,
|
||||
'is_active' => false,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => null,
|
||||
'approved_at' => null,
|
||||
], $failedAttrs));
|
||||
}
|
||||
|
||||
Log::warning('AiBiographyService: generation failed', [
|
||||
'user_id' => (int) $user->id,
|
||||
'errors' => $result['errors'],
|
||||
'retried' => $result['was_retried'] ?? false,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'generation_failed',
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now): void {
|
||||
// Deactivate any previous active records.
|
||||
CreatorAiBiography::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $result['text'],
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_GENERATED,
|
||||
'is_active' => true,
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => $now,
|
||||
'approved_at' => $now,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('AiBiographyService: biography generated and stored', [
|
||||
'user_id' => (int) $user->id,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'quality_tier' => $qualityTier,
|
||||
'was_retried' => $result['was_retried'] ?? false,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'generated',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a draft (non-active) without replacing the current user-edited biography.
|
||||
* Marks the existing user-edited record as needs_review so the creator is notified.
|
||||
*
|
||||
* @return array{success: bool, action: string, errors: list<string>}
|
||||
*/
|
||||
private function storeDraftForUserEdited(
|
||||
User $user,
|
||||
array $input,
|
||||
string $sourceHash,
|
||||
string $qualityTier,
|
||||
string $reason,
|
||||
CreatorAiBiography $existingEdited,
|
||||
): array {
|
||||
$now = now();
|
||||
$result = $this->generator->generate($input, $qualityTier);
|
||||
|
||||
if (! $result['success']) {
|
||||
$existingEdited->update([
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => 'generation_failed',
|
||||
'last_error_reason' => implode('; ', $result['errors']),
|
||||
]);
|
||||
|
||||
Log::warning('AiBiographyService: draft generation failed for user-edited bio', [
|
||||
'user_id' => (int) $user->id,
|
||||
'errors' => $result['errors'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'action' => 'generation_failed',
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now, $existingEdited): void {
|
||||
// Store the new generation as a non-active draft.
|
||||
CreatorAiBiography::create([
|
||||
'user_id' => (int) $user->id,
|
||||
'text' => $result['text'],
|
||||
'source_hash' => $sourceHash,
|
||||
'model' => $result['model'],
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
'input_quality_tier' => $qualityTier,
|
||||
'generation_reason' => $reason,
|
||||
'status' => CreatorAiBiography::STATUS_GENERATED,
|
||||
'is_active' => false, // kept as draft; user-edited version remains active
|
||||
'is_hidden' => false,
|
||||
'is_user_edited' => false,
|
||||
'needs_review' => false,
|
||||
'generated_at' => $now,
|
||||
'approved_at' => null,
|
||||
'last_attempted_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'last_error_reason' => null,
|
||||
]);
|
||||
|
||||
// Flag the active user-edited record: a newer AI draft is available.
|
||||
$existingEdited->update([
|
||||
'needs_review' => true,
|
||||
'last_attempted_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('AiBiographyService: draft stored for user-edited biography', [
|
||||
'user_id' => (int) $user->id,
|
||||
'prompt_version' => $result['prompt_version'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'draft_stored',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
241
app/Services/AiBiography/AiBiographyValidator.php
Normal file
241
app/Services/AiBiography/AiBiographyValidator.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
/**
|
||||
* Validates generated biography text before it is stored.
|
||||
*
|
||||
* v1.1 additions:
|
||||
* – Extended forbidden phrases (renowned, celebrated, iconic, etc.)
|
||||
* – Generic filler detection ("creator journey shows", "over the years" spam)
|
||||
* – Stat-dump detection (too many bare numbers in a short text)
|
||||
* – Repetitive phrase detection
|
||||
* – Sparse-profile mismatch check (rich-sounding bio for sparse creator)
|
||||
*
|
||||
* Rejects output that is:
|
||||
* – empty or too short to be useful
|
||||
* – too long (hard cap)
|
||||
* – not a single paragraph (multiple newlines separating blocks)
|
||||
* – contains markdown (headings, bullets, bold, italic, code)
|
||||
* – contains forbidden hype terms
|
||||
* – contains placeholder or apology patterns
|
||||
* – sounds too rich/boastful for a sparse creator profile
|
||||
*/
|
||||
final class AiBiographyValidator
|
||||
{
|
||||
private const MIN_WORDS = 20;
|
||||
private const MAX_WORDS = 180;
|
||||
|
||||
/**
|
||||
* Phrases that are always forbidden, regardless of tier.
|
||||
* These indicate hallucinated praise, AI-apology patterns, or unsupported claims.
|
||||
*/
|
||||
private const FORBIDDEN_PHRASES = [
|
||||
// Unsupported significance claims
|
||||
'world-class',
|
||||
'world class',
|
||||
'iconic visionary',
|
||||
'unmatched style',
|
||||
'legendary',
|
||||
'changed the platform',
|
||||
'beloved by everyone',
|
||||
'renowned for',
|
||||
'masterpiece creator',
|
||||
'masterclass',
|
||||
'celebrated artist',
|
||||
'celebrated creator',
|
||||
'celebrated by',
|
||||
'iconic creator',
|
||||
'iconic artist',
|
||||
'iconic work',
|
||||
'platform legend',
|
||||
'community favorite',
|
||||
'widely recognized',
|
||||
'highly regarded',
|
||||
'critically acclaimed',
|
||||
// AI apology / refusal patterns
|
||||
'i cannot',
|
||||
"i can't",
|
||||
'i apologize',
|
||||
'as an ai',
|
||||
'as a language model',
|
||||
'i do not have',
|
||||
"i don't have",
|
||||
'based on the information provided',
|
||||
'unfortunately',
|
||||
"i'm unable to",
|
||||
'i am unable to',
|
||||
// Vague over-praising filler
|
||||
'truly remarkable',
|
||||
'absolutely exceptional',
|
||||
'without a doubt',
|
||||
'undeniably talented',
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases that signal generic, formulaic filler when used more than once,
|
||||
* or which are always a warning sign of lazy output.
|
||||
* A single occurrence is allowed; repeated use is rejected.
|
||||
*/
|
||||
private const REPETITION_PHRASES = [
|
||||
'creator journey',
|
||||
'over the years',
|
||||
'has been part of skinbase',
|
||||
'has been a member',
|
||||
'throughout the years',
|
||||
'through the years',
|
||||
'journey on skinbase',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate the generated biography.
|
||||
*
|
||||
* @param string $text the generated biography text
|
||||
* @param string $qualityTier 'rich'|'medium'|'sparse' — used for sparse mismatch check
|
||||
* @return list<string> validation errors; empty list means valid
|
||||
*/
|
||||
public function validate(string $text, string $qualityTier = 'rich'): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$trimmed = trim($text);
|
||||
|
||||
if ($trimmed === '') {
|
||||
$errors[] = 'Biography is empty.';
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$wordCount = str_word_count($trimmed);
|
||||
|
||||
if ($wordCount < self::MIN_WORDS) {
|
||||
$errors[] = "Biography is too short ({$wordCount} words, minimum " . self::MIN_WORDS . ').';
|
||||
}
|
||||
|
||||
if ($wordCount > self::MAX_WORDS) {
|
||||
$errors[] = "Biography is too long ({$wordCount} words, maximum " . self::MAX_WORDS . ').';
|
||||
}
|
||||
|
||||
if ($this->containsMarkdown($trimmed)) {
|
||||
$errors[] = 'Biography contains markdown or structural formatting.';
|
||||
}
|
||||
|
||||
if ($this->hasMultipleParagraphs($trimmed)) {
|
||||
$errors[] = 'Biography contains multiple paragraphs; must be a single paragraph.';
|
||||
}
|
||||
|
||||
foreach (self::FORBIDDEN_PHRASES as $phrase) {
|
||||
if (str_contains(mb_strtolower($trimmed), $phrase)) {
|
||||
$errors[] = "Biography contains forbidden phrase: \"{$phrase}\".";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$repetitionError = $this->checkRepetition($trimmed);
|
||||
if ($repetitionError !== null) {
|
||||
$errors[] = $repetitionError;
|
||||
}
|
||||
|
||||
if ($qualityTier === 'sparse' && $this->soundsTooRichForSparseProfile($trimmed)) {
|
||||
$errors[] = 'Biography sounds too claim-heavy for a sparse creator profile.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public function isValid(string $text, string $qualityTier = 'rich'): bool
|
||||
{
|
||||
return $this->validate($text, $qualityTier) === [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function containsMarkdown(string $text): bool
|
||||
{
|
||||
// Headings: #, ##, ###
|
||||
if (preg_match('/^\s*#{1,6}\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bullets: lines starting with -, *, or numbered list
|
||||
if (preg_match('/^\s*[-*]\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*\d+\.\s/m', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bold / italic markers
|
||||
if (preg_match('/\*\*|__|\*[^*]|_[^_]/', $text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Code blocks or inline code
|
||||
if (str_contains($text, '`') || str_contains($text, '```')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasMultipleParagraphs(string $text): bool
|
||||
{
|
||||
// Two or more consecutive newlines indicate paragraph break.
|
||||
return (bool) preg_match('/\n\s*\n/', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any formulaic phrase appears more than once,
|
||||
* which usually indicates a recycled or low-quality output.
|
||||
*/
|
||||
private function checkRepetition(string $text): ?string
|
||||
{
|
||||
$lower = mb_strtolower($text);
|
||||
|
||||
foreach (self::REPETITION_PHRASES as $phrase) {
|
||||
// Count non-overlapping occurrences.
|
||||
$count = substr_count($lower, $phrase);
|
||||
if ($count >= 2) {
|
||||
return "Biography repeats the phrase \"{$phrase}\" too many times.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For sparse-profile biographies, reject text that sounds too achievement-heavy.
|
||||
* These signals typically appear only in rich profiles and would be hallucinated
|
||||
* or misleading when the creator has very little public history.
|
||||
*/
|
||||
private function soundsTooRichForSparseProfile(string $text): bool
|
||||
{
|
||||
$lower = mb_strtolower($text);
|
||||
|
||||
$richIndicators = [
|
||||
'featured',
|
||||
'best-performing',
|
||||
'standout',
|
||||
'milestone',
|
||||
'comeback',
|
||||
'evolution',
|
||||
'remaster',
|
||||
'era',
|
||||
'streak',
|
||||
'downloads',
|
||||
'most productive',
|
||||
];
|
||||
|
||||
$hitCount = 0;
|
||||
foreach ($richIndicators as $indicator) {
|
||||
if (str_contains($lower, $indicator)) {
|
||||
$hitCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If a sparse profile biography references 3+ rich signals, it likely hallucinated them.
|
||||
return $hitCount >= 3;
|
||||
}
|
||||
}
|
||||
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin client for the Skinbase Vision LLM gateway.
|
||||
*
|
||||
* Uses the existing Vision gateway infrastructure (VISION_GATEWAY_URL / VISION_GATEWAY_API_KEY).
|
||||
* Prefers /ai/chat; falls back to /v1/chat/completions if configured.
|
||||
*
|
||||
* Error codes handled:
|
||||
* 401 – invalid API key
|
||||
* 413 – oversized request
|
||||
* 422 – invalid payload
|
||||
* 503 – LLM unavailable
|
||||
* 504 – timeout / upstream
|
||||
*/
|
||||
final class VisionLlmClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->togetherApiKey() !== '' && $this->togetherModel() !== '',
|
||||
'gemini' => $this->geminiBaseUrl() !== '' && $this->geminiApiKey() !== '' && $this->geminiModel() !== '',
|
||||
'home' => $this->homeBaseUrl() !== '' && $this->homeModel() !== '',
|
||||
default => $this->baseUrl() !== '' && $this->apiKey() !== '',
|
||||
};
|
||||
}
|
||||
|
||||
public function configuredModel(): string
|
||||
{
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->togetherModel(),
|
||||
'gemini' => $this->geminiModel(),
|
||||
'home' => $this->homeModel(),
|
||||
default => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion payload to the Vision gateway.
|
||||
*
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return string The generated text content.
|
||||
*
|
||||
* @throws VisionLlmException On structured gateway failure.
|
||||
*/
|
||||
public function chat(array $payload): string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new VisionLlmException(
|
||||
match ($this->provider()) { 'together' => 'Together.ai is not configured. Set TOGETHER_API_KEY and optionally AI_BIOGRAPHY_TOGETHER_MODEL.', 'gemini' => 'Gemini API is not configured. Set GEMINI_API_KEY and optionally AI_BIOGRAPHY_GEMINI_MODEL.',
|
||||
'home' => 'Home LM Studio is not configured. Set AI_BIOGRAPHY_HOME_BASE_URL and AI_BIOGRAPHY_HOME_MODEL.',
|
||||
default => 'Vision LLM gateway is not configured. Set VISION_GATEWAY_URL and VISION_GATEWAY_API_KEY.',
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return match ($this->provider()) {
|
||||
'together' => $this->chatWithTogether($payload),
|
||||
'gemini' => $this->chatWithGemini($payload),
|
||||
'home' => $this->chatWithHome($payload),
|
||||
default => $this->chatWithVisionGateway($payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithTogether(array $payload): string
|
||||
{
|
||||
$response = $this->sendTogetherRequest($this->togetherEndpoint(), $this->toTogetherPayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'together');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithVisionGateway(array $payload): string
|
||||
{
|
||||
$endpoint = $this->primaryEndpoint();
|
||||
$response = $this->sendRequest($endpoint, $payload);
|
||||
|
||||
// If primary endpoint returned a 404, fall back to the OpenAI-compatible path.
|
||||
if ($response->status() === 404) {
|
||||
$fallbackEndpoint = $this->fallbackEndpoint();
|
||||
if ($fallbackEndpoint !== $endpoint) {
|
||||
Log::debug('VisionLlmClient: primary endpoint returned 404, trying fallback', [
|
||||
'primary' => $endpoint,
|
||||
'fallback' => $fallbackEndpoint,
|
||||
]);
|
||||
$response = $this->sendRequest($fallbackEndpoint, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSuccessful($response, 'vision_gateway');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithGemini(array $payload): string
|
||||
{
|
||||
$response = $this->sendGeminiRequest($this->geminiEndpoint(), $this->toGeminiPayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'gemini');
|
||||
|
||||
return $this->extractGeminiContent($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
*/
|
||||
private function chatWithHome(array $payload): string
|
||||
{
|
||||
$response = $this->sendHomeRequest($this->homeEndpoint(), $this->toHomePayload($payload));
|
||||
|
||||
$this->assertSuccessful($response, 'home');
|
||||
|
||||
return $this->extractContent($response);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function sendRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Vision LLM gateway connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendTogetherRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildTogetherRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Together.ai connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendGeminiRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildGeminiRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendHomeRequest(string $url, array $payload): Response
|
||||
{
|
||||
try {
|
||||
return $this->buildHomeRequest()->post($url, $payload);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
throw new VisionLlmException(
|
||||
'Home LM Studio connection failed: ' . $e->getMessage(),
|
||||
504,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSuccessful(Response $response, string $provider): void
|
||||
{
|
||||
if ($response->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $response->status();
|
||||
$body = mb_substr(trim($response->body()), 0, 300);
|
||||
|
||||
$label = match ($provider) {
|
||||
'together' => 'Together.ai',
|
||||
'gemini' => 'Gemini API',
|
||||
'home' => 'Home LM Studio',
|
||||
default => 'Vision LLM gateway',
|
||||
};
|
||||
|
||||
$message = match ($status) {
|
||||
401, 403 => "{$label}: invalid or unauthorized API key ({$status}).",
|
||||
413 => "{$label}: request payload too large (413).",
|
||||
422 => "{$label}: invalid payload (422). {$body}",
|
||||
429 => "{$label}: rate limit or quota exceeded (429).",
|
||||
503 => "{$label}: LLM service unavailable (503).",
|
||||
504 => "{$label}: upstream timeout (504).",
|
||||
default => "{$label}: unexpected HTTP {$status}. {$body}",
|
||||
};
|
||||
|
||||
Log::warning('VisionLlmClient: gateway error', [
|
||||
'provider' => $provider,
|
||||
'status' => $status,
|
||||
'excerpt' => $body,
|
||||
]);
|
||||
|
||||
throw new VisionLlmException($message, $status);
|
||||
}
|
||||
|
||||
private function extractContent(Response $response): string
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
// Standard OpenAI-compatible shape: choices[0].message.content
|
||||
if (isset($json['choices'][0]['message']['content'])) {
|
||||
return trim((string) $json['choices'][0]['message']['content']);
|
||||
}
|
||||
|
||||
// Simple shape: { "content": "..." }
|
||||
if (isset($json['content']) && is_string($json['content'])) {
|
||||
return trim($json['content']);
|
||||
}
|
||||
|
||||
// Shape: { "text": "..." }
|
||||
if (isset($json['text']) && is_string($json['text'])) {
|
||||
return trim($json['text']);
|
||||
}
|
||||
|
||||
throw new VisionLlmException(
|
||||
'Vision LLM gateway: unrecognized response shape. Could not extract generated text.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private function extractGeminiContent(Response $response): string
|
||||
{
|
||||
$json = $response->json();
|
||||
$parts = $json['candidates'][0]['content']['parts'] ?? null;
|
||||
|
||||
if (! is_array($parts) || $parts === []) {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API: unrecognized response shape. Could not extract generated text.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
$text = collect($parts)
|
||||
->map(fn ($part) => is_array($part) ? trim((string) ($part['text'] ?? '')) : '')
|
||||
->filter(fn (string $value): bool => $value !== '')
|
||||
->implode("\n");
|
||||
|
||||
if ($text === '') {
|
||||
throw new VisionLlmException(
|
||||
'Gemini API: response did not contain text content.',
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function buildRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withHeaders(['X-API-Key' => $this->apiKey()])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
|
||||
}
|
||||
|
||||
private function buildTogetherRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withToken($this->togetherApiKey())
|
||||
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
|
||||
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90))));
|
||||
}
|
||||
|
||||
private function buildGeminiRequest(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->withHeaders(['X-goog-api-key' => $this->geminiApiKey()])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
|
||||
}
|
||||
|
||||
private function buildHomeRequest(): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()
|
||||
->contentType('application/json')
|
||||
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
|
||||
|
||||
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
if ($this->homeApiKey() !== '') {
|
||||
$request = $request->withToken($this->homeApiKey());
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('vision.gateway.base_url', ''), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) config('vision.gateway.api_key', ''));
|
||||
}
|
||||
|
||||
private function primaryEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.llm_endpoint', '/ai/chat'), '/');
|
||||
|
||||
return $this->baseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
private function fallbackEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.llm_fallback_endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $this->baseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
private function provider(): string
|
||||
{
|
||||
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
|
||||
|
||||
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if ($this->togetherApiKey() !== '' && $this->togetherModel() !== '') {
|
||||
return 'together';
|
||||
}
|
||||
|
||||
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
|
||||
|
||||
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
|
||||
}
|
||||
|
||||
private function geminiBaseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/');
|
||||
}
|
||||
|
||||
private function geminiApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.gemini.api_key', ''));
|
||||
}
|
||||
|
||||
private function geminiModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest'));
|
||||
}
|
||||
|
||||
private function geminiEndpoint(): string
|
||||
{
|
||||
return $this->geminiBaseUrl() . '/v1beta/models/' . rawurlencode($this->geminiModel()) . ':generateContent';
|
||||
}
|
||||
|
||||
private function togetherApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.together.api_key', ''));
|
||||
}
|
||||
|
||||
private function togetherModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it'));
|
||||
}
|
||||
|
||||
private function togetherEndpoint(): string
|
||||
{
|
||||
$base = rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/');
|
||||
$path = ltrim((string) config('ai_biography.together.endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $base . '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toTogetherPayload(array $payload): array
|
||||
{
|
||||
return [
|
||||
'model' => $this->togetherModel(),
|
||||
'messages' => array_values((array) ($payload['messages'] ?? [])),
|
||||
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'stream' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function homeBaseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/');
|
||||
}
|
||||
|
||||
private function homeApiKey(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.home.api_key', ''));
|
||||
}
|
||||
|
||||
private function homeModel(): string
|
||||
{
|
||||
return trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b'));
|
||||
}
|
||||
|
||||
private function homeEndpoint(): string
|
||||
{
|
||||
$path = ltrim((string) config('ai_biography.home.endpoint', '/v1/chat/completions'), '/');
|
||||
|
||||
return $this->homeBaseUrl() . '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toGeminiPayload(array $payload): array
|
||||
{
|
||||
$systemParts = [];
|
||||
$contents = [];
|
||||
|
||||
foreach ((array) ($payload['messages'] ?? []) as $message) {
|
||||
$role = strtolower((string) ($message['role'] ?? 'user'));
|
||||
$content = trim((string) ($message['content'] ?? ''));
|
||||
|
||||
if ($content === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($role === 'system') {
|
||||
$systemParts[] = ['text' => $content];
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents[] = [
|
||||
'role' => $role === 'assistant' ? 'model' : 'user',
|
||||
'parts' => [
|
||||
['text' => $content],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($contents === [] && $systemParts !== []) {
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => $systemParts,
|
||||
];
|
||||
$systemParts = [];
|
||||
}
|
||||
|
||||
$geminiPayload = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'maxOutputTokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
],
|
||||
];
|
||||
|
||||
if ($systemParts !== []) {
|
||||
$geminiPayload['systemInstruction'] = [
|
||||
'parts' => $systemParts,
|
||||
];
|
||||
}
|
||||
|
||||
return $geminiPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toHomePayload(array $payload): array
|
||||
{
|
||||
return [
|
||||
'model' => $this->homeModel(),
|
||||
'messages' => array_values((array) ($payload['messages'] ?? [])),
|
||||
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
|
||||
'temperature' => (float) ($payload['temperature'] ?? 0.3),
|
||||
'stream' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Services/AiBiography/VisionLlmException.php
Normal file
14
app/Services/AiBiography/VisionLlmException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\AiBiography;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown when the Vision LLM gateway returns a structured failure.
|
||||
*/
|
||||
final class VisionLlmException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -81,11 +82,28 @@ final class ArtworkSearchService
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
$options = $this->viewerAwareOptions($options);
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
$results = Artwork::search($q ?: '')
|
||||
->options($this->viewerAwareOptions($options))
|
||||
->paginate($perPage);
|
||||
|
||||
if (! $this->shouldFallbackToViewerVisibilityFiltering($results)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$page = max(1, (int) request()->get('page', 1));
|
||||
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
|
||||
$fallbackResults = Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->paginate($candidateCount, 'page', 1);
|
||||
|
||||
$visibleItems = $this->filterSearchCollectionByCatalogVisibility($fallbackResults->getCollection());
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
$slice = $visibleItems->slice($offset, $perPage)->values();
|
||||
$visibleTotal = (int) ($fallbackResults->total() <= $candidateCount
|
||||
? $visibleItems->count()
|
||||
: $fallbackResults->total());
|
||||
|
||||
return $this->makeModelPaginator($slice, $visibleTotal, $perPage, $page);
|
||||
}
|
||||
|
||||
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
|
||||
@@ -96,21 +114,18 @@ final class ArtworkSearchService
|
||||
->options($this->viewerAwareOptions($options))
|
||||
->paginate($candidateCount, 'page', 1);
|
||||
|
||||
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
|
||||
if ($this->shouldFallbackToViewerVisibilityFiltering($results)) {
|
||||
$results = Artwork::search('')
|
||||
->options($options)
|
||||
->paginate($candidateCount, 'page', 1);
|
||||
}
|
||||
|
||||
$visibleItems = $this->filterSearchCollectionByCatalogVisibility($results->getCollection());
|
||||
$ordered = $this->rerankSearchCollectionByThumbnailHealth($visibleItems, $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',
|
||||
]
|
||||
);
|
||||
return $this->makeModelPaginator($slice, (int) $results->total(), $perPage, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,15 +180,14 @@ final class ArtworkSearchService
|
||||
*/
|
||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$cacheKey = "search.cat.{$cat}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'sort' => ['created_at:desc'],
|
||||
]))
|
||||
->paginate($perPage);
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'sort' => ['created_at:desc'],
|
||||
], $perPage, false, $page);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,15 +228,13 @@ final class ArtworkSearchService
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "category.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
]))
|
||||
->paginate($perPage);
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,15 +249,13 @@ final class ArtworkSearchService
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
]))
|
||||
->paginate($perPage);
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -431,6 +441,15 @@ final class ArtworkSearchService
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function shouldFallbackToViewerVisibilityFiltering(LengthAwarePaginator $results): bool
|
||||
{
|
||||
if ($results->total() > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->maturity->viewerPreferences(request()->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE;
|
||||
}
|
||||
|
||||
private function viewerCacheSegment(): string
|
||||
{
|
||||
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
|
||||
@@ -513,6 +532,37 @@ final class ArtworkSearchService
|
||||
->values();
|
||||
}
|
||||
|
||||
private function filterSearchCollectionByCatalogVisibility(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->values();
|
||||
}
|
||||
|
||||
$visibilityQuery = Artwork::query()
|
||||
->catalogVisible()
|
||||
->whereIn('id', $ids);
|
||||
|
||||
$visibleIds = $this->maturity
|
||||
->applyViewerFilter($visibilityQuery, request()->user())
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->flip();
|
||||
|
||||
return $items
|
||||
->filter(fn ($item) => $visibleIds->has((int) ($item->id ?? 0)))
|
||||
->values();
|
||||
}
|
||||
|
||||
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
|
||||
{
|
||||
return min(
|
||||
@@ -521,6 +571,23 @@ final class ArtworkSearchService
|
||||
);
|
||||
}
|
||||
|
||||
private function makeModelPaginator(Collection $items, int $total, int $perPage, int $page): LengthAwarePaginator
|
||||
{
|
||||
$paginator = new PaginationLengthAwarePaginator(
|
||||
[],
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->query(),
|
||||
'pageName' => 'page',
|
||||
]
|
||||
);
|
||||
|
||||
return $paginator->setCollection(new EloquentCollection($items->all()));
|
||||
}
|
||||
|
||||
private function emptyPaginator(int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||
|
||||
162
app/Services/Artworks/ArtworkPublicationService.php
Normal file
162
app/Services/Artworks/ArtworkPublicationService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artworks;
|
||||
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ArtworkPublicationService
|
||||
{
|
||||
public function publishNow(Artwork $artwork, ?Carbon $publishedAt = null): Artwork
|
||||
{
|
||||
$publishedAt ??= now()->utc();
|
||||
|
||||
$artwork->forceFill([
|
||||
'artwork_status' => 'published',
|
||||
'publish_at' => null,
|
||||
'artwork_timezone' => null,
|
||||
'published_at' => $publishedAt,
|
||||
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
|
||||
])->save();
|
||||
|
||||
$this->syncSearch($artwork);
|
||||
$this->recordActivity($artwork);
|
||||
|
||||
return $artwork;
|
||||
}
|
||||
|
||||
public function publishIfDue(Artwork $artwork, ?Carbon $now = null): Artwork
|
||||
{
|
||||
$now ??= now()->utc();
|
||||
|
||||
if (! $this->isDue($artwork, $now)) {
|
||||
return $artwork;
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$artwork, $now): void {
|
||||
$locked = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->find($artwork->id);
|
||||
|
||||
if (! $locked || ! $this->isDue($locked, $now)) {
|
||||
if ($locked) {
|
||||
$artwork = $locked;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork = $this->publishNow($locked, $now);
|
||||
});
|
||||
|
||||
return $artwork->fresh() ?? $artwork;
|
||||
}
|
||||
|
||||
public function publishDueScheduled(int $limit = 100, ?Carbon $now = null): array
|
||||
{
|
||||
$now ??= now()->utc();
|
||||
|
||||
$candidates = Artwork::query()
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||
|
||||
$published = collect();
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$result = null;
|
||||
|
||||
DB::transaction(function () use ($candidate, $now, &$result): void {
|
||||
$locked = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->first();
|
||||
|
||||
if (! $locked || ! $this->isDue($locked, $now)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->publishNow($locked, $now);
|
||||
});
|
||||
|
||||
if ($result instanceof Artwork) {
|
||||
$published->push($result);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $candidates,
|
||||
'published' => $published,
|
||||
];
|
||||
}
|
||||
|
||||
public function publishDueScheduledForUser(int $userId, int $limit = 100, ?Carbon $now = null): void
|
||||
{
|
||||
$now ??= now()->utc();
|
||||
|
||||
$candidateIds = Artwork::query()
|
||||
->where('user_id', $userId)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($candidateIds as $candidateId) {
|
||||
$artwork = Artwork::query()->find((int) $candidateId);
|
||||
if ($artwork) {
|
||||
$this->publishIfDue($artwork, $now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isDue(Artwork $artwork, Carbon $now): bool
|
||||
{
|
||||
return $artwork->artwork_status === 'scheduled'
|
||||
&& $artwork->is_approved
|
||||
&& $artwork->publish_at !== null
|
||||
&& $artwork->publish_at->lte($now);
|
||||
}
|
||||
|
||||
private function syncSearch(Artwork $artwork): void
|
||||
{
|
||||
if (! method_exists($artwork, 'searchable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
private function recordActivity(Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $artwork->user_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Models\Collection as CollectionModel;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
@@ -55,6 +56,7 @@ final class HomepageService
|
||||
private readonly CollectionSurfaceService $collectionSurfaces,
|
||||
private readonly GroupDiscoveryService $groupDiscovery,
|
||||
private readonly LeaderboardService $leaderboards,
|
||||
private readonly WorldService $worlds,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -126,6 +128,7 @@ final class HomepageService
|
||||
'collections_trending' => $this->getTrendingCollections(),
|
||||
'collections_editorial' => $this->getEditorialCollections(),
|
||||
'collections_community' => $this->getCommunityCollections(),
|
||||
'world_spotlight' => $this->worlds->homepageSpotlight(),
|
||||
'groups' => $this->getHomepageGroups(),
|
||||
'tags' => $this->getPopularTags(),
|
||||
'creators' => $this->getCreatorSpotlight(),
|
||||
@@ -180,6 +183,7 @@ final class HomepageService
|
||||
'collections_trending' => $this->getTrendingCollections(),
|
||||
'collections_editorial' => $this->getEditorialCollections(),
|
||||
'collections_community' => $this->getCommunityCollections(),
|
||||
'world_spotlight' => $this->worlds->homepageSpotlight($user),
|
||||
'groups' => $this->getHomepageGroups($user),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||
|
||||
@@ -12,10 +12,13 @@ use App\Models\Story;
|
||||
use App\Models\StoryLike;
|
||||
use App\Models\StoryView;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class LeaderboardService
|
||||
{
|
||||
@@ -68,6 +71,16 @@ class LeaderboardService
|
||||
return $this->persistRows(Leaderboard::TYPE_GROUP, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function calculateWorldLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeWorldRows()
|
||||
: $this->windowedWorldRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_WORLD, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function refreshAll(): array
|
||||
{
|
||||
$results = [];
|
||||
@@ -77,6 +90,7 @@ class LeaderboardService
|
||||
Leaderboard::TYPE_ARTWORK,
|
||||
Leaderboard::TYPE_GROUP,
|
||||
Leaderboard::TYPE_STORY,
|
||||
Leaderboard::TYPE_WORLD,
|
||||
] as $type) {
|
||||
foreach ($this->periods() as $period) {
|
||||
$results[$type][$period] = match ($type) {
|
||||
@@ -84,6 +98,7 @@ class LeaderboardService
|
||||
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
|
||||
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
|
||||
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
|
||||
Leaderboard::TYPE_WORLD => $this->calculateWorldLeaderboard($period),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -121,6 +136,7 @@ class LeaderboardService
|
||||
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_GROUP => $this->groupEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_WORLD => $this->worldEntities($items->pluck('entity_id')->all()),
|
||||
};
|
||||
|
||||
return [
|
||||
@@ -205,6 +221,7 @@ class LeaderboardService
|
||||
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
|
||||
'group', 'groups' => Leaderboard::TYPE_GROUP,
|
||||
'story', 'stories' => Leaderboard::TYPE_STORY,
|
||||
'world', 'worlds' => Leaderboard::TYPE_WORLD,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
}
|
||||
@@ -228,6 +245,7 @@ class LeaderboardService
|
||||
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
|
||||
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
|
||||
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
|
||||
Leaderboard::TYPE_WORLD => $this->calculateWorldLeaderboard($period),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -585,6 +603,128 @@ class LeaderboardService
|
||||
->values();
|
||||
}
|
||||
|
||||
private function allTimeWorldRows(): Collection
|
||||
{
|
||||
if (! $this->worldTablesExist()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return World::query()
|
||||
->from('worlds')
|
||||
->withCount([
|
||||
'worldRelations as relations_count',
|
||||
'worldRelations as featured_relations_count' => fn ($query) => $query->where('is_featured', true),
|
||||
'worldSubmissions as approved_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE),
|
||||
'worldSubmissions as featured_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true),
|
||||
])
|
||||
->publiclyVisible()
|
||||
->get()
|
||||
->map(fn (World $world): array => [
|
||||
'entity_id' => (int) $world->id,
|
||||
'score' => $this->scoreWorld(
|
||||
(int) ($world->relations_count ?? 0),
|
||||
(int) ($world->featured_relations_count ?? 0),
|
||||
(int) ($world->approved_submissions_count ?? 0),
|
||||
(int) ($world->featured_submissions_count ?? 0),
|
||||
$world->isCurrent(),
|
||||
(bool) $world->is_featured,
|
||||
false,
|
||||
),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedWorldRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
if (! $this->worldTablesExist()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$relations = DB::table('world_relations')
|
||||
->select('world_id', DB::raw('COUNT(*) as relations_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('world_id');
|
||||
|
||||
$featuredRelations = DB::table('world_relations')
|
||||
->select('world_id', DB::raw('COUNT(*) as featured_relations_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->where('is_featured', true)
|
||||
->groupBy('world_id');
|
||||
|
||||
$approvedSubmissions = DB::table('world_submissions')
|
||||
->select('world_id', DB::raw('COUNT(*) as approved_submissions_count'))
|
||||
->where('status', WorldSubmission::STATUS_LIVE)
|
||||
->where(function ($query) use ($start): void {
|
||||
$query->where('reviewed_at', '>=', $start)
|
||||
->orWhere(function ($fallback) use ($start): void {
|
||||
$fallback->whereNull('reviewed_at')
|
||||
->where('created_at', '>=', $start);
|
||||
});
|
||||
})
|
||||
->groupBy('world_id');
|
||||
|
||||
$featuredSubmissions = DB::table('world_submissions')
|
||||
->select('world_id', DB::raw('COUNT(*) as featured_submissions_count'))
|
||||
->where('status', WorldSubmission::STATUS_LIVE)
|
||||
->where('is_featured', true)
|
||||
->where(function ($query) use ($start): void {
|
||||
$query->where('reviewed_at', '>=', $start)
|
||||
->orWhere(function ($fallback) use ($start): void {
|
||||
$fallback->whereNull('reviewed_at')
|
||||
->where('created_at', '>=', $start);
|
||||
});
|
||||
})
|
||||
->groupBy('world_id');
|
||||
|
||||
return World::query()
|
||||
->from('worlds')
|
||||
->leftJoinSub($relations, 'relations', 'relations.world_id', '=', 'worlds.id')
|
||||
->leftJoinSub($featuredRelations, 'featured_relations', 'featured_relations.world_id', '=', 'worlds.id')
|
||||
->leftJoinSub($approvedSubmissions, 'approved_submissions', 'approved_submissions.world_id', '=', 'worlds.id')
|
||||
->leftJoinSub($featuredSubmissions, 'featured_submissions', 'featured_submissions.world_id', '=', 'worlds.id')
|
||||
->publiclyVisible()
|
||||
->select([
|
||||
'worlds.id',
|
||||
'worlds.status',
|
||||
'worlds.starts_at',
|
||||
'worlds.ends_at',
|
||||
'worlds.published_at',
|
||||
'worlds.is_featured',
|
||||
DB::raw('COALESCE(relations.relations_count, 0) as relations_count'),
|
||||
DB::raw('COALESCE(featured_relations.featured_relations_count, 0) as featured_relations_count'),
|
||||
DB::raw('COALESCE(approved_submissions.approved_submissions_count, 0) as approved_submissions_count'),
|
||||
DB::raw('COALESCE(featured_submissions.featured_submissions_count, 0) as featured_submissions_count'),
|
||||
DB::raw("CASE WHEN worlds.published_at >= '" . $start->toDateTimeString() . "' OR worlds.starts_at >= '" . $start->toDateTimeString() . "' THEN 1 ELSE 0 END as recent_launch_bonus"),
|
||||
])
|
||||
->get()
|
||||
->map(function ($row): array {
|
||||
$world = new World();
|
||||
$world->forceFill([
|
||||
'status' => (string) $row->status,
|
||||
'starts_at' => $row->starts_at,
|
||||
'ends_at' => $row->ends_at,
|
||||
'published_at' => $row->published_at,
|
||||
'is_featured' => (bool) $row->is_featured,
|
||||
]);
|
||||
|
||||
return [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => $this->scoreWorld(
|
||||
(int) $row->relations_count,
|
||||
(int) $row->featured_relations_count,
|
||||
(int) $row->approved_submissions_count,
|
||||
(int) $row->featured_submissions_count,
|
||||
$world->isCurrent(),
|
||||
(bool) $row->is_featured,
|
||||
(bool) $row->recent_launch_bonus,
|
||||
),
|
||||
];
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedGroupRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$follows = DB::table('group_follows')
|
||||
@@ -819,4 +959,95 @@ class LeaderboardService
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function worldEntities(array $ids): array
|
||||
{
|
||||
if (! $this->worldTablesExist()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return World::query()
|
||||
->withCount([
|
||||
'worldRelations as relations_count',
|
||||
'worldSubmissions as approved_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE),
|
||||
'worldSubmissions as featured_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true),
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->publiclyVisible()
|
||||
->get()
|
||||
->mapWithKeys(function (World $world): array {
|
||||
return [
|
||||
(int) $world->id => [
|
||||
'id' => (int) $world->id,
|
||||
'type' => Leaderboard::TYPE_WORLD,
|
||||
'name' => (string) $world->title,
|
||||
'summary' => (string) ($world->summary ?: $world->tagline ?: ''),
|
||||
'url' => $world->publicUrl(),
|
||||
'image' => $world->coverUrl(),
|
||||
'timeframe_label' => $this->worldTimeframeLabel($world),
|
||||
'badge_label' => (string) ($world->badge_label ?? ''),
|
||||
'phase' => $world->isCurrent() ? 'active' : ((string) $world->status === World::STATUS_ARCHIVED ? 'archive' : 'published'),
|
||||
'icon_name' => (string) ($world->icon_name ?: 'fa-solid fa-globe'),
|
||||
'theme_label' => $this->worldThemeLabel($world),
|
||||
'relations_count' => (int) ($world->relations_count ?? 0),
|
||||
'approved_submissions_count' => (int) ($world->approved_submissions_count ?? 0),
|
||||
'featured_submissions_count' => (int) ($world->featured_submissions_count ?? 0),
|
||||
'is_featured' => (bool) $world->is_featured,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function scoreWorld(
|
||||
int $relationsCount,
|
||||
int $featuredRelationsCount,
|
||||
int $approvedSubmissionsCount,
|
||||
int $featuredSubmissionsCount,
|
||||
bool $isCurrent,
|
||||
bool $isFeatured,
|
||||
bool $isRecentLaunch,
|
||||
): int {
|
||||
return ($relationsCount * 22)
|
||||
+ ($featuredRelationsCount * 10)
|
||||
+ ($approvedSubmissionsCount * 16)
|
||||
+ ($featuredSubmissionsCount * 26)
|
||||
+ ($isCurrent ? 48 : 0)
|
||||
+ ($isFeatured ? 70 : 0)
|
||||
+ ($isRecentLaunch ? 24 : 0);
|
||||
}
|
||||
|
||||
private function worldTablesExist(): bool
|
||||
{
|
||||
return Schema::hasTable('worlds')
|
||||
&& Schema::hasTable('world_relations')
|
||||
&& Schema::hasTable('world_submissions');
|
||||
}
|
||||
|
||||
private function worldThemeLabel(World $world): string
|
||||
{
|
||||
return match ((string) $world->type) {
|
||||
World::TYPE_EVENT => 'Event',
|
||||
World::TYPE_CAMPAIGN => 'Campaign',
|
||||
World::TYPE_TRIBUTE => 'Tribute',
|
||||
default => 'Seasonal',
|
||||
};
|
||||
}
|
||||
|
||||
private function worldTimeframeLabel(World $world): ?string
|
||||
{
|
||||
if ($world->starts_at && $world->ends_at) {
|
||||
return $world->starts_at->format('M j, Y') . ' - ' . $world->ends_at->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($world->starts_at) {
|
||||
return 'Starts ' . $world->starts_at->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($world->ends_at) {
|
||||
return 'Ends ' . $world->ends_at->format('M j, Y');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,13 @@ final class ArtworkMaturityService
|
||||
*/
|
||||
public function viewerPreferences(?User $viewer): array
|
||||
{
|
||||
$guestMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.guest_mode', self::VIEW_HIDE));
|
||||
$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,
|
||||
'visibility' => $guestMode,
|
||||
'warn_on_detail' => $defaultWarnOnDetail,
|
||||
'is_guest' => true,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\ContentType;
|
||||
use App\Services\TagNormalizer;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Vision\AiArtworkVectorSearchService;
|
||||
use App\Services\Vision\ArtworkLlmTagSuggestionService;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -26,11 +27,12 @@ final class StudioAiAssistService
|
||||
private readonly AiArtworkVectorSearchService $similarity,
|
||||
private readonly TagService $tagService,
|
||||
private readonly TagNormalizer $tagNormalizer,
|
||||
private readonly ArtworkLlmTagSuggestionService $llmTagSuggestions,
|
||||
private readonly StudioAiAssistEventService $eventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$mode = $assist->mode ?: $this->builder->detectMode($artwork->loadMissing(['tags', 'categories.contentType']), []);
|
||||
@@ -42,26 +44,26 @@ final class StudioAiAssistService
|
||||
])->save();
|
||||
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_QUEUED])->saveQuietly();
|
||||
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent];
|
||||
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent, 'provider' => $provider];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force, $intent, $provider)->afterCommit();
|
||||
|
||||
return $assist->fresh();
|
||||
}
|
||||
|
||||
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent];
|
||||
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent, 'provider' => $provider];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
return $this->analyze($artwork, $force, $intent);
|
||||
return $this->analyze($artwork, $force, $intent, $provider);
|
||||
}
|
||||
|
||||
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
|
||||
{
|
||||
$artwork->loadMissing(['tags', 'categories.contentType', 'user']);
|
||||
|
||||
@@ -99,7 +101,9 @@ final class StudioAiAssistService
|
||||
|
||||
$titleSuggestions = $this->builder->buildTitleSuggestions($artwork, $analysis, $mode);
|
||||
$descriptionSuggestions = $this->builder->buildDescriptionSuggestions($artwork, $analysis, $mode);
|
||||
$tagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
|
||||
$fallbackTagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
|
||||
$llmTagGeneration = $this->llmTagSuggestions->suggestForArtwork($artwork, 10, 15, $provider);
|
||||
$tagSuggestions = $this->mergeTagSuggestions($llmTagGeneration, $fallbackTagSuggestions);
|
||||
$similarCandidates = $this->buildSimilarCandidates($artwork);
|
||||
|
||||
$assist->forceFill([
|
||||
@@ -115,6 +119,7 @@ final class StudioAiAssistService
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'hash' => $hash,
|
||||
'intent' => $intent,
|
||||
'provider' => $provider,
|
||||
'force' => $force,
|
||||
'current_title' => (string) ($artwork->title ?? ''),
|
||||
'current_description' => (string) ($artwork->description ?? ''),
|
||||
@@ -122,6 +127,7 @@ final class StudioAiAssistService
|
||||
],
|
||||
'vision_debug' => $visionDebug,
|
||||
'analysis' => $analysis,
|
||||
'tag_generation' => $llmTagGeneration,
|
||||
'generated_at' => \now()->toIso8601String(),
|
||||
'force' => $force,
|
||||
],
|
||||
@@ -134,6 +140,7 @@ final class StudioAiAssistService
|
||||
'force' => $force,
|
||||
'mode' => $mode,
|
||||
'intent' => $intent,
|
||||
'provider' => $llmTagGeneration['provider'] ?? $provider,
|
||||
'title_suggestion_count' => count($titleSuggestions),
|
||||
'description_suggestion_count' => count($descriptionSuggestions),
|
||||
'tag_suggestion_count' => count($tagSuggestions),
|
||||
@@ -326,11 +333,54 @@ final class StudioAiAssistService
|
||||
'request' => $assist->raw_response_json['request'] ?? null,
|
||||
'vision_debug' => $assist->raw_response_json['vision_debug'] ?? null,
|
||||
'analysis' => $assist->raw_response_json['analysis'] ?? null,
|
||||
'tag_generation' => $assist->raw_response_json['tag_generation'] ?? null,
|
||||
'generated_at' => $assist->raw_response_json['generated_at'] ?? null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{tags: list<string>, model: ?string, endpoint: ?string, image_url: ?string, variant: string, raw_content?: string, reason?: string, error?: string} $llmResult
|
||||
* @param array<int, array{tag: string, confidence: float|null}> $fallback
|
||||
* @return array<int, array{tag: string, confidence: float|null, source: string}>
|
||||
*/
|
||||
private function mergeTagSuggestions(array $llmResult, array $fallback, int $min = 10, int $max = 15): array
|
||||
{
|
||||
$rows = collect();
|
||||
|
||||
foreach (array_values($llmResult['tags'] ?? []) as $index => $tag) {
|
||||
$rows->push([
|
||||
'tag' => $tag,
|
||||
'confidence' => round(max(0.55, 0.94 - ($index * 0.03)), 2),
|
||||
'source' => 'llm',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($fallback as $row) {
|
||||
$rows->push([
|
||||
'tag' => (string) ($row['tag'] ?? ''),
|
||||
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null,
|
||||
'source' => 'vision',
|
||||
]);
|
||||
}
|
||||
|
||||
$merged = $rows
|
||||
->filter(fn (array $row): bool => trim((string) ($row['tag'] ?? '')) !== '')
|
||||
->unique('tag')
|
||||
->take($max)
|
||||
->values();
|
||||
|
||||
if ($merged->count() < $min && count($fallback) > $merged->count()) {
|
||||
$merged = $rows
|
||||
->filter(fn (array $row): bool => trim((string) ($row['tag'] ?? '')) !== '')
|
||||
->unique('tag')
|
||||
->take(max($min, min($max, $rows->count())))
|
||||
->values();
|
||||
}
|
||||
|
||||
return $merged->all();
|
||||
}
|
||||
|
||||
private function assistRecord(Artwork $artwork): ArtworkAiAssist
|
||||
{
|
||||
return ArtworkAiAssist::query()->firstOrCreate(
|
||||
|
||||
@@ -77,10 +77,7 @@ final class TrendingService
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->catalogVisible()
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, $favCol, $commentCol, $shareCol, $wFavorite, $wComment, $wShare, $wView, &$updated): void {
|
||||
@@ -137,9 +134,7 @@ final class TrendingService
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->catalogVisible()
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->chunkById($chunkSize, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
|
||||
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal file
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class ArtworkLlmTagSuggestionService
|
||||
{
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are a precise visual-art tagging engine for Skinbase.
|
||||
|
||||
Analyze the provided artwork thumbnail and generate search-friendly tags that help users discover the work in a gallery.
|
||||
|
||||
Rules:
|
||||
- Use only what is clearly visible or strongly implied by the image.
|
||||
- Prefer concrete visual concepts over vague opinions.
|
||||
- Do not include artist names, brands, platform names, or watermarks.
|
||||
- Do not write sentences, explanations, numbering, or markdown.
|
||||
- Return concise gallery-style tags only.
|
||||
- Favor subject, setting, style, mood, palette, medium, and composition when visible.
|
||||
- Avoid filler tags like "art", "image", "beautiful", "cool", or "design".
|
||||
- Avoid duplicates and near-duplicates.
|
||||
PROMPT;
|
||||
|
||||
private const USER_PROMPT = <<<'PROMPT'
|
||||
Analyze this artwork thumbnail and return ONLY a valid JSON array of lowercase strings.
|
||||
|
||||
Requirements:
|
||||
- Return between 10 and 15 tags.
|
||||
- Each tag must be 1 to 3 words.
|
||||
- Use only letters, numbers, spaces, and hyphens.
|
||||
- No markdown, no explanations, no extra text.
|
||||
- Order tags from most useful to least useful.
|
||||
|
||||
Focus on:
|
||||
1. main subject or scene
|
||||
2. style or genre
|
||||
3. mood or atmosphere
|
||||
4. dominant colours
|
||||
5. medium or technique
|
||||
6. notable composition or visual elements
|
||||
|
||||
Good example:
|
||||
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","detailed armor","cinematic mood","character art","moody background"]
|
||||
|
||||
Bad example:
|
||||
["art","beautiful image","masterpiece","cool fantasy woman"]
|
||||
PROMPT;
|
||||
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
private readonly ArtworkVisionImageUrl $imageUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{provider: string, tags: list<string>, model: ?string, endpoint: ?string, image_url: ?string, variant: string, raw_content?: string, reason?: string, error?: string}
|
||||
*/
|
||||
public function suggestForArtwork(Artwork $artwork, int $minTags = 10, int $maxTags = 15, ?string $providerOverride = null): array
|
||||
{
|
||||
$provider = $this->resolveProvider($providerOverride);
|
||||
$variant = 'md';
|
||||
$imageUrl = $this->imageUrl->fromHash((string) ($artwork->hash ?? ''), (string) ($artwork->thumb_ext ?: 'webp'), $variant);
|
||||
if ($imageUrl === null) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'tags' => [],
|
||||
'model' => null,
|
||||
'endpoint' => null,
|
||||
'image_url' => null,
|
||||
'variant' => $variant,
|
||||
'reason' => 'image_url_unavailable',
|
||||
];
|
||||
}
|
||||
|
||||
$configuration = $this->providerConfiguration($provider);
|
||||
$baseUrl = rtrim((string) ($configuration['base_url'] ?? ''), '/');
|
||||
$endpointPath = (string) ($configuration['endpoint'] ?? '/v1/chat/completions');
|
||||
$model = trim((string) ($configuration['model'] ?? ''));
|
||||
if ($baseUrl === '' || $model === '') {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'tags' => [],
|
||||
'model' => $model !== '' ? $model : null,
|
||||
'endpoint' => $baseUrl !== '' ? $baseUrl . '/' . ltrim($endpointPath, '/') : null,
|
||||
'image_url' => $imageUrl,
|
||||
'variant' => $variant,
|
||||
'reason' => $provider . '_not_configured',
|
||||
];
|
||||
}
|
||||
|
||||
$endpoint = $baseUrl . '/' . ltrim($endpointPath, '/');
|
||||
$safeMin = min(15, max(1, $minTags));
|
||||
$safeMax = min(15, max($safeMin, $maxTags));
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
|
||||
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => self::SYSTEM_PROMPT,
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
['type' => 'image_url', 'image_url' => ['url' => $imageUrl]],
|
||||
['type' => 'text', 'text' => str_replace(['10 and 15', '10 to 15'], ["{$safeMin} and {$safeMax}", "{$safeMin} to {$safeMax}"], self::USER_PROMPT)],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->buildRequest($provider, $configuration)
|
||||
->post($endpoint, $payload);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'tags' => [],
|
||||
'model' => $model,
|
||||
'endpoint' => $endpoint,
|
||||
'image_url' => $imageUrl,
|
||||
'variant' => $variant,
|
||||
'reason' => 'http_' . $response->status(),
|
||||
'error' => substr($response->body(), 0, 500),
|
||||
];
|
||||
}
|
||||
|
||||
$body = $response->json();
|
||||
$content = is_array($body)
|
||||
? (string) (($body['choices'][0]['message']['content'] ?? ''))
|
||||
: '';
|
||||
$tags = $this->parseTags($content, $safeMax);
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'tags' => $tags,
|
||||
'model' => $model,
|
||||
'endpoint' => $endpoint,
|
||||
'image_url' => $imageUrl,
|
||||
'variant' => $variant,
|
||||
'raw_content' => $content,
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'tags' => [],
|
||||
'model' => $model,
|
||||
'endpoint' => $endpoint,
|
||||
'image_url' => $imageUrl,
|
||||
'variant' => $variant,
|
||||
'reason' => 'request_failed',
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string}
|
||||
*/
|
||||
private function providerConfiguration(string $provider): array
|
||||
{
|
||||
return match ($provider) {
|
||||
'together' => [
|
||||
'base_url' => (string) config('vision.together.base_url', 'https://api.together.xyz'),
|
||||
'endpoint' => (string) config('vision.together.endpoint', '/v1/chat/completions'),
|
||||
'model' => (string) config('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo'),
|
||||
'timeout' => (int) config('vision.together.timeout', 90),
|
||||
'connect_timeout' => (int) config('vision.together.connect_timeout', 5),
|
||||
'api_key' => (string) config('vision.together.api_key', ''),
|
||||
],
|
||||
default => [
|
||||
'base_url' => (string) config('vision.lm_studio.base_url', ''),
|
||||
'endpoint' => '/v1/chat/completions',
|
||||
'model' => (string) config('vision.lm_studio.model', ''),
|
||||
'timeout' => (int) config('vision.lm_studio.timeout', 60),
|
||||
'connect_timeout' => (int) config('vision.lm_studio.connect_timeout', 5),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string} $configuration
|
||||
*/
|
||||
private function buildRequest(string $provider, array $configuration): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()
|
||||
->asJson()
|
||||
->timeout(max(1, (int) ($configuration['timeout'] ?? 60)))
|
||||
->connectTimeout(max(1, (int) ($configuration['connect_timeout'] ?? 5)));
|
||||
|
||||
if ($provider === 'together') {
|
||||
$apiKey = trim((string) ($configuration['api_key'] ?? ''));
|
||||
if ($apiKey !== '') {
|
||||
$request = $request->withToken($apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function resolveProvider(?string $providerOverride = null): string
|
||||
{
|
||||
$candidate = trim(strtolower((string) ($providerOverride ?? config('vision.tag_suggestions.provider', 'lm_studio'))));
|
||||
|
||||
return match ($candidate) {
|
||||
'together', 'together_ai' => 'together',
|
||||
'lm-studio', 'local', 'home' => 'lm_studio',
|
||||
default => 'lm_studio',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseTags(string $content, int $maxTags): array
|
||||
{
|
||||
$trimmed = trim($content);
|
||||
$trimmed = preg_replace('/^```(?:json)?\s*/i', '', $trimmed) ?? $trimmed;
|
||||
$trimmed = preg_replace('/\s*```$/', '', $trimmed) ?? $trimmed;
|
||||
|
||||
if (! preg_match('/(\[.*?\])/s', $trimmed, $matches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
if (! is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
foreach ($decoded as $item) {
|
||||
if (! is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizer->normalize($item);
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tags[] = $normalized;
|
||||
}
|
||||
|
||||
return array_slice(array_values(array_unique($tags)), 0, $maxTags);
|
||||
}
|
||||
}
|
||||
1369
app/Services/Worlds/WorldService.php
Normal file
1369
app/Services/Worlds/WorldService.php
Normal file
File diff suppressed because it is too large
Load Diff
547
app/Services/Worlds/WorldSubmissionService.php
Normal file
547
app/Services/Worlds/WorldSubmissionService.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Worlds;
|
||||
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class WorldSubmissionService
|
||||
{
|
||||
public function __construct(private readonly ArtworkMaturityService $maturity)
|
||||
{
|
||||
}
|
||||
|
||||
public function eligibleWorldOptions(?User $viewer = null): array
|
||||
{
|
||||
return $this->eligibleWorldsQuery()
|
||||
->get()
|
||||
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function artworkSubmissionOptions(Artwork $artwork, User $viewer): array
|
||||
{
|
||||
$artwork->loadMissing(['worldSubmissions.world', 'worldSubmissions.reviewer']);
|
||||
|
||||
$existing = $artwork->worldSubmissions
|
||||
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
|
||||
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
|
||||
|
||||
$eligibleWorlds = $this->eligibleWorldsQuery()->get()->keyBy(fn (World $world): int => (int) $world->id);
|
||||
$worlds = $eligibleWorlds;
|
||||
|
||||
$missingWorldIds = $existing->keys()
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->reject(fn (int $id): bool => $eligibleWorlds->has($id))
|
||||
->values();
|
||||
|
||||
if ($missingWorldIds->isNotEmpty()) {
|
||||
World::query()
|
||||
->whereIn('id', $missingWorldIds->all())
|
||||
->get()
|
||||
->each(fn (World $world) => $worlds->put((int) $world->id, $world));
|
||||
}
|
||||
|
||||
return $worlds
|
||||
->sortBy([
|
||||
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
|
||||
fn (World $world): int => $world->starts_at?->getTimestamp() ?? PHP_INT_MAX,
|
||||
fn (World $world): string => Str::lower((string) $world->title),
|
||||
])
|
||||
->values()
|
||||
->map(function (World $world) use ($existing): array {
|
||||
$submission = $existing->get((int) $world->id);
|
||||
|
||||
return $this->mapCreatorWorldOption($world, $submission, $this->isEligibleWorld($world));
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function syncForArtwork(Artwork $artwork, User $actor, array $entries): void
|
||||
{
|
||||
$artwork->loadMissing('worldSubmissions');
|
||||
|
||||
$normalizedEntries = collect($entries)
|
||||
->map(function (array $entry): ?array {
|
||||
$worldId = (int) ($entry['world_id'] ?? 0);
|
||||
if ($worldId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'world_id' => $worldId,
|
||||
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->unique('world_id')
|
||||
->values();
|
||||
|
||||
$existing = $artwork->worldSubmissions->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
|
||||
$selectedWorldIds = $normalizedEntries->pluck('world_id')->map(fn ($id): int => (int) $id)->all();
|
||||
$allWorldIds = array_values(array_unique(array_merge($selectedWorldIds, $existing->keys()->map(fn ($id): int => (int) $id)->all())));
|
||||
|
||||
$worlds = World::query()
|
||||
->whereIn('id', $allWorldIds)
|
||||
->get()
|
||||
->keyBy(fn (World $world): int => (int) $world->id);
|
||||
|
||||
$errors = [];
|
||||
|
||||
foreach ($normalizedEntries as $index => $entry) {
|
||||
$world = $worlds->get((int) $entry['world_id']);
|
||||
$submission = $existing->get((int) $entry['world_id']);
|
||||
|
||||
if (! $world) {
|
||||
$errors["world_submissions.{$index}.world_id"] = 'Selected world no longer exists.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isEligibleWorld($world)) {
|
||||
$errors["world_submissions.{$index}.world_id"] = 'That world is not currently accepting community submissions.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($submission && $submission->isBlockingResubmission()) {
|
||||
$errors["world_submissions.{$index}.world_id"] = 'This artwork is blocked from that world until a moderator clears the block.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($submission && (string) $submission->status === WorldSubmission::STATUS_REMOVED && ! (bool) $world->allow_readd_after_removal) {
|
||||
$errors["world_submissions.{$index}.world_id"] = 'That world does not allow re-adding removed artworks right now.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($normalizedEntries, $artwork, $actor, $existing, $worlds, $selectedWorldIds): void {
|
||||
foreach ($normalizedEntries as $entry) {
|
||||
$worldId = (int) $entry['world_id'];
|
||||
$submission = $existing->get($worldId);
|
||||
$world = $worlds->get($worldId);
|
||||
|
||||
$note = ($world?->submission_note_enabled ?? true) ? ($entry['note'] !== '' ? $entry['note'] : null) : null;
|
||||
$startingStatus = $world?->submissionStartsAsLive()
|
||||
? WorldSubmission::STATUS_LIVE
|
||||
: WorldSubmission::STATUS_PENDING;
|
||||
$reviewedAt = $startingStatus === WorldSubmission::STATUS_LIVE ? now() : null;
|
||||
|
||||
if ($submission && $submission->isBlockingResubmission()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($submission) {
|
||||
$payload = [
|
||||
'mode_snapshot' => $world?->participation_mode,
|
||||
'note' => $note,
|
||||
];
|
||||
|
||||
if ((string) $submission->status === WorldSubmission::STATUS_REMOVED) {
|
||||
$payload = array_merge($payload, [
|
||||
'status' => $startingStatus,
|
||||
'is_featured' => false,
|
||||
'reviewer_note' => null,
|
||||
'moderation_reason' => null,
|
||||
'reviewed_by_user_id' => null,
|
||||
'reviewed_at' => $reviewedAt,
|
||||
'removed_at' => null,
|
||||
'blocked_at' => null,
|
||||
'featured_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$submission->forceFill($payload)->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
WorldSubmission::query()->create([
|
||||
'world_id' => $worldId,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'submitted_by_user_id' => (int) $actor->id,
|
||||
'status' => $startingStatus,
|
||||
'is_featured' => false,
|
||||
'mode_snapshot' => $world?->participation_mode,
|
||||
'note' => $note,
|
||||
'reviewed_at' => $reviewedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
|
||||
if (in_array((string) $submission->status, [WorldSubmission::STATUS_LIVE, WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($worldId, $selectedWorldIds, true)) {
|
||||
$submission->delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function transition(WorldSubmission $submission, User $reviewer, string $status, ?string $reviewerNote = null): WorldSubmission
|
||||
{
|
||||
$payload = [
|
||||
'status' => $status,
|
||||
'reviewer_note' => $this->nullableText($reviewerNote),
|
||||
'moderation_reason' => $this->nullableText($reviewerNote),
|
||||
];
|
||||
|
||||
if ($status === WorldSubmission::STATUS_PENDING) {
|
||||
$payload['reviewer_note'] = null;
|
||||
$payload['moderation_reason'] = null;
|
||||
$payload['reviewed_by_user_id'] = null;
|
||||
$payload['reviewed_at'] = null;
|
||||
$payload['removed_at'] = null;
|
||||
$payload['blocked_at'] = null;
|
||||
} else {
|
||||
$payload['reviewed_by_user_id'] = (int) $reviewer->id;
|
||||
$payload['reviewed_at'] = now();
|
||||
$payload['removed_at'] = $status === WorldSubmission::STATUS_REMOVED ? now() : null;
|
||||
$payload['blocked_at'] = $status === WorldSubmission::STATUS_BLOCKED ? now() : null;
|
||||
}
|
||||
|
||||
if ($status !== WorldSubmission::STATUS_LIVE) {
|
||||
$payload['is_featured'] = false;
|
||||
$payload['featured_at'] = null;
|
||||
}
|
||||
|
||||
$submission->forceFill($payload)->save();
|
||||
|
||||
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
|
||||
}
|
||||
|
||||
public function setFeatured(WorldSubmission $submission, User $reviewer, bool $featured, ?string $reviewerNote = null): WorldSubmission
|
||||
{
|
||||
$payload = [
|
||||
'is_featured' => $featured,
|
||||
'featured_at' => $featured ? now() : null,
|
||||
'reviewed_by_user_id' => (int) $reviewer->id,
|
||||
'reviewed_at' => now(),
|
||||
];
|
||||
|
||||
if ($reviewerNote !== null) {
|
||||
$payload['reviewer_note'] = $this->nullableText($reviewerNote);
|
||||
$payload['moderation_reason'] = $this->nullableText($reviewerNote);
|
||||
}
|
||||
|
||||
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
|
||||
$payload['status'] = WorldSubmission::STATUS_LIVE;
|
||||
$payload['removed_at'] = null;
|
||||
$payload['blocked_at'] = null;
|
||||
}
|
||||
|
||||
$submission->forceFill($payload)->save();
|
||||
|
||||
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
|
||||
}
|
||||
|
||||
public function studioReviewQueue(World $world): array
|
||||
{
|
||||
$world->loadMissing([
|
||||
'worldSubmissions.artwork.user.profile',
|
||||
'worldSubmissions.artwork.stats',
|
||||
'worldSubmissions.artwork.categories',
|
||||
'worldSubmissions.submittedBy.profile',
|
||||
'worldSubmissions.reviewer.profile',
|
||||
]);
|
||||
|
||||
$items = $world->worldSubmissions
|
||||
->sortBy([
|
||||
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
|
||||
WorldSubmission::STATUS_PENDING => 0,
|
||||
WorldSubmission::STATUS_LIVE => $submission->is_featured ? 1 : 2,
|
||||
WorldSubmission::STATUS_REMOVED => 3,
|
||||
WorldSubmission::STATUS_BLOCKED => 4,
|
||||
default => 4,
|
||||
},
|
||||
fn (WorldSubmission $submission): int => -1 * ($submission->reviewed_at?->getTimestamp() ?? $submission->created_at?->getTimestamp() ?? 0),
|
||||
])
|
||||
->values();
|
||||
|
||||
return [
|
||||
'counts' => [
|
||||
'pending' => $items->where('status', WorldSubmission::STATUS_PENDING)->count(),
|
||||
'live' => $items->where('status', WorldSubmission::STATUS_LIVE)->count(),
|
||||
'removed' => $items->where('status', WorldSubmission::STATUS_REMOVED)->count(),
|
||||
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
|
||||
'featured' => $items->where('is_featured', true)->count(),
|
||||
],
|
||||
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
public function publicSectionPayload(World $world, ?User $viewer = null): ?array
|
||||
{
|
||||
if (! $world->community_section_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = Artwork::query()
|
||||
->select('artworks.*', 'world_submissions.status as world_submission_status', 'world_submissions.is_featured as world_submission_is_featured', 'world_submissions.note as world_submission_note', 'world_submissions.reviewed_at as world_submission_reviewed_at')
|
||||
->join('world_submissions', function ($join) use ($world): void {
|
||||
$join->on('world_submissions.artwork_id', '=', 'artworks.id')
|
||||
->where('world_submissions.world_id', '=', $world->id)
|
||||
->where('world_submissions.status', '=', WorldSubmission::STATUS_LIVE);
|
||||
})
|
||||
->with(['user.profile', 'categories.contentType', 'stats'])
|
||||
->catalogVisible();
|
||||
|
||||
$this->maturity->applyViewerFilter($query, $viewer);
|
||||
|
||||
$items = $query
|
||||
->orderByRaw('CASE WHEN world_submissions.is_featured = 1 THEN 0 ELSE 1 END')
|
||||
->orderByDesc('world_submissions.reviewed_at')
|
||||
->limit(24)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => $this->mapPublicSubmissionArtwork($artwork))
|
||||
->all();
|
||||
|
||||
if ($items === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'Community submissions',
|
||||
'description' => 'Artworks submitted by creators and selected for this world outside the editorial curated-relation system.',
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
private function eligibleWorldsQuery(): Builder
|
||||
{
|
||||
return World::query()
|
||||
->published()
|
||||
->where('accepts_submissions', true)
|
||||
->whereIn('participation_mode', [World::PARTICIPATION_MODE_MANUAL_APPROVAL, World::PARTICIPATION_MODE_AUTO_ADD])
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('submission_starts_at')
|
||||
->orWhere('submission_starts_at', '<=', now());
|
||||
})
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('submission_ends_at')
|
||||
->orWhere('submission_ends_at', '>=', now());
|
||||
})
|
||||
->orderBy('submission_ends_at')
|
||||
->orderBy('starts_at')
|
||||
->orderBy('title');
|
||||
}
|
||||
|
||||
private function isEligibleWorld(World $world): bool
|
||||
{
|
||||
return $world->isAcceptingSubmissions();
|
||||
}
|
||||
|
||||
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
|
||||
{
|
||||
$status = $submission ? (string) $submission->status : null;
|
||||
$selected = match ($status) {
|
||||
WorldSubmission::STATUS_PENDING,
|
||||
WorldSubmission::STATUS_LIVE => true,
|
||||
default => false,
|
||||
};
|
||||
|
||||
$locked = match ($status) {
|
||||
WorldSubmission::STATUS_BLOCKED => true,
|
||||
WorldSubmission::STATUS_PENDING => ! $eligible,
|
||||
WorldSubmission::STATUS_REMOVED => ! $eligible || ! (bool) $world->allow_readd_after_removal,
|
||||
default => false,
|
||||
};
|
||||
|
||||
$lockedReason = $locked
|
||||
? match ($status) {
|
||||
WorldSubmission::STATUS_BLOCKED => 'This artwork is blocked from this world until a moderator clears the block.',
|
||||
WorldSubmission::STATUS_PENDING => 'This world is no longer accepting submission changes right now.',
|
||||
WorldSubmission::STATUS_REMOVED => (bool) $world->allow_readd_after_removal
|
||||
? 'This world is not currently open for re-adding removed artworks.'
|
||||
: 'Removed artworks cannot be re-added to this world right now.',
|
||||
default => 'This world is locked.',
|
||||
}
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => (int) $world->id,
|
||||
'title' => (string) $world->title,
|
||||
'slug' => (string) $world->slug,
|
||||
'tagline' => (string) ($world->tagline ?? ''),
|
||||
'summary' => (string) ($world->summary ?? ''),
|
||||
'cover_url' => $world->coverUrl(),
|
||||
'timeframe_label' => $this->timeframeLabel($world),
|
||||
'submission_window_label' => $this->submissionWindowLabel($world),
|
||||
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
|
||||
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
|
||||
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
|
||||
'submission_note_enabled' => (bool) $world->submission_note_enabled,
|
||||
'is_accepting_submissions' => $eligible,
|
||||
'selected' => $selected,
|
||||
'selection_locked' => $locked,
|
||||
'selection_locked_reason' => $lockedReason,
|
||||
'note' => (string) ($submission?->note ?? ''),
|
||||
'status' => $status,
|
||||
'status_label' => $status ? $this->statusLabel($status, (bool) ($submission?->is_featured ?? false)) : null,
|
||||
'reviewer_note' => (string) ($submission?->moderation_reason ?: $submission?->reviewer_note ?? ''),
|
||||
'is_featured' => (bool) ($submission?->is_featured ?? false),
|
||||
'submitted_at' => $submission?->created_at?->toIso8601String(),
|
||||
'reviewed_at' => $submission?->reviewed_at?->toIso8601String(),
|
||||
'can_resubmit' => $eligible && (bool) $world->allow_readd_after_removal && $status === WorldSubmission::STATUS_REMOVED,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapStudioSubmission(WorldSubmission $submission): array
|
||||
{
|
||||
$artwork = $submission->artwork;
|
||||
$views = (int) ($artwork?->stats?->views ?? 0);
|
||||
|
||||
return [
|
||||
'id' => (int) $submission->id,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
|
||||
'is_featured' => (bool) $submission->is_featured,
|
||||
'note' => (string) ($submission->note ?? ''),
|
||||
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
|
||||
'submitted_at' => $submission->created_at?->toIso8601String(),
|
||||
'reviewed_at' => $submission->reviewed_at?->toIso8601String(),
|
||||
'removed_at' => $submission->removed_at?->toIso8601String(),
|
||||
'blocked_at' => $submission->blocked_at?->toIso8601String(),
|
||||
'featured_at' => $submission->featured_at?->toIso8601String(),
|
||||
'submitted_by' => $submission->submittedBy ? [
|
||||
'id' => (int) $submission->submittedBy->id,
|
||||
'name' => (string) ($submission->submittedBy->name ?: $submission->submittedBy->username ?: 'Unknown creator'),
|
||||
'username' => (string) ($submission->submittedBy->username ?? ''),
|
||||
] : null,
|
||||
'reviewed_by' => $submission->reviewer ? [
|
||||
'id' => (int) $submission->reviewer->id,
|
||||
'name' => (string) ($submission->reviewer->name ?: $submission->reviewer->username ?: 'Moderator'),
|
||||
] : null,
|
||||
'artwork' => $artwork ? [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
|
||||
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'thumbnail_url' => $artwork->thumbUrl('md'),
|
||||
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
|
||||
'meta' => array_values(array_filter([
|
||||
$artwork->categories->first()?->name,
|
||||
$views > 0 ? number_format($views) . ' views' : null,
|
||||
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
|
||||
])),
|
||||
] : null,
|
||||
'actions' => [
|
||||
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'block' => route('studio.worlds.submissions.block', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'unblock' => route('studio.worlds.submissions.unblock', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'restore' => route('studio.worlds.submissions.restore', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function mapPublicSubmissionArtwork(Artwork $artwork): array
|
||||
{
|
||||
$resource = ArtworkListResource::make($artwork)->toArray(request());
|
||||
$views = (int) ($artwork->stats?->views ?? 0);
|
||||
$status = (string) ($artwork->world_submission_status ?? WorldSubmission::STATUS_LIVE);
|
||||
$isFeatured = (bool) ($artwork->world_submission_is_featured ?? false);
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($resource['title'] ?? $artwork->title ?? 'Untitled artwork'),
|
||||
'subtitle' => (string) ($resource['author']['name'] ?? ''),
|
||||
'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120),
|
||||
'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])),
|
||||
'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'),
|
||||
'status' => $status,
|
||||
'status_label' => $this->statusLabel($status, $isFeatured),
|
||||
'context_label' => $isFeatured ? 'Community featured' : 'Community submission',
|
||||
'meta' => array_values(array_filter([
|
||||
$resource['category']['name'] ?? null,
|
||||
$views > 0 ? number_format($views) . ' views' : null,
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
private function statusLabel(string $status, bool $isFeatured = false): string
|
||||
{
|
||||
if ($status === WorldSubmission::STATUS_LIVE && $isFeatured) {
|
||||
return 'Featured';
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
WorldSubmission::STATUS_PENDING => 'Pending',
|
||||
WorldSubmission::STATUS_LIVE => 'Live',
|
||||
WorldSubmission::STATUS_REMOVED => 'Removed',
|
||||
WorldSubmission::STATUS_BLOCKED => 'Blocked',
|
||||
default => Str::headline($status),
|
||||
};
|
||||
}
|
||||
|
||||
private function participationModeLabel(string $mode): string
|
||||
{
|
||||
return match ($mode) {
|
||||
World::PARTICIPATION_MODE_MANUAL_APPROVAL => 'Manual approval',
|
||||
World::PARTICIPATION_MODE_AUTO_ADD => 'Auto add',
|
||||
World::PARTICIPATION_MODE_CLOSED => 'Closed',
|
||||
default => Str::headline($mode),
|
||||
};
|
||||
}
|
||||
|
||||
private function timeframeLabel(World $world): string
|
||||
{
|
||||
if ($world->starts_at && $world->ends_at) {
|
||||
return $world->starts_at->format('M j') . ' - ' . $world->ends_at->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($world->starts_at) {
|
||||
return 'Starts ' . $world->starts_at->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($world->ends_at) {
|
||||
return 'Until ' . $world->ends_at->format('M j, Y');
|
||||
}
|
||||
|
||||
return 'Open-ended world';
|
||||
}
|
||||
|
||||
private function submissionWindowLabel(World $world): string
|
||||
{
|
||||
$start = $world->submission_starts_at;
|
||||
$end = $world->submission_ends_at;
|
||||
|
||||
if ($start && $end) {
|
||||
return $start->format('M j') . ' - ' . $end->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($start) {
|
||||
return 'Opens ' . $start->format('M j, Y');
|
||||
}
|
||||
|
||||
if ($end) {
|
||||
return 'Open until ' . $end->format('M j, Y');
|
||||
}
|
||||
|
||||
return 'Open submissions';
|
||||
}
|
||||
|
||||
private function nullableText(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user