Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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();
}
}

View 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,
];
}
}

View 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) . "'";
}
}

View 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,
};
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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

View 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;
}
}

View 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;
}
}

View File

@@ -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()}");

View 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;
}
}

View 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;
}
}

View File

@@ -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');
}
/**

View 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,
]);
}
}

View File

@@ -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'))
);
}
}

View 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(),
],
]);
}
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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'));
}

View File

@@ -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

View 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.');
}
}

View File

@@ -90,6 +90,8 @@ class SetupUsernameController extends Controller
])->save();
});
$request->session()->forget('username_login_upgrade');
return redirect('/@' . strtolower($candidate));
}
}

View 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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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()),
]);
}

View File

@@ -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,

View 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);
}
}

View 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.');
}
}
}

View File

@@ -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')

View File

@@ -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',

View File

@@ -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);

View File

@@ -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))

View File

@@ -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(

View File

@@ -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'),

View File

@@ -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', [

View File

@@ -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,
];
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -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.');
}

View 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.');
}
}

View File

@@ -28,7 +28,7 @@ class EnsureOnboardingComplete
}
$step = strtolower((string) ($user->onboarding_step ?? ''));
if ($step === 'complete') {
if ($step === '' || $step === 'complete') {
return $next($request);
}

View File

@@ -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.
*

View File

@@ -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.');
}

View 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.');
}
});
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Worlds;
class UpdateWorldRequest extends StoreWorldRequest
{
}

View File

@@ -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());
}

View File

@@ -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);

View File

@@ -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);
}
}

View 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,
]);
}
}
}

View File

@@ -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',
};
}
}

View 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',
};
}
}

View File

@@ -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();

View 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) !== '';
}
}

View File

@@ -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';

View File

@@ -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
View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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();
}
}

View File

@@ -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,
];
/**

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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' => [],
];
}
}

View 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;
}
}

View 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,
];
}
}

View 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
{
}

View File

@@ -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);

View 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) {
}
}
}

View File

@@ -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'] ?? []),

View File

@@ -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;
}
}

View File

@@ -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,
];

View File

@@ -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(

View File

@@ -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) {

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}