feat: ship creator journey v2 and profile updates
This commit is contained in:
156
app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php
Normal file
156
app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Maturity\ArtworkMaturityAuditService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
final class AuditArtworkMaturityThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:audit-thumbnail-maturity
|
||||
{--id= : Audit only this artwork ID}
|
||||
{--after-id=0 : Skip artworks with ID less than or equal to this value}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=25 : Number of artworks to scan per batch}
|
||||
{--variant= : Thumbnail variant to analyze (defaults to vision.image_variant)}
|
||||
{--refresh : Re-scan artworks that already have an open audit finding}
|
||||
{--dry-run : Report candidates without writing audit findings}';
|
||||
|
||||
protected $description = 'Scan artwork thumbnails for possible mature content without mutating artwork maturity fields.';
|
||||
|
||||
public function handle(VisionService $vision, ArtworkMaturityAuditService $audit): int
|
||||
{
|
||||
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 200));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$refresh = (bool) $this->option('refresh');
|
||||
$variant = trim((string) ($this->option('variant') ?: config('vision.image_variant', 'md')));
|
||||
|
||||
if (! $vision->isEnabled()) {
|
||||
$this->error('Vision maturity analysis is disabled.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && ! Schema::hasTable('artwork_maturity_audit_findings')) {
|
||||
$this->error('Artwork maturity audit findings table is missing. Run the latest database migrations first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting artwork maturity thumbnail audit. order=id_desc variant=%s chunk=%d limit=%s refresh=%s dry_run=%s',
|
||||
$variant !== '' ? $variant : 'md',
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$refresh ? 'yes' : 'no',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
));
|
||||
|
||||
$query = $audit->eligibleArtworkQuery($refresh)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
}
|
||||
|
||||
if ($afterId > 0) {
|
||||
$query->where('id', '>', $afterId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$flagged = 0;
|
||||
$safe = 0;
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkByIdDesc($chunkSize, function ($artworks) use ($vision, $audit, $variant, $limit, $dryRun, $refresh, &$processed, &$flagged, &$safe, &$written, &$failed) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$assessment = (array) ($vision->analyzeArtworkMaturityDetailed($artwork, (string) $artwork->hash, $variant)['assessment'] ?? []);
|
||||
$processed++;
|
||||
|
||||
if ($audit->shouldOpenFinding($assessment)) {
|
||||
$flagged++;
|
||||
$message = sprintf(
|
||||
'Artwork %d flagged for moderator review. action=%s confidence=%s label=%s',
|
||||
(int) $artwork->id,
|
||||
(string) ($assessment['action_hint'] ?? 'unknown'),
|
||||
is_numeric($assessment['confidence'] ?? null) ? number_format((float) $assessment['confidence'], 4, '.', '') : 'n/a',
|
||||
(string) ($assessment['maturity_label'] ?? 'unknown'),
|
||||
);
|
||||
|
||||
$this->warn($message);
|
||||
Log::warning('artworks:audit-thumbnail-maturity candidate detected', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'assessment' => $assessment,
|
||||
'variant' => $variant,
|
||||
]);
|
||||
|
||||
if (! $dryRun) {
|
||||
$audit->recordFinding($artwork, $assessment, $variant !== '' ? $variant : 'md');
|
||||
$written++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) === ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
|
||||
$safe++;
|
||||
$this->line(sprintf('Artwork %d scanned safe for audit purposes.', (int) $artwork->id));
|
||||
|
||||
if (! $dryRun && $refresh) {
|
||||
$audit->markFindingCleared($artwork, 'Thumbnail maturity rescan no longer indicates moderator review.');
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$failed++;
|
||||
$this->warn(sprintf(
|
||||
'Artwork %d maturity audit failed: %s',
|
||||
(int) $artwork->id,
|
||||
(string) ($assessment['advisory'] ?? $assessment['status'] ?? 'unknown failure'),
|
||||
));
|
||||
} catch (Throwable $exception) {
|
||||
$processed++;
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
Log::warning('artworks:audit-thumbnail-maturity failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'variant' => $variant,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Artwork maturity thumbnail audit complete. processed=%d flagged=%d safe=%d written=%d failed=%d',
|
||||
$processed,
|
||||
$flagged,
|
||||
$safe,
|
||||
$written,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
203
app/Console/Commands/AuditArtworkThumbnailsCommand.php
Normal file
203
app/Console/Commands/AuditArtworkThumbnailsCommand.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class AuditArtworkThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:audit-thumbnails
|
||||
{--id= : Audit only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=200 : Number of artworks to scan per batch}
|
||||
{--variant=* : Specific thumbnail variants to check (defaults to all configured derivatives)}
|
||||
{--dry-run : Report missing thumbnails without updating the artworks table}';
|
||||
|
||||
protected $description = 'Check artwork thumbnails on the configured object storage disk and mark artworks with missing thumbnails.';
|
||||
|
||||
public function handle(UploadStorageService $storage): int
|
||||
{
|
||||
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$variants = $this->resolveVariants();
|
||||
if ($variants === []) {
|
||||
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun && ! Schema::hasColumns('artworks', [
|
||||
'has_missing_thumbnails',
|
||||
'missing_thumbnail_variants_json',
|
||||
'thumbnails_checked_at',
|
||||
])) {
|
||||
$this->error('Artwork thumbnail audit columns are missing. Run the latest database migrations first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$diskName = $storage->objectDiskName();
|
||||
$diskConfig = config("filesystems.disks.{$diskName}");
|
||||
if (! is_array($diskConfig)) {
|
||||
$this->error("Filesystem disk [{$diskName}] is not configured.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($diskName);
|
||||
$this->info(sprintf(
|
||||
'Starting thumbnail audit. disk=%s variants=%s chunk=%d limit=%s dry_run=%s',
|
||||
$diskName,
|
||||
implode(',', $variants),
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
));
|
||||
|
||||
$query = Artwork::query()
|
||||
->select(['id', 'hash', 'thumb_ext'])
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$healthy = 0;
|
||||
$missing = 0;
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById($chunkSize, function ($artworks) use ($storage, $disk, $variants, $limit, $dryRun, &$processed, &$healthy, &$missing, &$written, &$failed) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$missingVariants = $this->resolveMissingVariants($artwork, $variants, $storage, $disk);
|
||||
$hasMissing = $missingVariants !== [];
|
||||
|
||||
if ($hasMissing) {
|
||||
$missing++;
|
||||
$this->warn(sprintf(
|
||||
'Artwork %d missing thumbnails: %s',
|
||||
(int) $artwork->id,
|
||||
implode(',', $missingVariants),
|
||||
));
|
||||
} else {
|
||||
$healthy++;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->persistAuditResult((int) $artwork->id, $hasMissing, $missingVariants);
|
||||
$written++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
}
|
||||
|
||||
$processed++;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Thumbnail audit complete. processed=%d healthy=%d missing=%d written=%d failed=%d',
|
||||
$processed,
|
||||
$healthy,
|
||||
$missing,
|
||||
$written,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveVariants(): array
|
||||
{
|
||||
$configured = array_keys((array) config('uploads.derivatives', []));
|
||||
$configured = array_values(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
$configured,
|
||||
)));
|
||||
|
||||
$requested = (array) $this->option('variant');
|
||||
if ($requested === []) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$normalizedRequested = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
$requested,
|
||||
))));
|
||||
|
||||
$invalid = array_values(array_diff($normalizedRequested, $configured));
|
||||
if ($invalid !== []) {
|
||||
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
|
||||
$this->line('Configured variants: ' . implode(', ', $configured));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $normalizedRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $variants
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
|
||||
{
|
||||
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
|
||||
|
||||
if ($hash === '' || $thumbExt === '') {
|
||||
return $variants;
|
||||
}
|
||||
|
||||
$filename = $hash . '.' . $thumbExt;
|
||||
$missing = [];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$objectPath = $storage->objectPathForVariant($variant, $hash, $filename);
|
||||
if (! $disk->exists($objectPath)) {
|
||||
$missing[] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $missingVariants
|
||||
*/
|
||||
private function persistAuditResult(int $artworkId, bool $hasMissing, array $missingVariants): void
|
||||
{
|
||||
DB::table('artworks')
|
||||
->where('id', $artworkId)
|
||||
->update([
|
||||
'has_missing_thumbnails' => $hasMissing,
|
||||
'missing_thumbnail_variants_json' => $missingVariants === []
|
||||
? null
|
||||
: json_encode(array_values($missingVariants), JSON_UNESCAPED_SLASHES),
|
||||
'thumbnails_checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,22 @@ class ConfigureMeilisearchIndex extends Command
|
||||
private const SORTABLE_ATTRIBUTES = [
|
||||
'created_at',
|
||||
'published_at_ts',
|
||||
'missing_thumbnail_rank',
|
||||
'trending_score_24h',
|
||||
'trending_score_7d',
|
||||
'favorites_count',
|
||||
'downloads_count',
|
||||
'awards_received_count',
|
||||
'awards_score_7d',
|
||||
'awards_score_30d',
|
||||
'views',
|
||||
'likes',
|
||||
'downloads',
|
||||
'ranking_score',
|
||||
'engagement_velocity',
|
||||
'shares_count',
|
||||
'comments_count',
|
||||
'heat_score',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -44,6 +52,11 @@ class ConfigureMeilisearchIndex extends Command
|
||||
'id',
|
||||
'is_public',
|
||||
'is_approved',
|
||||
'is_mature',
|
||||
'is_mature_effective',
|
||||
'maturity_level',
|
||||
'maturity_status',
|
||||
'has_missing_thumbnails',
|
||||
'category',
|
||||
'content_type',
|
||||
'tags',
|
||||
|
||||
@@ -5,20 +5,20 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkAwardStat;
|
||||
use App\Services\ArtworkAwardService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
|
||||
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_medals`.
|
||||
*
|
||||
* Score mapping (legacy score → new medal):
|
||||
* 4 → gold (weight 3)
|
||||
* 3 → silver (weight 2)
|
||||
* 2 → bronze (weight 1)
|
||||
* 1 → skipped (too low to map meaningfully)
|
||||
* 5 → gold
|
||||
* 4 → gold
|
||||
* 3 → silver
|
||||
* 2 → silver
|
||||
* 1 → bronze
|
||||
* 0 → bronze
|
||||
*
|
||||
* Usage:
|
||||
* php artisan awards:import-legacy
|
||||
@@ -29,22 +29,38 @@ use Illuminate\Support\Facades\Schema;
|
||||
class ImportLegacyAwards extends Command
|
||||
{
|
||||
protected $signature = 'awards:import-legacy
|
||||
{--connection=legacy : Legacy database connection name}
|
||||
{--artwork-id=* : Restrict import to one or more artwork IDs}
|
||||
{--show-duplicates : Output skipped duplicate artwork/user pairs at the end}
|
||||
{--duplicates-limit=100 : Maximum duplicate rows to print when --show-duplicates is used}
|
||||
{--dry-run : Preview only — no writes to DB}
|
||||
{--chunk=250 : Rows to process per batch}
|
||||
{--skip-stats : Skip per-artwork stats recalculation at the end}
|
||||
{--force : Overwrite existing awards instead of skipping duplicates}';
|
||||
|
||||
protected $description = 'Import legacy users_opinions into artwork_awards';
|
||||
protected $description = 'Import legacy users_opinions into artwork_medals';
|
||||
|
||||
/** Maps legacy score value → medal string */
|
||||
private const SCORE_MAP = [
|
||||
4 => 'gold',
|
||||
0 => 'bronze',
|
||||
1 => 'bronze',
|
||||
2 => 'silver',
|
||||
3 => 'silver',
|
||||
2 => 'bronze',
|
||||
4 => 'gold',
|
||||
5 => 'gold',
|
||||
];
|
||||
|
||||
public function handle(ArtworkAwardService $service): int
|
||||
{
|
||||
$legacyConnection = (string) $this->option('connection');
|
||||
$artworkIds = collect((array) $this->option('artwork-id'))
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->filter(static fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$showDuplicates = (bool) $this->option('show-duplicates');
|
||||
$duplicatesLimit = max(1, (int) $this->option('duplicates-limit'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$skipStats = (bool) $this->option('skip-stats');
|
||||
@@ -56,17 +72,24 @@ class ImportLegacyAwards extends Command
|
||||
|
||||
// Verify legacy connection is reachable
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
DB::connection($legacyConnection)->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||
$this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
|
||||
$this->error('Legacy table `users_opinions` not found.');
|
||||
if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable('users_opinions')) {
|
||||
$this->error("Legacy table `users_opinions` not found on connection [{$legacyConnection}].");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacyQuery = DB::connection($legacyConnection)->table('users_opinions');
|
||||
|
||||
if ($artworkIds !== []) {
|
||||
$legacyQuery->whereIn('artwork_id', $artworkIds);
|
||||
$this->info('Restricting import to artwork IDs: ' . implode(', ', $artworkIds));
|
||||
}
|
||||
|
||||
// Pre-load sets of valid artwork IDs and user IDs from the new DB
|
||||
$this->info('Loading new-DB artwork and user ID sets…');
|
||||
$validArtworkIds = DB::table('artworks')
|
||||
@@ -88,9 +111,7 @@ class ImportLegacyAwards extends Command
|
||||
));
|
||||
|
||||
// Count legacy rows for progress bar
|
||||
$total = DB::connection('legacy')
|
||||
->table('users_opinions')
|
||||
->count();
|
||||
$total = (clone $legacyQuery)->count();
|
||||
|
||||
$this->info("Legacy rows to process: {$total}");
|
||||
|
||||
@@ -105,11 +126,13 @@ class ImportLegacyAwards extends Command
|
||||
'skipped_artwork' => 0,
|
||||
'skipped_user' => 0,
|
||||
'skipped_duplicate'=> 0,
|
||||
'reported_duplicate'=> 0,
|
||||
'updated_force' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$affectedArtworkIds = [];
|
||||
$duplicateRows = [];
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||
@@ -117,24 +140,30 @@ class ImportLegacyAwards extends Command
|
||||
$bar->setMessage('0', 'skipped');
|
||||
$bar->start();
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users_opinions')
|
||||
$legacyQuery
|
||||
->orderBy('opinion_id')
|
||||
->chunk($chunk, function ($rows) use (
|
||||
&$stats,
|
||||
&$affectedArtworkIds,
|
||||
&$duplicateRows,
|
||||
$validArtworkIds,
|
||||
$validUserIds,
|
||||
$dryRun,
|
||||
$force,
|
||||
$showDuplicates,
|
||||
$duplicatesLimit,
|
||||
$bar
|
||||
) {
|
||||
$inserts = [];
|
||||
$now = now();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// Legacy users_opinions semantics:
|
||||
// - artwork_id = the artwork being scored
|
||||
// - author_id = the artwork owner / author
|
||||
// - user_id = the voter who gave the score
|
||||
$artworkId = (int) $row->artwork_id;
|
||||
$userId = (int) $row->author_id; // author_id = the voter
|
||||
$userId = (int) $row->user_id;
|
||||
$score = (int) $row->score;
|
||||
$postedAt = $row->post_date ?? $now;
|
||||
|
||||
@@ -163,11 +192,11 @@ class ImportLegacyAwards extends Command
|
||||
if (! $dryRun) {
|
||||
if ($force) {
|
||||
// Upsert: update medal if row already exists
|
||||
$affected = DB::table('artwork_awards')
|
||||
$affected = DB::table('artwork_medals')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
'medal' => $medal,
|
||||
'medal_type' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -180,13 +209,26 @@ class ImportLegacyAwards extends Command
|
||||
}
|
||||
} else {
|
||||
// Skip if already exists
|
||||
if (
|
||||
DB::table('artwork_awards')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->exists()
|
||||
) {
|
||||
$existingMedal = DB::table('artwork_medals')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->value('medal_type');
|
||||
|
||||
if ($existingMedal !== null) {
|
||||
$stats['skipped_duplicate']++;
|
||||
|
||||
if ($showDuplicates && count($duplicateRows) < $duplicatesLimit) {
|
||||
$duplicateRows[] = [
|
||||
'opinion_id' => (int) ($row->opinion_id ?? 0),
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'legacy_score' => $score,
|
||||
'legacy_medal' => $medal,
|
||||
'existing_medal' => (string) $existingMedal,
|
||||
];
|
||||
$stats['reported_duplicate']++;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
@@ -195,7 +237,7 @@ class ImportLegacyAwards extends Command
|
||||
$inserts[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'medal' => $medal,
|
||||
'medal_type' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
'created_at' => $postedAt,
|
||||
'updated_at' => $postedAt,
|
||||
@@ -212,12 +254,12 @@ class ImportLegacyAwards extends Command
|
||||
// stats are recalculated in bulk at the end for performance)
|
||||
if (! $dryRun && ! empty($inserts)) {
|
||||
try {
|
||||
DB::table('artwork_awards')->insert($inserts);
|
||||
DB::table('artwork_medals')->insert($inserts);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: insert one-by-one to isolate constraint violations
|
||||
foreach ($inserts as $row) {
|
||||
try {
|
||||
DB::table('artwork_awards')->insertOrIgnore([$row]);
|
||||
DB::table('artwork_medals')->insertOrIgnore([$row]);
|
||||
} catch (\Throwable) {
|
||||
$stats['errors']++;
|
||||
}
|
||||
@@ -277,6 +319,30 @@ class ImportLegacyAwards extends Command
|
||||
]
|
||||
);
|
||||
|
||||
if ($showDuplicates && $stats['skipped_duplicate'] > 0) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Duplicate rows skipped: %d. Showing %d row(s)%s.',
|
||||
$stats['skipped_duplicate'],
|
||||
count($duplicateRows),
|
||||
$stats['skipped_duplicate'] > count($duplicateRows) ? " (truncated by --duplicates-limit={$duplicatesLimit})" : ''
|
||||
));
|
||||
|
||||
if ($duplicateRows !== []) {
|
||||
$this->table(
|
||||
['Legacy opinion', 'Artwork ID', 'Voter user_id', 'Legacy score', 'Legacy medal', 'Existing medal'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['opinion_id'],
|
||||
$row['artwork_id'],
|
||||
$row['user_id'],
|
||||
$row['legacy_score'],
|
||||
$row['legacy_medal'],
|
||||
$row['existing_medal'],
|
||||
], $duplicateRows)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
|
||||
109
app/Console/Commands/ImportLegacyNewsCommand.php
Normal file
109
app/Console/Commands/ImportLegacyNewsCommand.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
class ImportLegacyNewsCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:import-legacy {--dry-run} {--limit=500} {--start=0}';
|
||||
|
||||
protected $description = 'Import News articles from legacy DB into the current Skinbase news_articles table.';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
$start = (int) $this->option('start');
|
||||
|
||||
// Verify legacy DB connection exists and is reachable
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot connect to legacy database via connection "legacy": ' . $e->getMessage());
|
||||
Log::error('Legacy import failed - cannot connect to legacy', ['exception' => $e]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('news')) {
|
||||
$this->error('Legacy table `news` not found on legacy connection.');
|
||||
return 2;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Fetching up to %d legacy rows starting at %d...', $limit, $start));
|
||||
|
||||
try {
|
||||
$rows = DB::connection('legacy')->table('news')
|
||||
->orderBy('news_id')
|
||||
->skip($start)
|
||||
->take($limit)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to query legacy DB: ' . $e->getMessage());
|
||||
Log::error('Legacy import failed', ['exception' => $e]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('No rows found in legacy news table.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('Processing ' . $rows->count() . ' rows...');
|
||||
|
||||
$created = 0;
|
||||
foreach ($rows as $row) {
|
||||
// Map fields conservatively — adjust mapping as needed for your legacy schema
|
||||
$title = $row->headline ?? ($row->title ?? '');
|
||||
$content = $row->content ?? ($row->message ?? '');
|
||||
$excerpt = $row->preview ?? null;
|
||||
$publishedAt = $row->create_date ?? ($row->published_at ?? null);
|
||||
|
||||
// Best-effort author mapping: try username/uname then fallback to user id 1
|
||||
$authorId = 1;
|
||||
if (!empty($row->uname)) {
|
||||
$uid = DB::table('users')->where('username', $row->uname)->orWhere('uname', $row->uname)->value('id');
|
||||
if ($uid) {
|
||||
$authorId = $uid;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'title' => $title,
|
||||
'slug' => NewsArticle::generateUniqueSlug($title),
|
||||
'excerpt' => $excerpt,
|
||||
'content' => $content,
|
||||
'cover_image' => $row->picture ?? null,
|
||||
'type' => 'announcement',
|
||||
'author_id' => $authorId,
|
||||
'category_id' => null,
|
||||
'editorial_status' => isset($row->type) && (int)$row->type === 0 ? NewsArticle::EDITORIAL_STATUS_DRAFT : NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||
'published_at' => $publishedAt ? date('Y-m-d H:i:s', strtotime($publishedAt)) : null,
|
||||
'is_featured' => ($row->frontpage ?? 0) == 1,
|
||||
'is_pinned' => ($row->type ?? 0) == 2,
|
||||
'views' => $row->views ?? 0,
|
||||
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line('[dry-run] Would insert: ' . $payload['title'] . ' (' . ($payload['published_at'] ?? 'no-date') . ')');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
NewsArticle::create($payload);
|
||||
$created++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to insert legacy article ' . ($row->news_id ?? '?') . ': ' . $e->getMessage());
|
||||
Log::error('import-legacy: insert failed', ['exception' => $e, 'row' => $row]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('Done. Created %d articles (dry-run=%s).', $created, $dryRun ? 'yes' : 'no'));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
90
app/Console/Commands/RebuildCreatorErasCommand.php
Normal file
90
app/Console/Commands/RebuildCreatorErasCommand.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorEraService;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Rebuild creator eras independently of milestones.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan creator-journey:rebuild-eras (all users)
|
||||
* php artisan creator-journey:rebuild-eras {user_id} (single user)
|
||||
*/
|
||||
class RebuildCreatorErasCommand extends Command
|
||||
{
|
||||
protected $signature = 'creator-journey:rebuild-eras
|
||||
{user_id? : Rebuild eras for a single user}
|
||||
{--chunk=500 : Chunk size when rebuilding all}';
|
||||
|
||||
protected $description = 'Rebuild creator era records from public artwork history (v2)';
|
||||
|
||||
public function handle(CreatorEraService $eraService, CreatorJourneyService $journeys): int
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->rebuildSingle((int) $userId, $eraService, $journeys);
|
||||
}
|
||||
|
||||
return $this->rebuildAll($eraService, $journeys, (int) $this->option('chunk'));
|
||||
}
|
||||
|
||||
private function rebuildSingle(int $userId, CreatorEraService $eraService, CreatorJourneyService $journeys): int
|
||||
{
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User {$userId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Delegate to journeys service so eras + milestones stay in sync
|
||||
$journeys->rebuildForUser($user);
|
||||
|
||||
$this->info("Rebuilt eras for user #{$userId}.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function rebuildAll(CreatorEraService $eraService, CreatorJourneyService $journeys, int $chunk): int
|
||||
{
|
||||
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||
$this->info("Rebuilding eras for {$total} users...");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$eras = 0;
|
||||
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use ($journeys, $bar, &$eras): void {
|
||||
foreach ($users as $userRow) {
|
||||
try {
|
||||
$user = User::query()->find($userRow->id);
|
||||
if ($user) {
|
||||
$journeys->rebuildForUser($user);
|
||||
$eras++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->newLine();
|
||||
$this->warn("Failed for user #{$userRow->id}: " . $e->getMessage());
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Rebuilt eras for {$eras} users.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
123
app/Console/Commands/RebuildCreatorJourneyCommand.php
Normal file
123
app/Console/Commands/RebuildCreatorJourneyCommand.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RebuildCreatorJourneyJob;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RebuildCreatorJourneyCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:rebuild-creator-journey
|
||||
{user_id? : The ID of a single creator to rebuild}
|
||||
{--all : Rebuild creator journey rows for all non-deleted users}
|
||||
{--chunk=500 : Chunk size when --all is used}
|
||||
{--queue : Dispatch rebuild jobs instead of rebuilding inline}';
|
||||
|
||||
protected $description = 'Rebuild persisted creator journey milestones from public source data';
|
||||
|
||||
public function handle(CreatorJourneyService $journeys): int
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
$all = (bool) $this->option('all');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$queue = (bool) $this->option('queue');
|
||||
|
||||
if ($userId !== null && $all) {
|
||||
$this->error('Provide either a user_id OR --all, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->rebuildSingle((int) $userId, $journeys, $queue);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
return $this->rebuildAll($journeys, $chunk, $queue);
|
||||
}
|
||||
|
||||
$this->error('Provide a user_id or use --all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function rebuildSingle(int $userId, CreatorJourneyService $journeys, bool $queue): int
|
||||
{
|
||||
if (! DB::table('users')->where('id', $userId)->exists()) {
|
||||
$this->error("User {$userId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
RebuildCreatorJourneyJob::dispatch([$userId]);
|
||||
$this->info("Queued creator journey rebuild for user #{$userId}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $journeys->rebuildForUser($userId);
|
||||
|
||||
$this->table(['Metric', 'Value'], [
|
||||
['user_id', $userId],
|
||||
['milestones_saved', $result['milestones_saved']],
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function rebuildAll(CreatorJourneyService $journeys, int $chunk, bool $queue): int
|
||||
{
|
||||
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s Rebuilding creator journeys for %d users (chunk=%d)...',
|
||||
$queue ? '[QUEUE]' : '[LIVE]',
|
||||
$total,
|
||||
$chunk,
|
||||
));
|
||||
|
||||
if ($queue) {
|
||||
$dispatched = 0;
|
||||
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use (&$dispatched): void {
|
||||
$ids = $users->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
RebuildCreatorJourneyJob::dispatch($ids);
|
||||
$dispatched += count($ids);
|
||||
$this->line(' Queued chunk of ' . count($ids) . ' users (total dispatched: ' . $dispatched . ')');
|
||||
});
|
||||
|
||||
$this->info("Done - {$dispatched} users queued for creator journey rebuild.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use ($journeys, &$processed, $bar): void {
|
||||
foreach ($users as $user) {
|
||||
$journeys->rebuildForUser((int) $user->id);
|
||||
$processed++;
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Done - {$processed} creator journeys rebuilt.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
31
app/Console/Commands/WarmHomepageGuestCacheCommand.php
Normal file
31
app/Console/Commands/WarmHomepageGuestCacheCommand.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\HomepageService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class WarmHomepageGuestCacheCommand extends Command
|
||||
{
|
||||
protected $signature = 'homepage:warm-guest-cache';
|
||||
|
||||
protected $description = 'Warm the guest homepage payload cache';
|
||||
|
||||
public function handle(HomepageService $homepage): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$payload = $homepage->warmGuestPayloadCache();
|
||||
$durationMs = (microtime(true) - $startedAt) * 1000;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Warmed guest homepage cache (%d sections) in %.2fms using store [%s].',
|
||||
count($payload),
|
||||
$durationMs,
|
||||
$homepage->guestPayloadCacheStoreName(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user