2026-09-22 14:36:57 +03:00
<? php
namespace Modules\Core\Command ;
use Illuminate\Console\Command ;
2026-09-24 21:57:31 +03:00
use Lunar\Models\CartLine ;
2026-09-22 14:36:57 +03:00
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
2026-09-24 21:57:31 +03:00
* 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/
2026-09-22 14:36:57 +03:00
* 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
{
2026-09-22 21:17:35 +03:00
// 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 ();
2026-09-22 14:36:57 +03:00
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.
2026-09-22 15:29:03 +03:00
*
* 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
2026-09-24 22:50:41 +03:00
* MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s
2026-09-22 15:29:03 +03:00
* 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.
2026-09-22 14:36:57 +03:00
*/
private function wipe () : void
{
2026-09-22 15:29:03 +03:00
ImportMapping :: whereIn ( 'source_type' , [ 'product' , 'variant' , 'image' ]) -> delete ();
2026-09-24 21:57:31 +03:00
// 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 ();
2026-09-22 15:29:03 +03:00
while ( true ) {
2026-09-22 21:17:35 +03:00
// 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' ])
2026-09-22 15:29:03 +03:00
-> 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 ();
2026-09-22 21:17:35 +03:00
// 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 ();
2026-09-22 14:36:57 +03:00
}
2026-09-22 15:29:03 +03:00
2026-09-22 21:17:35 +03:00
$product -> forceDelete ();
2026-09-22 15:29:03 +03:00
}
}
2026-09-22 14:36:57 +03:00
Product :: removeAllFromSearch ();
}
}