155 lines
6.2 KiB
PHP
155 lines
6.2 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace Modules\Core\Command;
|
||
|
|
|
||
|
|
use Illuminate\Console\Command;
|
||
|
|
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, 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
|
||
|
|
{
|
||
|
|
$productCount = Product::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.
|
||
|
|
*/
|
||
|
|
private function wipe(): void
|
||
|
|
{
|
||
|
|
ImportMapping::where('source_type', 'product')->delete();
|
||
|
|
ImportMapping::where('source_type', 'variant')->delete();
|
||
|
|
|
||
|
|
// Model-by-model, not a bulk query — see class docblock on why
|
||
|
|
// this must go through Eloquent for Spatie's media cleanup to
|
||
|
|
// fire on both Product and ProductVariant.
|
||
|
|
Product::with(['variants', 'associations', 'inverseAssociations'])
|
||
|
|
->chunkById(100, function ($products) {
|
||
|
|
foreach ($products as $product) {
|
||
|
|
$product->associations()->delete();
|
||
|
|
$product->inverseAssociations()->delete();
|
||
|
|
$product->productOptions()->detach();
|
||
|
|
|
||
|
|
foreach ($product->variants as $variant) {
|
||
|
|
$variant->prices()->delete();
|
||
|
|
$variant->delete();
|
||
|
|
}
|
||
|
|
|
||
|
|
$product->delete();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
Product::removeAllFromSearch();
|
||
|
|
}
|
||
|
|
}
|