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