Feature: Adding Wipe Catalog Command for all products
This commit is contained in:
@@ -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>
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
return $cart->calculate();
|
||||
try {
|
||||
return $cart->calculate();
|
||||
} catch (MissingCurrencyPriceException) {
|
||||
return $cart;
|
||||
}
|
||||
}
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
|
||||
Reference in New Issue
Block a user