diff --git a/resources/views/auth/mail/otp.blade.php b/resources/views/auth/mail/otp.blade.php index a10ac3e..10e3b8f 100644 --- a/resources/views/auth/mail/otp.blade.php +++ b/resources/views/auth/mail/otp.blade.php @@ -1,5 +1,5 @@

Hi {{ $name }},

-

Your login code is:

+

{{ $intro }}

{{ $code }}

\ No newline at end of file diff --git a/src/Auth/Mail/OtpMail.php b/src/Auth/Mail/OtpMail.php index 2046f7e..d603735 100644 --- a/src/Auth/Mail/OtpMail.php +++ b/src/Auth/Mail/OtpMail.php @@ -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']; } } diff --git a/src/Auth/Services/OtpService.php b/src/Auth/Services/OtpService.php index 426b340..d5b813f 100644 --- a/src/Auth/Services/OtpService.php +++ b/src/Auth/Services/OtpService.php @@ -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; } diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php index f4a4bbb..8061ac1 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php @@ -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 diff --git a/src/Command/WipeCatalogCommand.php b/src/Command/WipeCatalogCommand.php new file mode 100644 index 0000000..ec1d572 --- /dev/null +++ b/src/Command/WipeCatalogCommand.php @@ -0,0 +1,154 @@ +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(); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index f21ad10..e1c2610 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -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]));