214 lines
9.7 KiB
PHP
214 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Command;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Lunar\Models\CartLine;
|
|
use Lunar\Models\Product;
|
|
use Modules\Core\Auth\Models\Staff;
|
|
use Modules\Core\Auth\Services\OtpService;
|
|
use Modules\Core\MigrateImport\Models\ImportMapping;
|
|
|
|
use function Laravel\Prompts\password;
|
|
use function Laravel\Prompts\text;
|
|
|
|
/**
|
|
* Irreversibly deletes every Product and everything that only exists
|
|
* because of a product — variants, variant prices, product-option value
|
|
* assignments, product images/media, product associations, the
|
|
* ImportMapping rows tying them back to an external source, product-
|
|
* variant CartLine rows (line items only — Cart records themselves are
|
|
* left alone), and the Meilisearch product index. Deliberately does NOT
|
|
* touch catalog STRUCTURE other products could still reference: ProductOption/
|
|
* ProductOptionValue definitions ("Size", "Color" as reusable option
|
|
* types), Brands, Collections, Tags, Customer Groups — none of those are
|
|
* products, they're config a merchant would otherwise have to rebuild
|
|
* from scratch.
|
|
*
|
|
* Two gates a destructive, whole-catalog, irreversible operation
|
|
* warrants — deliberately NOT restricted to non-production on top of
|
|
* these; a real, legitimate use case is wiping a client's demo/seed
|
|
* catalog on a production database right before real launch, and the OTP
|
|
* below already proves the operator has real staff access, not just
|
|
* shell access to wherever `php artisan` happens to be runnable:
|
|
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
|
|
* OtpService — the exact mechanism admin login already uses).
|
|
* 2. Typing the literal product count back, not just "yes" — a plain
|
|
* confirm() is too easy to reflexively accept; forcing the operator
|
|
* to read and retype the actual number they're about to delete is a
|
|
* last check against running this against the wrong environment/
|
|
* database by mistake.
|
|
*
|
|
* Deletes via Eloquent model instances, not DB::table()->delete() —
|
|
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
|
|
* Traits\HasMedia), which only cleans up media files/rows on a real model
|
|
* `deleted` event, never on a raw query-builder delete.
|
|
*/
|
|
class WipeCatalogCommand extends Command
|
|
{
|
|
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
|
|
|
|
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
|
|
|
|
public function handle(OtpService $otp): int
|
|
{
|
|
// withTrashed() — a prior soft-delete-only bug in this command
|
|
// (fixed in wipe() below) could leave ghost rows a plain count()
|
|
// would never see, silently reporting "nothing to do" while they
|
|
// sit there breaking other things (e.g. the admin's own global
|
|
// search, which assumes every returned product has variants).
|
|
$productCount = Product::withTrashed()->count();
|
|
|
|
if ($productCount === 0) {
|
|
$this->info('No products exist — nothing to do.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if (! $this->authorize($otp)) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
|
|
|
|
$typed = text(label: "Type the product count ({$productCount}) to confirm");
|
|
|
|
if ($typed !== (string) $productCount) {
|
|
$this->error('Count did not match — aborted, nothing was deleted.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->wipe();
|
|
|
|
$this->info("Deleted {$productCount} product(s) and all related data.");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function authorize(OtpService $otp): bool
|
|
{
|
|
$email = $this->option('email') ?? text(
|
|
label: 'Staff email to send a confirmation code to',
|
|
validate: fn (string $value) => Staff::where('email', $value)->exists()
|
|
? null
|
|
: 'No staff account with that email exists.',
|
|
);
|
|
|
|
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
|
|
$this->error('Could not send a confirmation code to that email.');
|
|
|
|
return false;
|
|
}
|
|
|
|
$this->info("A confirmation code was sent to {$email}.");
|
|
|
|
$code = password(label: 'Enter the confirmation code');
|
|
|
|
if ($otp->validate($email, $code) === null) {
|
|
$this->error('Invalid or expired code — aborted, nothing was deleted.');
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Every step below goes through a real Eloquent relation, never a raw
|
|
* table name — Lunar's own table prefix is configurable
|
|
* (config('lunar.database.table_prefix'), applied in BaseModel's
|
|
* constructor), so a hardcoded 'lunar_...' string would silently
|
|
* no-op on an install using a different one.
|
|
*
|
|
* Order matters: product_associations and the product/product_option
|
|
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
|
|
* RESTRICT, Laravel's own default), so they're detached before the
|
|
* product/variant rows they reference — deleting a product that
|
|
* still has either would throw. ProductVariant's own `prices` (a
|
|
* plain morph, HasPrices trait — no FK constraint at all) would
|
|
* otherwise silently orphan rather than throw, so it's cleared the
|
|
* same way regardless. media_variant and product_option_value_
|
|
* product_variant DO cascade at the DB level (see their own
|
|
* migrations), so deleting the variant itself is enough for those two.
|
|
*
|
|
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
|
|
* every iteration, but deleting rows inside the loop shrinks the
|
|
* table out from under it: any product whose id fell in a range
|
|
* chunkById() had already stepped past could be silently skipped and
|
|
* never actually deleted at all. Caught in practice — the first real
|
|
* run of this command left orphaned Media rows (Spatie's own
|
|
* deleteAllMedia(), fired from Product's `deleting` event, never ran
|
|
* for the skipped products) whose 'image' ImportMapping rows then
|
|
* caused a LATER Shopify re-import to silently reuse those now-
|
|
* orphaned Media objects instead of importing fresh ones — see
|
|
* MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s
|
|
* own docblock for that half of the same incident. Always re-querying
|
|
* the first N remaining rows (never advancing an id cursor) guarantees
|
|
* every product is actually visited exactly once, however many are
|
|
* deleted out from under the query as it goes.
|
|
*/
|
|
private function wipe(): void
|
|
{
|
|
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
|
|
|
|
// Only the line items — not the parent Cart rows. This command is
|
|
// meant for early-stage/setup use where no real customer carts
|
|
// matter yet, but a customer's Cart record also anchors their
|
|
// session/coupon/address state; deleting it outright is more than
|
|
// "the catalog is gone" calls for. Leaving every variant a cart
|
|
// line could reference about to be force-deleted below would
|
|
// otherwise reproduce the exact storefront crash this step exists
|
|
// to prevent: CartLine::purchasable() resolves to null,
|
|
// PricingManager::for() throws a TypeError on every page load that
|
|
// renders the cart drawer.
|
|
CartLine::where('purchasable_type', 'product_variant')->delete();
|
|
|
|
while (true) {
|
|
// withTrashed(): Product/ProductVariant both use SoftDeletes
|
|
// — a plain query would stop seeing a product the moment
|
|
// forceDelete() below actually removes it, which is fine, but
|
|
// WITHOUT withTrashed() here this loop would never even
|
|
// fetch a row that a previous, buggy run of this command
|
|
// (or any other code) had already soft-deleted without
|
|
// force-deleting it. Ghost rows like that are exactly what
|
|
// this command exists to remove.
|
|
$products = Product::withTrashed()
|
|
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
|
|
->limit(100)
|
|
->get();
|
|
|
|
if ($products->isEmpty()) {
|
|
break;
|
|
}
|
|
|
|
foreach ($products as $product) {
|
|
$product->associations()->delete();
|
|
$product->inverseAssociations()->delete();
|
|
$product->productOptions()->detach();
|
|
|
|
foreach ($product->variants as $variant) {
|
|
$variant->prices()->delete();
|
|
// NOT delete() — Product/ProductVariant both use
|
|
// SoftDeletes, and a plain delete() only sets
|
|
// deleted_at, leaving the row (and, for Product, its
|
|
// media) sitting in the table. This command's whole
|
|
// purpose is an irreversible wipe; a soft-deleted
|
|
// ghost row is the opposite of that. Caught in
|
|
// practice — a prior run's plain delete() left 185
|
|
// ghost Product rows with zero real variants, which
|
|
// then crashed the admin's own global search
|
|
// (Lunar\Admin\Filament\Resources\ProductResource::
|
|
// getGlobalSearchResultDetails() assumes
|
|
// $record->variants->first() is never null).
|
|
$variant->forceDelete();
|
|
}
|
|
|
|
$product->forceDelete();
|
|
}
|
|
}
|
|
|
|
Product::removeAllFromSearch();
|
|
}
|
|
}
|