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:
isaacclad
2026-06-10 17:06:49 +00:00
co-authored by Claude Opus 4.8
parent a939abc2de
commit 37425f5ad1
2 changed files with 242 additions and 0 deletions
@@ -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;
}
}
}
}
+10
View File
@@ -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,
],
],
/*