Feature: Adding Wipe Catalog Command for all products

This commit is contained in:
2026-09-22 14:36:57 +03:00
parent c0ae9d8996
commit 050204f063
6 changed files with 211 additions and 7 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<p>Hi {{ $name }},</p>
<p>Your login code is:</p>
<p>{{ $intro }}</p>
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
+29 -2
View File
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The one OTP email template for every use of Auth\Services\OtpService —
* not just admin login. A code confirming a destructive Artisan command
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
* validation mechanism as login, but "Your login code" as the subject
* would be actively misleading for that — the recipient never initiated a
* login. $purpose is a small, fixed set of known keys (see
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
* to 'login' rather than rendering a blank subject/intro.
*/
class OtpMail extends Mailable
{
private const COPY_BY_PURPOSE = [
'login' => [
'subject' => 'Your login code',
'intro' => 'Your login code is:',
],
'wipe-catalog' => [
'subject' => 'Confirm: Wipe Catalog',
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
],
];
public function __construct(
public readonly string $name,
public readonly string $code,
public readonly string $purpose = 'login',
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your login code');
return new Envelope(subject: $this->copy()['subject']);
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.otp');
return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
}
private function copy(): array
{
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
}
}
+8 -2
View File
@@ -11,7 +11,13 @@ class OtpService
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
/**
* $purpose is forwarded as-is to OtpMail, which only recognizes a
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
* value there just falls back to 'login' rather than failing here, so
* this method has nothing of its own to validate.
*/
public function generateAndSend(string $email, string $purpose = 'login'): bool
{
$staff = Staff::where('email', $email)->first();
@@ -25,7 +31,7 @@ class OtpService
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save();
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code));
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
return true;
}
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Exceptions\MissingCurrencyPriceException;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
@@ -47,6 +48,17 @@ class ViewCart extends ViewRecord
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line.
*
* calculate() throws Lunar\Exceptions\MissingCurrencyPriceException
* (vendor PricingManager) the moment ANY line's purchasable has no
* price row for the cart's currency — including a line whose
* purchasable no longer exists at all (a deleted ProductVariant still
* referenced by cart_lines.purchasable_id), which 500'd this whole
* page rather than just leaving that one line unpriced. The Lines
* section below already guards every purchasable-derived field with
* `instanceof ProductVariant` and renders fine with $cart left
* uncalculated — subTotal/total/etc. simply won't be populated, which
* reads as a stale/pending state rather than a broken page.
*/
protected function resolveRecord(int|string $key): Cart
{
@@ -58,7 +70,11 @@ class ViewCart extends ViewRecord
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']);
try {
return $cart->calculate();
} catch (MissingCurrencyPriceException) {
return $cart;
}
}
public function infolist(Schema $schema): Schema
+154
View File
@@ -0,0 +1,154 @@
<?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();
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\ProcessErasureRequestsCommand;
use Modules\Core\Command\TuneProductSearchCommand;
use Modules\Core\Command\WipeCatalogCommand;
class CoreServiceProvider extends ServiceProvider
{
@@ -39,7 +40,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class, WipeCatalogCommand::class]);
//Overriding lunar:install
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));