Add storefronts:import-legacy command
One-time importer that copies legacy menu/shop storefronts (and their products, stored inline in qr_codes.payload.content.sections) from the shared platform DB into this app's database, so owners who created them before the Merchant extraction can see/manage them again. - Maps platform owners to local users by public_id (SSO sub), creating a local user stub when missing - Copies product images from a new read-only `platform_qr` disk (PLATFORM_QR_ROOT) to the local `qr` disk - Idempotent (skips existing short_codes unless --refresh); supports --user, --dry-run, --skip-images Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a939abc2de
commit
37425f5ad1
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* One-time importer: copy legacy menu/shop storefronts (and their products,
|
||||
* which live inline in qr_codes.payload.content.sections) from the shared
|
||||
* platform database (ladilldb, the `platform` connection) into this app's
|
||||
* own database, so owners who created them before the Merchant extraction
|
||||
* can see and manage them again.
|
||||
*
|
||||
* Owners are matched to local users by their platform public_id (the OIDC
|
||||
* `sub` used at SSO login); a local user stub is created when missing.
|
||||
* Product images are copied from the `platform_qr` disk to the local `qr`
|
||||
* disk (set PLATFORM_QR_ROOT to the monolith's storage/app/private/qr).
|
||||
*
|
||||
* Idempotent: existing local codes (matched by short_code) are skipped
|
||||
* unless --refresh is given.
|
||||
*/
|
||||
class ImportLegacyStorefronts extends Command
|
||||
{
|
||||
protected $signature = 'storefronts:import-legacy
|
||||
{--user= : Only import for this owner (platform public_id or email)}
|
||||
{--refresh : Overwrite local codes that already exist (matched by short_code)}
|
||||
{--skip-images : Do not copy image files, only the database rows}
|
||||
{--dry-run : Report what would happen without writing anything}';
|
||||
|
||||
protected $description = 'Import legacy menu/shop storefronts and their products from the platform database.';
|
||||
|
||||
/** Storefront QR types that hold products in their payload. */
|
||||
private array $types = ['shop', 'menu'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dry = (bool) $this->option('dry-run');
|
||||
$refresh = (bool) $this->option('refresh');
|
||||
$skipImages = (bool) $this->option('skip-images');
|
||||
|
||||
$platform = DB::connection('platform');
|
||||
|
||||
$query = $platform->table('qr_codes')->whereIn('type', $this->types);
|
||||
|
||||
if ($owner = $this->option('user')) {
|
||||
$platformOwner = $platform->table('users')
|
||||
->where('public_id', $owner)
|
||||
->orWhere('email', $owner)
|
||||
->first();
|
||||
|
||||
if (! $platformOwner) {
|
||||
$this->error("No platform user matches '{$owner}'.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query->where('user_id', $platformOwner->id);
|
||||
}
|
||||
|
||||
$rows = $query->orderBy('id')->get();
|
||||
$this->info("Found {$rows->count()} legacy storefront code(s) on the platform.");
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$platformUsers = $platform->table('users')
|
||||
->whereIn('id', $rows->pluck('user_id')->unique()->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$usersCreated = 0;
|
||||
$imagesCopied = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$platformUser = $platformUsers->get($row->user_id);
|
||||
|
||||
if (! $platformUser) {
|
||||
$this->warn(" {$row->short_code}: platform owner #{$row->user_id} not found — skipped.");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$localUser = User::query()
|
||||
->when($platformUser->public_id, fn ($q) => $q->where('public_id', $platformUser->public_id))
|
||||
->when(! $platformUser->public_id, fn ($q) => $q->where('email', $platformUser->email))
|
||||
->first();
|
||||
|
||||
if (! $localUser && $platformUser->public_id) {
|
||||
$localUser = User::where('email', $platformUser->email)->first();
|
||||
}
|
||||
|
||||
if (! $localUser) {
|
||||
if (! $platformUser->public_id) {
|
||||
$this->warn(" {$row->short_code}: owner has no public_id and no local match — skipped.");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
$this->line(" would create local user {$platformUser->email}");
|
||||
} else {
|
||||
$localUser = User::create([
|
||||
'public_id' => $platformUser->public_id,
|
||||
'name' => $platformUser->name,
|
||||
'email' => $platformUser->email ?: $platformUser->public_id.'@users.ladill.com',
|
||||
'avatar_url' => $platformUser->avatar_url ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$usersCreated++;
|
||||
}
|
||||
|
||||
$existing = DB::table('qr_codes')->where('short_code', $row->short_code)->first();
|
||||
|
||||
if ($existing && ! $refresh) {
|
||||
$skipped++;
|
||||
$this->line(" {$row->short_code}: already exists — skipped.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
$this->line(" would import {$row->short_code} ({$row->type}) for {$platformUser->email}");
|
||||
$imported++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$attrs = [
|
||||
'user_id' => $localUser->id,
|
||||
'short_code' => $row->short_code,
|
||||
'type' => $row->type,
|
||||
'label' => $row->label,
|
||||
'destination_url' => $row->destination_url,
|
||||
'qr_document_id' => null,
|
||||
'payload' => $row->payload,
|
||||
'is_active' => $row->is_active,
|
||||
'png_path' => $row->png_path,
|
||||
'svg_path' => $row->svg_path,
|
||||
'scans_total' => $row->scans_total ?? 0,
|
||||
'unique_scans_total' => $row->unique_scans_total ?? 0,
|
||||
'last_scanned_at' => $row->last_scanned_at,
|
||||
'destination_updated_at' => $row->destination_updated_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
DB::table('qr_codes')->where('id', $existing->id)->update($attrs);
|
||||
} else {
|
||||
DB::table('qr_codes')->insert($attrs);
|
||||
}
|
||||
|
||||
$imported++;
|
||||
|
||||
if (! $skipImages) {
|
||||
$imagesCopied += $this->copyImages($row->png_path, $row->svg_path, $row->payload);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Imported: {$imported} · Skipped: {$skipped} · Local users created: {$usersCreated} · Images copied: {$imagesCopied} · Failed: {$failed}");
|
||||
|
||||
if ($dry) {
|
||||
$this->comment('Dry run — no changes were written.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a code's image files (QR PNG/SVG + every *_path / image_path in the
|
||||
* payload, e.g. logo_path, cover_path, sections[][items][].image_path) from
|
||||
* the platform QR storage to the local QR disk. Best-effort.
|
||||
*/
|
||||
private function copyImages(?string $png, ?string $svg, ?string $payloadJson): int
|
||||
{
|
||||
$paths = [$png, $svg];
|
||||
$this->collectPaths(json_decode((string) $payloadJson, true) ?: [], $paths);
|
||||
$paths = array_values(array_unique(array_filter($paths, fn ($p) => is_string($p) && $p !== '')));
|
||||
|
||||
$src = Storage::disk('platform_qr');
|
||||
$dst = Storage::disk('qr');
|
||||
$copied = 0;
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if ($dst->exists($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $src->exists($path)) {
|
||||
$this->warn(" image missing on source: {$path}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dst->put($path, $src->get($path));
|
||||
$copied++;
|
||||
}
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param array<int, mixed> $paths
|
||||
*/
|
||||
private function collectPaths(array $data, array &$paths): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$this->collectPaths($value, $paths);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '' && is_string($key)
|
||||
&& (str_ends_with($key, '_path') || $key === 'path')) {
|
||||
$paths[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,16 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
// Read-only view of the monolith's QR storage, used by the
|
||||
// storefronts:import-legacy command to copy legacy product images.
|
||||
// Set PLATFORM_QR_ROOT to the monolith's storage/app/private/qr path.
|
||||
'platform_qr' => [
|
||||
'driver' => 'local',
|
||||
'root' => env('PLATFORM_QR_ROOT', storage_path('app/private/qr')),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user