Current state

This commit is contained in:
2026-02-07 08:23:18 +01:00
commit 0a4372c40d
22479 changed files with 1553543 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportArtworkHashes extends Command
{
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
public function handle(): int
{
$file = $this->argument('file');
$path = base_path($file);
if (!is_readable($path)) {
$this->error("CSV file not readable: {$path}");
return 1;
}
$handle = fopen($path, 'r');
if (!$handle) {
$this->error('Unable to open CSV file');
return 1;
}
// Read header
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV appears empty');
fclose($handle);
return 1;
}
$start = (int) $this->option('start');
$limit = (int) $this->option('limit');
$rowIndex = 0;
$bar = null;
// Optionally count lines for progress bar
if ($limit === 0) {
// We'll not determine total to keep memory low
}
while (($row = fgetcsv($handle)) !== false) {
$rowIndex++;
if ($rowIndex <= $start) {
continue;
}
if ($limit > 0 && ($rowIndex - $start) > $limit) {
break;
}
// Expecting columns: id,hash,file_ext,thumb_ext
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
if (empty($id)) {
continue;
}
// Normalize null strings
if (strtoupper($hash) === 'NULL') $hash = null;
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
try {
DB::table('artworks')->where('id', $id)->limit(1)->update([
'hash' => $hash,
'file_ext' => $file_ext,
'thumb_ext' => $thumb_ext,
]);
} catch (\Throwable $e) {
// Log and continue on duplicate / other DB errors
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
continue;
}
if ($rowIndex % 500 === 0) {
$this->info("Processed {$rowIndex} rows");
}
}
fclose($handle);
$this->info('Import complete.');
return 0;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class ImportCategories extends Command
{
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
protected $description = 'Import categories from legacy CSV into categories table';
public function handle(): int
{
$path = base_path($this->argument('path'));
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$file = new \SplFileObject($path);
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
$header = null;
$rows = [];
foreach ($file as $index => $row) {
if ($row === [null] || $row === false) {
continue;
}
if ($index === 0) {
$header = $row;
continue;
}
// normalize row length
if (!is_array($row)) {
continue;
}
$rows[] = $row;
}
// build a map of rows by category id to allow ancestor lookups
$map = [];
foreach ($rows as $r) {
$rid = isset($r[0]) ? (int)$r[0] : null;
if ($rid) {
$map[$rid] = $r;
}
}
// Temporarily disable foreign key checks to allow inserting parents out of order
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// truncate categories table to start fresh
DB::table('categories')->truncate();
DB::beginTransaction();
try {
$this->info('Inserting categories...');
// helper to walk ancestor chain and find a valid content_type_id
$findAncestorContentType = function ($startRoot) use ($map) {
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$ancestor = $map[$cur];
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
if (!empty($candidate)) {
// verify the content type exists in DB
if (DB::table('content_types')->where('id', $candidate)->exists()) {
return $candidate;
}
}
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
}
return null;
};
// helper to collect ancestor chain rows for debugging dump
$collectAncestorChain = function ($startRoot) use ($map) {
$chain = [];
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$r = $map[$cur];
$chain[] = [
'id' => $cur,
'name' => $r[1] ?? null,
'section_id' => isset($r[7]) ? (int)$r[7] : null,
'rootid' => isset($r[8]) ? (int)$r[8] : null,
'raw' => $r,
];
$cur = isset($r[8]) ? (int)$r[8] : null;
}
return $chain;
};
foreach ($rows as $row) {
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
$origId = isset($row[0]) ? (int)$row[0] : null;
$name = isset($row[1]) ? trim($row[1]) : '';
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
$rootId = isset($row[8]) ? (int)$row[8] : null;
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
$parentId = null;
} else {
// If content_type_id == parent_id then parent_id should be NULL
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
}
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
if (!$ctExists) {
if (!empty($rootId)) {
$resolved = $findAncestorContentType($rootId);
if ($resolved) {
$contentTypeId = $resolved;
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
}
}
}
// after resolution, verify content_type exists
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
$dump = [
'row' => $row,
'resolved_root' => $rootId,
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
];
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
}
$slug = Str::slug($name) ?: 'cat-' . $origId;
// ensure uniqueness for slug within content_type
$existing = DB::table('categories')
->where('content_type_id', $contentTypeId)
->where('slug', $slug)
->exists();
if ($existing) {
$slug = $slug . '-' . $origId;
}
$now = now();
$data = [
'id' => $origId,
'content_type_id' => $contentTypeId,
'parent_id' => $parentId,
'name' => $name,
'slug' => $slug,
'description' => $description,
'image' => $image,
'is_active' => true,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
];
DB::table('categories')->insert($data);
$this->line("Inserted category {$origId}: {$name}");
}
DB::commit();
$this->info('Import completed.');
return 0;
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Import failed: ' . $e->getMessage());
return 1;
} finally {
// re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
*
* Usage:
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
*/
class ImportLegacyArtworks extends Command
{
protected $signature = 'skinbase:import-legacy-artworks
{--chunk=500 : chunk size for processing}
{--limit= : maximum number of legacy rows to import}
{--dry-run : do not persist any changes}
{--legacy-connection=legacy : name of legacy DB connection}
{--legacy-table=wallz : legacy artworks table name}
{--connected-table=connected : legacy artwork->category table}
';
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
private function coerceUnsignedInt(mixed $value, int $default = 0): int
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 1 : 0;
}
if (is_int($value)) {
return max(0, $value);
}
if (is_float($value)) {
return max(0, (int) $value);
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return $default;
}
if (is_numeric($trimmed)) {
return max(0, (int) $trimmed);
}
}
return $default;
}
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$stringValue = trim((string) $value);
return $stringValue !== '' ? $stringValue : $default;
}
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$connectedTable = $this->option('connected-table');
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
$processed = 0;
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
foreach ($rows as $row) {
if ($limit !== null && $processed >= $limit) {
return false; // stop chunking
}
$legacyId = $row->id ?? null;
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
$description = $row->description ?? $row->desc ?? null;
$slugBase = Str::slug(substr((string) $title, 0, 120));
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
$publishedAt = null;
if (! empty($row->datum)) {
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
} elseif (! empty($row->created_at)) {
$publishedAt = $row->created_at;
}
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
$fileName = null;
$filePath = '';
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
$fileName = trim((string) $rawFileName);
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
}
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
$mime = $row->mimetype ?? $row->mime ?? null;
if (empty($mime) && $fileName) {
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
$map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'zip' => 'application/zip',
'pdf' => 'application/pdf',
];
$mime = $map[$ext] ?? null;
}
if (empty($mime)) {
$mime = 'application/octet-stream';
}
$data = [
'id' => $row->id ?? null,
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
'user_id' => $row->user_id ?? 1,
'title' => (string) $title,
'slug' => (string) $slug,
'description' => $description,
'file_name' => $fileName,
// ensure non-null file_path to satisfy NOT NULL DB constraints
'file_path' => $filePath ?? '',
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
'mime_type' => $mime,
'width' => $row->width ?? null,
'height' => $row->height ?? null,
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
'published_at' => $publishedAt,
];
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
if ($dryRun) {
$processed++;
continue;
}
try {
$art = null;
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
// create artwork (guard against unique slug collisions)
$baseSlug = $data['slug'];
$attempt = 0;
$slug = $baseSlug;
while (Artwork::where('slug', $slug)->exists()) {
$attempt++;
$slug = $baseSlug . '-' . $attempt;
}
$data['slug'] = $slug;
// Preserve legacy primary ID if available and safe to do so.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
if (Artwork::where('id', $preserveId)->exists()) {
// Avoid overwriting an existing artwork with the same id.
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
}
$data['id'] = $preserveId;
}
// If we need to preserve the legacy primary id, perform a raw insert
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
if (! empty($data['id'])) {
$insert = $data;
$ts = date('Y-m-d H:i:s');
if (! array_key_exists('created_at', $insert)) {
$insert['created_at'] = $ts;
}
if (! array_key_exists('updated_at', $insert)) {
$insert['updated_at'] = $ts;
}
DB::table('artworks')->insert($insert);
$art = Artwork::find($insert['id']);
} else {
$art = Artwork::create($data);
}
// attach categories if connected table exists
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
$rows = DB::connection($legacyConn)->table($connectedTable)
->where(function ($q) use ($legacyId) {
$q->where('wallz_id', $legacyId)
->orWhere('art_id', $legacyId)
->orWhere('item_id', $legacyId);
})->get();
$categoryIds = [];
foreach ($rows as $r) {
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
if ($cid) {
// try to find matching Category in new DB by id or slug
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
$categoryIds[] = (int) $cid;
} else {
// maybe legacy stores slug
$cat = \App\Models\Category::where('slug', $cid)->first();
if ($cat) {
$categoryIds[] = $cat->id;
}
}
}
}
if (! empty($categoryIds)) {
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
}
}
});
// Post-insert verification: if we attempted to preserve the legacy id,
// confirm the row exists with that id. Log mapping if preservation failed.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
$exists = Artwork::where('id', $preserveId)->exists();
if (! $exists) {
// If $art was created but with a different id, log mapping for manual reconciliation
if ($art instanceof Artwork) {
Log::warning('Imported legacy artwork but failed to preserve id', [
'legacy_id' => $preserveId,
'created_id' => $art->id,
'slug' => $art->slug ?? null,
]);
} else {
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
}
}
}
$processed++;
} catch (Throwable $e) {
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
Log::error('ImportLegacyArtworks error', [
'legacy_id' => $legacyId,
'error' => $e->getMessage(),
'data' => $data ?? null,
'trace' => $e->getTraceAsString(),
]);
}
}
return null;
}, 'id');
$this->info('Import complete. Processed: ' . $processed);
return 0;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected array $usedUsernames = [];
protected array $usedEmails = [];
public function handle(): int
{
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
$chunk = (int) $this->option('chunk');
$imported = 0;
$skipped = 0;
if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE;
}
DB::connection('legacy')
->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
$ids = $rows->pluck('user_id')->all();
$stats = DB::connection('legacy')
->table('users_statistics')
->whereIn('user_id', $ids)
->get()
->keyBy('user_id');
foreach ($rows as $row) {
try {
$this->importRow($row, $stats[$row->user_id] ?? null);
$imported++;
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
}
}
}, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}");
return self::SUCCESS;
}
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$username = $this->uniqueUsername($baseUsername);
$email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null;
// Optionally force-reset every imported user's password to a secure random value.
if ($this->option('force-reset-all')) {
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
$passwordHash = Hash::make(Str::random(64));
} else {
// Force-reset known weak default passwords (e.g. "abc123").
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
$passwordHash = Hash::make(Str::random(64));
} else {
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
}
}
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => $passwordHash,
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null,
'created_at' => $row->joinDate ?: $now,
'updated_at' => $now,
]);
DB::table('user_profiles')->insert([
'user_id' => $legacyId,
'bio' => $row->about_me ?: $row->description ?: null,
'avatar' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null,
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'website' => $row->web ?: null,
'created_at' => $now,
'updated_at' => $now,
]);
if (!empty($row->web)) {
DB::table('user_social_links')->insert([
'user_id' => $legacyId,
'platform' => 'website',
'url' => $row->web,
'created_at' => $now,
'updated_at' => $now,
]);
}
DB::table('user_statistics')->insert([
'user_id' => $legacyId,
'uploads' => $uploads,
'downloads' => $downloads,
'pageviews' => $pageviews,
'awards' => $awards,
'created_at' => $now,
'updated_at' => $now,
]);
});
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
protected function sanitizeStatValue($value): int
{
$n = is_numeric($value) ? (int) $value : 0;
if ($n < 0) {
return 0;
}
return $n;
}
protected function sanitizeUsername(string $username): string
{
$username = strtolower(trim($username));
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
}
protected function uniqueUsername(string $base): string
{
$name = $base;
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$this->usedUsernames[$name] = $name;
return $name;
}
protected function prepareEmail(?string $legacyEmail, string $username): string
{
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
$baseLocal = $this->sanitizeEmailLocal($username);
$domain = 'users.skinbase.org';
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
$email = $this->uniqueEmail($email, $baseLocal, $domain);
return $email;
}
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
{
$i = 1;
$local = explode('@', $email)[0];
$current = $email;
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
$current = $local . $i . '@' . $domain;
$i++;
}
$this->usedEmails[$current] = $current;
return $current;
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MigrateFeaturedWorks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$this->info('Starting migration from `featured_works` to `artwork_features`');
if ($dryRun) {
$this->info('Running in dry-run mode; no inserts will be performed.');
}
$inserted = 0;
$skipped = 0;
$total = 0;
$startId = (int) $this->option('start-id');
$chunk = (int) $this->option('chunk');
$mapping = [
3 => 10, // Gold -> high priority
2 => 20, // Silver
1 => 30, // Bronze
4 => 50, // Featured
0 => 100 // default
];
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
if ($startId > 0) {
$this->info("Resuming from featured_id >= {$startId}");
$query = $query->where('featured_id', '>=', $startId);
}
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
foreach ($rows as $row) {
$total++;
if ($limit > 0 && $inserted >= $limit) {
return false; // stop chunking
}
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
if ($artworkId <= 0) {
$skipped++;
continue;
}
// Verify artwork exists
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
if (! $exists) {
$skipped++;
continue;
}
// Avoid duplicate active feature for same artwork
$already = DB::table('artwork_features')
->where('artwork_id', $artworkId)
->where('is_active', true)
->exists();
if ($already) {
$skipped++;
continue;
}
// Determine featured_at
$postDate = $row->post_date ?? null;
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
$featuredAt = Carbon::now();
} else {
try {
$featuredAt = Carbon::parse($postDate);
} catch (\Throwable $e) {
$featuredAt = Carbon::now();
}
}
// Map priority from legacy 'type'
$type = isset($row->type) ? (int) $row->type : 0;
$priority = $mapping[$type] ?? 100;
// Validate created_by: only set if a valid user id exists in new users table
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
$createdBy = null;
}
$record = [
'artwork_id' => $artworkId,
'featured_at' => $featuredAt->toDateTimeString(),
'expires_at' => null,
'priority' => $priority,
'label' => null,
'note' => $row->description ?? null,
'is_active' => 1,
'created_by' => $createdBy,
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
if ($dryRun) {
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
$inserted++;
continue;
}
try {
DB::table('artwork_features')->insert($record);
$inserted++;
} catch (\Throwable $e) {
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
$skipped++;
}
}
// Return true to continue, false to stop chunking
return ($limit > 0 && $inserted >= $limit) ? false : true;
}, 'featured_id');
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
return 0;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetAllUserPasswords extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
public function handle(): int
{
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
$this->info('Aborted. No changes were made.');
return 0;
}
$chunk = (int) $this->option('chunk');
$count = DB::table('users')->count();
if ($count === 0) {
$this->info('No users found.');
return 0;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
foreach ($rows as $row) {
DB::table('users')->where('id', $row->id)->update([
'password' => Hash::make(Str::random(64)),
'needs_password_reset' => true,
'updated_at' => now(),
]);
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
$this->info('All user passwords reset and accounts flagged for password reset.');
return 0;
}
}