# Layer 6: Notifications Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Wire Laravel Notifications (email via Resend + in-app inbox via the built-in `notifications` morph table) onto the domain events dispatched in Layer 5, plus a minimal buyer inbox UI (bell + dropdown + page).

**Architecture:** One `Notification` class per (recipient × event kind), one thin listener per (recipient × event), preference-gated `via()` returning `['mail', 'database']`. Orders/Shipping categories are always-on (transactional); admin `PurchaseDisputed` bypasses prefs. No push, no broadcasting, no dedup table.

**Tech Stack:** Laravel 11 Notifications, Resend mail transport, Mailpit for local preview, Spatie Permission for admin role lookup, Next.js 15 App Router + SWR for frontend, `@alqove/api-client` + generated `@alqove/types` for contract-first wiring.

**Spec:** `Alqove/docs/superpowers/specs/2026-04-21-layer-6-notifications-design.md`. **Prerequisites:** Layer 5 merged (`acb8a0a`). Existing: `NotificationPreference` model, `NotificationChannel` + `NotificationCategory` enums, all 12 events already dispatched, `Store::owner()` relation, User uses `Notifiable` trait, Resend package installed.

---

## Task 1: Create `notifications` table migration

**Files:**
- Create: `api/database/migrations/2026_04_21_000001_create_notifications_table.php`
- Test: `api/tests/Feature/Notifications/NotificationsTableTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Feature/Notifications/NotificationsTableTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;

class NotificationsTableTest extends TestCase
{
    use RefreshDatabase;

    public function test_notifications_table_supports_uuid_morph_to_user(): void
    {
        $user = User::factory()->create();

        DB::table('notifications')->insert([
            'id' => Str::uuid()->toString(),
            'type' => 'App\\Modules\\Notifications\\Notifications\\BuyerOrderShippedNotification',
            'notifiable_type' => User::class,
            'notifiable_id' => $user->id,
            'data' => json_encode(['title' => 'Shipped', 'body' => 'Order #A7F4']),
            'read_at' => null,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->assertDatabaseHas('notifications', [
            'notifiable_id' => $user->id,
            'type' => 'App\\Modules\\Notifications\\Notifications\\BuyerOrderShippedNotification',
        ]);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationsTableTest`
Expected: FAIL — "table notifications does not exist" or similar SQL error.

- [ ] **Step 3: Write the migration**

Laravel's `notifications:table` generator targets a default `bigIncrements` PK. Our convention is UUID PKs (root `CLAUDE.md`) + UUID morphs (since `users.id` is UUID). Write the migration by hand:

Create `api/database/migrations/2026_04_21_000001_create_notifications_table.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('notifications', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('type');
            $table->uuidMorphs('notifiable');
            $table->text('data');
            $table->timestamp('read_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('notifications');
    }
};
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan migrate && docker compose exec laravel.test php artisan test --filter=NotificationsTableTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add api/database/migrations/2026_04_21_000001_create_notifications_table.php api/tests/Feature/Notifications/NotificationsTableTest.php
git commit -m "feat(notifications): add notifications morph table with UUID PK"
```

---

## Task 2: Add `notificationPreferences` relationship to `User`

**Files:**
- Modify: `api/app/Models/User.php`
- Test: `api/tests/Unit/Models/UserNotificationPreferencesTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Unit/Models/UserNotificationPreferencesTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Models;

use App\Models\NotificationPreference;
use App\Models\User;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\NotificationChannel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserNotificationPreferencesTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_has_notification_preferences_relation(): void
    {
        $user = User::factory()->create();

        NotificationPreference::create([
            'user_id' => $user->id,
            'channel' => NotificationChannel::Email,
            'category' => NotificationCategory::Orders,
            'enabled' => true,
        ]);

        $this->assertCount(1, $user->notificationPreferences);
        $this->assertSame(NotificationChannel::Email, $user->notificationPreferences->first()->channel);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=UserNotificationPreferencesTest`
Expected: FAIL — "Call to undefined relationship [notificationPreferences]".

- [ ] **Step 3: Add relationship to User**

Edit `api/app/Models/User.php`, inside the class:

```php
    public function notificationPreferences(): \Illuminate\Database\Eloquent\Relations\HasMany
    {
        return $this->hasMany(NotificationPreference::class);
    }
```

Add to imports at the top of the file (preserve alphabetical order):

```php
use Illuminate\Database\Eloquent\Relations\HasMany;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=UserNotificationPreferencesTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add api/app/Models/User.php api/tests/Unit/Models/UserNotificationPreferencesTest.php
git commit -m "feat(notifications): add notificationPreferences relation to User"
```

---

## Task 3: `NotificationPreferenceGate` service

**Files:**
- Create: `api/app/Modules/Notifications/Services/NotificationPreferenceGate.php`
- Test: `api/tests/Unit/Notifications/NotificationPreferenceGateTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Unit/Notifications/NotificationPreferenceGateTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Notifications;

use App\Models\NotificationPreference;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\NotificationChannel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class NotificationPreferenceGateTest extends TestCase
{
    use RefreshDatabase;

    public function test_orders_is_transactional_always_returns_all_candidates(): void
    {
        $user = User::factory()->create();

        NotificationPreference::create([
            'user_id' => $user->id,
            'channel' => NotificationChannel::Email,
            'category' => NotificationCategory::Orders,
            'enabled' => false, // even when disabled
        ]);

        $gate = new NotificationPreferenceGate;

        $this->assertSame(
            ['mail', 'database'],
            $gate->channelsFor($user, NotificationCategory::Orders, ['mail', 'database']),
        );
    }

    public function test_shipping_is_transactional_always_returns_all_candidates(): void
    {
        $user = User::factory()->create();

        $gate = new NotificationPreferenceGate;

        $this->assertSame(
            ['mail', 'database'],
            $gate->channelsFor($user, NotificationCategory::Shipping, ['mail', 'database']),
        );
    }

    public function test_non_transactional_honors_preference_rows(): void
    {
        $user = User::factory()->create();

        NotificationPreference::create([
            'user_id' => $user->id,
            'channel' => NotificationChannel::Email,
            'category' => NotificationCategory::Promotions,
            'enabled' => false,
        ]);
        NotificationPreference::create([
            'user_id' => $user->id,
            'channel' => NotificationChannel::Push,
            'category' => NotificationCategory::Promotions,
            'enabled' => true,
        ]);

        $gate = new NotificationPreferenceGate;

        $this->assertSame(
            ['database'],
            $gate->channelsFor($user, NotificationCategory::Promotions, ['mail', 'database']),
        );
    }

    public function test_missing_preference_row_fails_open(): void
    {
        $user = User::factory()->create();
        $gate = new NotificationPreferenceGate;

        $this->assertSame(
            ['mail', 'database'],
            $gate->channelsFor($user, NotificationCategory::Promotions, ['mail', 'database']),
        );
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationPreferenceGateTest`
Expected: FAIL — "Class NotificationPreferenceGate not found".

- [ ] **Step 3: Write the service**

Create `api/app/Modules/Notifications/Services/NotificationPreferenceGate.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Services;

use App\Models\User;
use App\Support\Enums\NotificationCategory;

class NotificationPreferenceGate
{
    /** @var array<int, NotificationCategory> */
    private const TRANSACTIONAL = [
        NotificationCategory::Orders,
        NotificationCategory::Shipping,
    ];

    /**
     * @param  array<int, string>  $candidates
     * @return array<int, string>
     */
    public function channelsFor(User $user, NotificationCategory $category, array $candidates): array
    {
        if (in_array($category, self::TRANSACTIONAL, true)) {
            return $candidates;
        }

        return array_values(array_filter(
            $candidates,
            fn (string $channel) => $this->allows($user, $channel, $category),
        ));
    }

    private function allows(User $user, string $channel, NotificationCategory $category): bool
    {
        $pref = $user->notificationPreferences()
            ->where('channel', $channel)
            ->where('category', $category->value)
            ->first();

        return $pref?->enabled ?? true;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationPreferenceGateTest`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Notifications/Services/NotificationPreferenceGate.php api/tests/Unit/Notifications/NotificationPreferenceGateTest.php
git commit -m "feat(notifications): add NotificationPreferenceGate service"
```

---

## Task 4: `AdminRecipients` service

**Files:**
- Create: `api/app/Modules/Notifications/Services/AdminRecipients.php`
- Test: `api/tests/Unit/Notifications/AdminRecipientsTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Unit/Notifications/AdminRecipientsTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Notifications;

use App\Models\User;
use App\Modules\Notifications\Services\AdminRecipients;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AdminRecipientsTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_returns_only_users_with_admin_role(): void
    {
        $admin1 = User::factory()->create();
        $admin1->assignRole('admin');
        $admin2 = User::factory()->create();
        $admin2->assignRole('admin');
        $buyer = User::factory()->create();
        $buyer->assignRole('buyer');

        $service = new AdminRecipients;
        $admins = $service->all();

        $this->assertCount(2, $admins);
        $this->assertTrue($admins->pluck('id')->contains($admin1->id));
        $this->assertTrue($admins->pluck('id')->contains($admin2->id));
        $this->assertFalse($admins->pluck('id')->contains($buyer->id));
    }

    public function test_returns_empty_when_no_admins(): void
    {
        User::factory()->create()->assignRole('buyer');

        $service = new AdminRecipients;

        $this->assertCount(0, $service->all());
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=AdminRecipientsTest`
Expected: FAIL — "Class AdminRecipients not found".

- [ ] **Step 3: Write the service**

Create `api/app/Modules/Notifications/Services/AdminRecipients.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Services;

use App\Models\User;
use Illuminate\Database\Eloquent\Collection;

class AdminRecipients
{
    /** @return Collection<int, User> */
    public function all(): Collection
    {
        return User::role('admin')->get();
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=AdminRecipientsTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Notifications/Services/AdminRecipients.php api/tests/Unit/Notifications/AdminRecipientsTest.php
git commit -m "feat(notifications): add AdminRecipients service for role-based lookup"
```

---

## Task 5: Default-prefs seeding hook + integrate with Auth registration

**Files:**
- Create: `api/app/Modules/Notifications/Services/SeedDefaultPreferences.php`
- Modify: `api/app/Modules/Auth/Services/AuthService.php`
- Modify: `api/database/seeders/DatabaseSeeder.php` (if it seeds users directly) — verify first
- Test: `api/tests/Feature/Notifications/DefaultPreferencesTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Feature/Notifications/DefaultPreferencesTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use App\Modules\Auth\Services\AuthService;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\NotificationChannel;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DefaultPreferencesTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_registration_seeds_full_pref_grid_enabled(): void
    {
        $service = app(AuthService::class);
        $result = $service->register('Alice', 'alice@example.com', 'secret-pass');

        /** @var User $user */
        $user = $result['user'];
        $prefs = $user->notificationPreferences;

        $expected = count(NotificationChannel::cases()) * count(NotificationCategory::cases());
        $this->assertCount($expected, $prefs);
        $this->assertTrue($prefs->every(fn ($p) => $p->enabled === true));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=DefaultPreferencesTest`
Expected: FAIL — `$prefs` is empty.

- [ ] **Step 3: Write the seeding service**

Create `api/app/Modules/Notifications/Services/SeedDefaultPreferences.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Services;

use App\Models\NotificationPreference;
use App\Models\User;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\NotificationChannel;

class SeedDefaultPreferences
{
    public function forUser(User $user): void
    {
        $rows = [];
        foreach (NotificationChannel::cases() as $channel) {
            foreach (NotificationCategory::cases() as $category) {
                $rows[] = [
                    'user_id' => $user->id,
                    'channel' => $channel->value,
                    'category' => $category->value,
                    'enabled' => true,
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
            }
        }

        NotificationPreference::upsert(
            $rows,
            ['user_id', 'channel', 'category'],
            ['enabled', 'updated_at'],
        );
    }
}
```

- [ ] **Step 4: Hook into `AuthService::register`**

Edit `api/app/Modules/Auth/Services/AuthService.php`:

Add to imports:

```php
use App\Modules\Notifications\Services\SeedDefaultPreferences;
```

Change `register` method body to:

```php
    public function register(string $name, string $email, string $password): array
    {
        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password' => $password,
        ]);

        $user->assignRole('buyer');

        app(SeedDefaultPreferences::class)->forUser($user);

        $token = $user->createToken('auth')->plainTextToken;

        return ['user' => $user, 'token' => $token];
    }
```

Also update `findOrCreateSocialUser` — in the branch that creates a new user, add the same `app(SeedDefaultPreferences::class)->forUser($user);` call directly after `$user->assignRole('buyer');`.

- [ ] **Step 5: Run test to verify it passes**

Run: `docker compose exec laravel.test php artisan test --filter=DefaultPreferencesTest`
Expected: PASS.

- [ ] **Step 6: Re-run Auth test suite to ensure no regression**

Run: `docker compose exec laravel.test php artisan test --filter=Auth`
Expected: All pass.

- [ ] **Step 7: Commit**

```bash
git add api/app/Modules/Notifications/Services/SeedDefaultPreferences.php api/app/Modules/Auth/Services/AuthService.php api/tests/Feature/Notifications/DefaultPreferencesTest.php
git commit -m "feat(notifications): seed default preferences on registration"
```

---

## Task 6: Publish and brand the mail layout

**Files:**
- Create (via publish): `api/resources/views/vendor/mail/**` (several files)
- Modify: `api/resources/views/vendor/mail/html/header.blade.php`
- Modify: `api/resources/views/vendor/mail/html/footer.blade.php`

- [ ] **Step 1: Publish Laravel's default mail layout**

Run:

```bash
docker compose exec laravel.test php artisan vendor:publish --tag=laravel-mail
```

Expected: files appear under `api/resources/views/vendor/mail/html/*` and `api/resources/views/vendor/mail/text/*`.

- [ ] **Step 2: Customize the header**

Overwrite `api/resources/views/vendor/mail/html/header.blade.php` with:

```blade
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (trim($slot) === 'Laravel')
<img src="https://alqove.com/brand/logo-forest.png" class="logo" alt="Alqove Logo">
@else
<span style="font-size:18px;font-weight:600;color:#1d3228;">{{ $slot }}</span>
@endif
</a>
</td>
</tr>
```

- [ ] **Step 3: Customize the footer**

Overwrite `api/resources/views/vendor/mail/html/footer.blade.php` with:

```blade
@props(['url' => 'https://alqove.com'])
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p style="margin:0;font-size:12px;color:#6b7280;">
Questions? Reply directly to this email.
</p>
<p style="margin:8px 0 0;font-size:12px;color:#6b7280;">
&copy; {{ date('Y') }} Alqove. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
```

- [ ] **Step 4: Smoke-test rendering with a trivial mailable preview**

Run (in Tinker):

```bash
docker compose exec laravel.test php artisan tinker --execute="
use Illuminate\Notifications\Messages\MailMessage;
\$msg = (new MailMessage)->subject('Test')->greeting('Hi there!')->line('Smoke test.')->action('Open', 'https://alqove.com');
echo \$msg->render();
" | head -40
```

Expected: HTML output containing the `Alqove` brand header text (not "Laravel").

- [ ] **Step 5: Commit**

```bash
git add api/resources/views/vendor/mail/
git commit -m "chore(notifications): publish and brand mail layout"
```

---

## Task 7: `OrderPaid` notifications (buyer receipt + seller new-order)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderPaidNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/SellerOrderPaidNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendBuyerOrderPaidEmail.php`
- Create: `api/app/Modules/Notifications/Listeners/SendSellerOrderPaidEmail.php`
- Create: `api/resources/views/emails/order/buyer-paid.blade.php`
- Create: `api/resources/views/emails/order/seller-paid.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php` (register two listeners on OrderPaid)
- Test: `api/tests/Feature/Notifications/OrderPaidNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderPaidNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderPaidNotification;
use App\Modules\Notifications\Notifications\SellerOrderPaidNotification;
use App\Modules\Orders\Events\OrderPaid;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderPaidNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_buyer_and_seller_on_order_paid(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $buyer->assignRole('buyer');
        $sellerOwner = User::factory()->create();
        $sellerOwner->assignRole('seller');

        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
        ]);

        OrderPaid::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderPaidNotification::class);
        Notification::assertSentTo($sellerOwner, SellerOrderPaidNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderPaidNotificationTest`
Expected: FAIL — notification classes not found.

- [ ] **Step 3: Write `BuyerOrderPaidNotification`**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderPaidNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderPaidNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $purchase = $this->order->purchase;
        $storeName = $this->order->store?->name ?? 'Alqove';

        return (new MailMessage)
            ->subject("Payment received — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), "{$storeName} via Alqove")
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-paid', [
                'user' => $notifiable,
                'order' => $this->order,
                'purchase' => $purchase,
                'storeName' => $storeName,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$purchase->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Payment received',
            'body' => "Order #{$this->shortId()} from {$this->order->store?->name}",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'check',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write `SellerOrderPaidNotification`**

Create `api/app/Modules/Notifications/Notifications/SellerOrderPaidNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class SellerOrderPaidNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("New order to ship — #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.seller-paid', [
                'user' => $notifiable,
                'order' => $this->order,
                'shipBy' => $this->order->ship_by,
                'ctaUrl' => config('app.frontend_url').'/seller/orders/'.$this->order->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'New order to ship',
            'body' => "Order #{$this->shortId()} is paid and awaiting fulfillment",
            'cta_url' => '/seller/orders/'.$this->order->id,
            'icon' => 'package',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 5: Write the buyer template**

Create `api/resources/views/emails/order/buyer-paid.blade.php`:

```blade
@component('mail::message')
# Payment confirmed

Hi {{ $user->name }},

We received your payment for **Order #{{ strtoupper(substr($order->id, 0, 4)) }}** from **{{ $storeName }}**.

- Total: ${{ number_format($purchase->total / 100, 2) }}
- Estimated ship by: {{ optional($order->ship_by)->format('M j, Y') ?? 'within processing window' }}

You'll get another email when the seller ships. Track your order here:

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 6: Write the seller template**

Create `api/resources/views/emails/order/seller-paid.blade.php`:

```blade
@component('mail::message')
# New order to ship

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** is paid and ready for fulfillment.

- Ship by: **{{ optional($shipBy)->format('M j, Y') ?? 'TBD' }}**
- Items: {{ $order->orderItems->count() }}

@component('mail::button', ['url' => $ctaUrl])
Open Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 7: Write the listeners**

Create `api/app/Modules/Notifications/Listeners/SendBuyerOrderPaidEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderPaidNotification;
use App\Modules\Orders\Events\OrderPaid;

class SendBuyerOrderPaidEmail
{
    public function handle(OrderPaid $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderPaidNotification($event->order));
    }
}
```

Create `api/app/Modules/Notifications/Listeners/SendSellerOrderPaidEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\SellerOrderPaidNotification;
use App\Modules\Orders\Events\OrderPaid;

class SendSellerOrderPaidEmail
{
    public function handle(OrderPaid $event): void
    {
        $owner = $event->order->store?->owner;
        if (! $owner) {
            return;
        }

        $owner->notify(new SellerOrderPaidNotification($event->order));
    }
}
```

- [ ] **Step 8: Register listeners in `EventServiceProvider`**

Edit `api/app/Providers/EventServiceProvider.php`. Add imports:

```php
use App\Modules\Notifications\Listeners\SendBuyerOrderPaidEmail;
use App\Modules\Notifications\Listeners\SendSellerOrderPaidEmail;
```

Extend the `OrderPaid::class` entry in `$listen`:

```php
        OrderPaid::class => [
            ScheduleShipByJobsOnPaid::class,
            SendBuyerOrderPaidEmail::class,
            SendSellerOrderPaidEmail::class,
        ],
```

- [ ] **Step 9: Verify `Purchase` has a `buyer()` relationship; add if missing**

Check: `grep -n "buyer" api/app/Models/Purchase.php`. Expected: a `buyer(): BelongsTo` method returning `User`. If missing, add to `Purchase.php`:

```php
    public function buyer(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(User::class, 'buyer_id');
    }
```

And ensure `use Illuminate\Database\Eloquent\Relations\BelongsTo;` is imported (it already is).

- [ ] **Step 10: Add `support_address` + `frontend_url` config fallbacks**

Edit `api/config/mail.php`, add before the closing `];`:

```php
    'support_address' => env('MAIL_SUPPORT_ADDRESS', 'support@alqove.com'),
```

Verify `api/config/app.php` has a `frontend_url` entry. If not, add:

```php
    'frontend_url' => env('APP_FRONTEND_URL', 'http://localhost:3000'),
```

- [ ] **Step 11: Run the feature test**

Run: `docker compose exec laravel.test php artisan test --filter=OrderPaidNotificationTest`
Expected: PASS.

- [ ] **Step 12: Commit**

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderPaidNotification.php api/app/Modules/Notifications/Notifications/SellerOrderPaidNotification.php api/app/Modules/Notifications/Listeners/SendBuyerOrderPaidEmail.php api/app/Modules/Notifications/Listeners/SendSellerOrderPaidEmail.php api/resources/views/emails/order/buyer-paid.blade.php api/resources/views/emails/order/seller-paid.blade.php api/app/Providers/EventServiceProvider.php api/app/Models/Purchase.php api/config/mail.php api/config/app.php api/tests/Feature/Notifications/OrderPaidNotificationTest.php
git commit -m "feat(notifications): OrderPaid buyer receipt + seller new-order email"
```

---

## Task 8: `OrderShipped` notification (buyer tracking)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderShippedNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendOrderShippedEmail.php`
- Create: `api/resources/views/emails/order/buyer-shipped.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderShippedNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderShippedNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderShippedNotification;
use App\Modules\Orders\Events\OrderShipped;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderShippedNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_buyer_on_order_shipped(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $store = Store::factory()->verified()->create();
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'tracking_number' => '1ZABC12345',
            'carrier' => 'UPS',
            'tracking_url' => 'https://track.example.com/1ZABC12345',
        ]);

        OrderShipped::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderShippedNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderShippedNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write the notification class**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderShippedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderShippedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Shipping,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->order->store?->name ?? 'Alqove';

        return (new MailMessage)
            ->subject("Shipped — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), "{$storeName} via Alqove")
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-shipped', [
                'user' => $notifiable,
                'order' => $this->order,
                'storeName' => $storeName,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$this->order->purchase_id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Your order has shipped',
            'body' => "Order #{$this->shortId()} — tracking available",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'truck',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write the template**

Create `api/resources/views/emails/order/buyer-shipped.blade.php`:

```blade
@component('mail::message')
# Your order is on the way

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** from **{{ $storeName }}** has shipped.

- Carrier: **{{ $order->carrier ?? 'TBD' }}**
- Tracking number: **{{ $order->tracking_number ?? 'available soon' }}**

@if ($order->tracking_url)
@component('mail::button', ['url' => $order->tracking_url])
Track Shipment
@endcomponent
@else
@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent
@endif

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 5: Write the listener**

Create `api/app/Modules/Notifications/Listeners/SendOrderShippedEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderShippedNotification;
use App\Modules\Orders\Events\OrderShipped;

class SendOrderShippedEmail
{
    public function handle(OrderShipped $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderShippedNotification($event->order));
    }
}
```

- [ ] **Step 6: Register in `EventServiceProvider`**

Edit `api/app/Providers/EventServiceProvider.php`. Add import:

```php
use App\Modules\Notifications\Listeners\SendOrderShippedEmail;
```

Extend `OrderShipped::class`:

```php
        OrderShipped::class => [
            TransferFundsToStore::class,
            RecomputePurchaseStatusOnOrderChange::class,
            SendOrderShippedEmail::class,
        ],
```

- [ ] **Step 7: Run the feature test**

Run: `docker compose exec laravel.test php artisan test --filter=OrderShippedNotificationTest`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderShippedNotification.php api/app/Modules/Notifications/Listeners/SendOrderShippedEmail.php api/resources/views/emails/order/buyer-shipped.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderShippedNotificationTest.php
git commit -m "feat(notifications): OrderShipped buyer tracking email"
```

---

## Task 9: `OrderDelivered` notification (buyer confirmation)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderDeliveredNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendOrderDeliveredEmail.php`
- Create: `api/resources/views/emails/order/buyer-delivered.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderDeliveredNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderDeliveredNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderDeliveredNotification;
use App\Modules\Orders\Events\OrderDelivered;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderDeliveredNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_buyer_on_order_delivered(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $store = Store::factory()->verified()->create();
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
        ]);

        OrderDelivered::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderDeliveredNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDeliveredNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write the notification class**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderDeliveredNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderDeliveredNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Shipping,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->order->store?->name ?? 'Alqove';

        return (new MailMessage)
            ->subject("Delivered — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), "{$storeName} via Alqove")
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-delivered', [
                'user' => $notifiable,
                'order' => $this->order,
                'storeName' => $storeName,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$this->order->purchase_id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Order delivered',
            'body' => "Order #{$this->shortId()} has been delivered",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'check',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write the template**

Create `api/resources/views/emails/order/buyer-delivered.blade.php`:

```blade
@component('mail::message')
# Your order has been delivered

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** from **{{ $storeName }}** has been delivered.

If something's not right, reply to this email within 7 days and the seller will help sort it out.

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 5: Write the listener**

Create `api/app/Modules/Notifications/Listeners/SendOrderDeliveredEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderDeliveredNotification;
use App\Modules\Orders\Events\OrderDelivered;

class SendOrderDeliveredEmail
{
    public function handle(OrderDelivered $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderDeliveredNotification($event->order));
    }
}
```

- [ ] **Step 6: Register in `EventServiceProvider`**

Add import + extend `OrderDelivered::class` entry:

```php
use App\Modules\Notifications\Listeners\SendOrderDeliveredEmail;
```

```php
        OrderDelivered::class => [
            RecomputePurchaseStatusOnOrderChange::class,
            SendOrderDeliveredEmail::class,
        ],
```

- [ ] **Step 7: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDeliveredNotificationTest`
Expected: PASS.

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderDeliveredNotification.php api/app/Modules/Notifications/Listeners/SendOrderDeliveredEmail.php api/resources/views/emails/order/buyer-delivered.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderDeliveredNotificationTest.php
git commit -m "feat(notifications): OrderDelivered buyer confirmation email"
```

---

## Task 10: `OrderDelayed` notifications (buyer apology + seller nudge)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderDelayedNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/SellerOrderDelayedNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendBuyerOrderDelayedEmail.php`
- Create: `api/app/Modules/Notifications/Listeners/SendSellerOrderDelayedEmail.php`
- Create: `api/resources/views/emails/order/buyer-delayed.blade.php`
- Create: `api/resources/views/emails/order/seller-delayed.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderDelayedNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderDelayedNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderDelayedNotification;
use App\Modules\Notifications\Notifications\SellerOrderDelayedNotification;
use App\Modules\Orders\Events\OrderDelayed;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderDelayedNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_both_buyer_and_seller_on_order_delayed(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'ship_by' => now()->subDays(3),
        ]);

        OrderDelayed::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderDelayedNotification::class);
        Notification::assertSentTo($sellerOwner, SellerOrderDelayedNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDelayedNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write `BuyerOrderDelayedNotification`**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderDelayedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderDelayedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Shipping,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->order->store?->name ?? 'Alqove';

        return (new MailMessage)
            ->subject("Delivery delayed — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), "{$storeName} via Alqove")
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-delayed', [
                'user' => $notifiable,
                'order' => $this->order,
                'storeName' => $storeName,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$this->order->purchase_id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Your order is delayed',
            'body' => "Order #{$this->shortId()} hasn't shipped yet — we'll keep you posted",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'alert',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write `SellerOrderDelayedNotification`**

Create `api/app/Modules/Notifications/Notifications/SellerOrderDelayedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class SellerOrderDelayedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Past ship-by — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.seller-delayed', [
                'user' => $notifiable,
                'order' => $this->order,
                'ctaUrl' => config('app.frontend_url').'/seller/orders/'.$this->order->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Order past ship-by',
            'body' => "Order #{$this->shortId()} is overdue — auto-cancel in 5 days",
            'cta_url' => '/seller/orders/'.$this->order->id,
            'icon' => 'alert',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 5: Write the two templates**

Create `api/resources/views/emails/order/buyer-delayed.blade.php`:

```blade
@component('mail::message')
# Your order is taking longer than expected

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** from **{{ $storeName }}** is past its expected ship date.

We've pinged the seller. If it doesn't ship within a few more business days, we'll auto-cancel and refund you.

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Thanks for your patience,<br>
Alqove
@endcomponent
```

Create `api/resources/views/emails/order/seller-delayed.blade.php`:

```blade
@component('mail::message')
# Order past ship-by

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** is 2 days past its ship deadline.

Please purchase a label and ship ASAP. If no label is purchased in the next 5 business days, the order will auto-cancel and the buyer will be refunded.

@component('mail::button', ['url' => $ctaUrl])
Open Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 6: Write the listeners**

Create `api/app/Modules/Notifications/Listeners/SendBuyerOrderDelayedEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderDelayedNotification;
use App\Modules\Orders\Events\OrderDelayed;

class SendBuyerOrderDelayedEmail
{
    public function handle(OrderDelayed $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderDelayedNotification($event->order));
    }
}
```

Create `api/app/Modules/Notifications/Listeners/SendSellerOrderDelayedEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\SellerOrderDelayedNotification;
use App\Modules\Orders\Events\OrderDelayed;

class SendSellerOrderDelayedEmail
{
    public function handle(OrderDelayed $event): void
    {
        $owner = $event->order->store?->owner;
        if (! $owner) {
            return;
        }

        $owner->notify(new SellerOrderDelayedNotification($event->order));
    }
}
```

- [ ] **Step 7: Register in `EventServiceProvider`**

Add imports:

```php
use App\Modules\Notifications\Listeners\SendBuyerOrderDelayedEmail;
use App\Modules\Notifications\Listeners\SendSellerOrderDelayedEmail;
use App\Modules\Orders\Events\OrderDelayed;
```

Add entry to `$listen`:

```php
        OrderDelayed::class => [
            SendBuyerOrderDelayedEmail::class,
            SendSellerOrderDelayedEmail::class,
        ],
```

- [ ] **Step 8: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDelayedNotificationTest`
Expected: PASS.

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderDelayedNotification.php api/app/Modules/Notifications/Notifications/SellerOrderDelayedNotification.php api/app/Modules/Notifications/Listeners/SendBuyerOrderDelayedEmail.php api/app/Modules/Notifications/Listeners/SendSellerOrderDelayedEmail.php api/resources/views/emails/order/buyer-delayed.blade.php api/resources/views/emails/order/seller-delayed.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderDelayedNotificationTest.php
git commit -m "feat(notifications): OrderDelayed buyer+seller emails"
```

---

## Task 11: `ShipByReminderDue` notification (seller nudge)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/SellerShipByReminderNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendShipByReminderEmail.php`
- Create: `api/resources/views/emails/order/seller-ship-by-reminder.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/ShipByReminderNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/ShipByReminderNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\SellerShipByReminderNotification;
use App\Modules\Orders\Events\ShipByReminderDue;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class ShipByReminderNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_seller_on_ship_by_reminder_due(): void
    {
        Notification::fake();

        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create();
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Pending,
        ]);

        ShipByReminderDue::dispatch($order);

        Notification::assertSentTo($sellerOwner, SellerShipByReminderNotification::class);
    }

    public function test_does_not_notify_when_order_already_shipped(): void
    {
        Notification::fake();

        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create();
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Shipped,
            'shipped_at' => now(),
        ]);

        ShipByReminderDue::dispatch($order);

        Notification::assertNothingSent();
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=ShipByReminderNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write the notification class**

Create `api/app/Modules/Notifications/Notifications/SellerShipByReminderNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class SellerShipByReminderNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Ship-by approaching — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.seller-ship-by-reminder', [
                'user' => $notifiable,
                'order' => $this->order,
                'shipBy' => $this->order->ship_by,
                'ctaUrl' => config('app.frontend_url').'/seller/orders/'.$this->order->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Ship-by approaching',
            'body' => "Order #{$this->shortId()} — ship by ".optional($this->order->ship_by)->format('M j'),
            'cta_url' => '/seller/orders/'.$this->order->id,
            'icon' => 'alert',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write the template**

Create `api/resources/views/emails/order/seller-ship-by-reminder.blade.php`:

```blade
@component('mail::message')
# Ship-by deadline approaching

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** needs to ship by **{{ optional($shipBy)->format('M j, Y') ?? 'TBD' }}**.

Purchase a label from the order page to stay on track.

@component('mail::button', ['url' => $ctaUrl])
Buy Label
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 5: Write the listener with short-circuit**

Create `api/app/Modules/Notifications/Listeners/SendShipByReminderEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\SellerShipByReminderNotification;
use App\Modules\Orders\Events\ShipByReminderDue;
use App\Support\Enums\OrderStatus;

class SendShipByReminderEmail
{
    public function handle(ShipByReminderDue $event): void
    {
        $order = $event->order->fresh();
        if (! $order || $order->shipped_at !== null || $order->status === OrderStatus::Shipped) {
            return;
        }

        $owner = $order->store?->owner;
        if (! $owner) {
            return;
        }

        $owner->notify(new SellerShipByReminderNotification($order));
    }
}
```

- [ ] **Step 6: Register in `EventServiceProvider`**

Add imports:

```php
use App\Modules\Notifications\Listeners\SendShipByReminderEmail;
use App\Modules\Orders\Events\ShipByReminderDue;
```

Add to `$listen`:

```php
        ShipByReminderDue::class => [
            SendShipByReminderEmail::class,
        ],
```

- [ ] **Step 7: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=ShipByReminderNotificationTest`
Expected: PASS (2 tests).

```bash
git add api/app/Modules/Notifications/Notifications/SellerShipByReminderNotification.php api/app/Modules/Notifications/Listeners/SendShipByReminderEmail.php api/resources/views/emails/order/seller-ship-by-reminder.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/ShipByReminderNotificationTest.php
git commit -m "feat(notifications): ShipByReminderDue seller nudge email"
```

---

## Task 12: `OrderCancelled` notification (counterparty, branching on `cancelled_by`)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/OrderCancelledCounterpartyNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendOrderCancelledEmail.php`
- Create: `api/resources/views/emails/order/cancelled-counterparty.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderCancelledNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderCancelledNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\OrderCancelledCounterpartyNotification;
use App\Modules\Orders\Events\OrderCancelled;
use App\Support\Enums\OrderCancelledBy;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderCancelledNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_buyer_cancel_notifies_seller(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'cancelled_by' => OrderCancelledBy::Buyer,
        ]);

        OrderCancelled::dispatch($order);

        Notification::assertSentTo($sellerOwner, OrderCancelledCounterpartyNotification::class);
        Notification::assertNotSentTo($buyer, OrderCancelledCounterpartyNotification::class);
    }

    public function test_seller_cancel_notifies_buyer(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'cancelled_by' => OrderCancelledBy::Seller,
        ]);

        OrderCancelled::dispatch($order);

        Notification::assertSentTo($buyer, OrderCancelledCounterpartyNotification::class);
        Notification::assertNotSentTo($sellerOwner, OrderCancelledCounterpartyNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderCancelledNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Verify `OrderCancelledBy` enum values**

Run: `grep -n "case " api/app/Support/Enums/OrderCancelledBy.php`
Expected: cases including `Buyer`, `Seller`, possibly `System`. If the enum names differ, adjust imports and comparisons in this task accordingly.

- [ ] **Step 4: Write the notification class**

Create `api/app/Modules/Notifications/Notifications/OrderCancelledCounterpartyNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\OrderCancelledBy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class OrderCancelledCounterpartyNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $isBuyerRecipient = $this->order->cancelled_by === OrderCancelledBy::Seller;

        return (new MailMessage)
            ->subject("Order cancelled — #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.cancelled-counterparty', [
                'user' => $notifiable,
                'order' => $this->order,
                'isBuyerRecipient' => $isBuyerRecipient,
                'reason' => $this->order->cancellation_reason?->value,
                'ctaUrl' => $isBuyerRecipient
                    ? config('app.frontend_url').'/purchases/'.$this->order->purchase_id
                    : config('app.frontend_url').'/seller/orders/'.$this->order->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        $isBuyerRecipient = $this->order->cancelled_by === OrderCancelledBy::Seller;

        return [
            'title' => 'Order cancelled',
            'body' => $isBuyerRecipient
                ? "Order #{$this->shortId()} was cancelled by the seller — refund issued"
                : "Order #{$this->shortId()} was cancelled by the buyer",
            'cta_url' => $isBuyerRecipient
                ? '/purchases/'.$this->order->purchase_id
                : '/seller/orders/'.$this->order->id,
            'icon' => 'x',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 5: Write the branching template**

Create `api/resources/views/emails/order/cancelled-counterparty.blade.php`:

```blade
@component('mail::message')
# Order cancelled

Hi {{ $user->name }},

@if ($isBuyerRecipient)
Your **Order #{{ strtoupper(substr($order->id, 0, 4)) }}** was cancelled by the seller.
A full refund has been issued and should appear on your statement within 5–10 business days.
@if ($reason)
- Reason: {{ str_replace('_', ' ', $reason) }}
@endif
@else
**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** was cancelled by the buyer.
The item has been relisted and is available for other buyers.
@if ($reason)
- Reason: {{ str_replace('_', ' ', $reason) }}
@endif
@endif

@component('mail::button', ['url' => $ctaUrl])
View Details
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 6: Write the branching listener**

Create `api/app/Modules/Notifications/Listeners/SendOrderCancelledEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\OrderCancelledCounterpartyNotification;
use App\Modules\Orders\Events\OrderCancelled;
use App\Support\Enums\OrderCancelledBy;

class SendOrderCancelledEmail
{
    public function handle(OrderCancelled $event): void
    {
        $order = $event->order;

        $recipient = match ($order->cancelled_by) {
            OrderCancelledBy::Buyer => $order->store?->owner,
            OrderCancelledBy::Seller => $order->purchase?->buyer,
            default => null,
        };

        if (! $recipient) {
            return;
        }

        $recipient->notify(new OrderCancelledCounterpartyNotification($order));
    }
}
```

- [ ] **Step 7: Register in `EventServiceProvider`**

Add import:

```php
use App\Modules\Notifications\Listeners\SendOrderCancelledEmail;
```

Extend `OrderCancelled::class`:

```php
        OrderCancelled::class => [
            RecomputePurchaseStatusOnOrderChange::class,
            SendOrderCancelledEmail::class,
        ],
```

- [ ] **Step 8: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=OrderCancelledNotificationTest`
Expected: PASS (2 tests).

```bash
git add api/app/Modules/Notifications/Notifications/OrderCancelledCounterpartyNotification.php api/app/Modules/Notifications/Listeners/SendOrderCancelledEmail.php api/resources/views/emails/order/cancelled-counterparty.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderCancelledNotificationTest.php
git commit -m "feat(notifications): OrderCancelled counterparty email (branches on cancelled_by)"
```

---

## Task 13: `OrderAutoCancelled` notifications (buyer + seller)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderAutoCancelledNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/SellerOrderAutoCancelledNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendBuyerOrderAutoCancelledEmail.php`
- Create: `api/app/Modules/Notifications/Listeners/SendSellerOrderAutoCancelledEmail.php`
- Create: `api/resources/views/emails/order/buyer-auto-cancelled.blade.php`
- Create: `api/resources/views/emails/order/seller-auto-cancelled.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderAutoCancelledNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderAutoCancelledNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderAutoCancelledNotification;
use App\Modules\Notifications\Notifications\SellerOrderAutoCancelledNotification;
use App\Modules\Orders\Events\OrderAutoCancelled;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderAutoCancelledNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_both_buyer_and_seller(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $sellerOwner = User::factory()->create();
        $store = Store::factory()->verified()->create(['owner_user_id' => $sellerOwner->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
        ]);

        OrderAutoCancelled::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderAutoCancelledNotification::class);
        Notification::assertSentTo($sellerOwner, SellerOrderAutoCancelledNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderAutoCancelledNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write `BuyerOrderAutoCancelledNotification`**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderAutoCancelledNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderAutoCancelledNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Order auto-cancelled — #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-auto-cancelled', [
                'user' => $notifiable,
                'order' => $this->order,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$this->order->purchase_id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Order auto-cancelled',
            'body' => "Order #{$this->shortId()} didn't ship in time — you've been refunded",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'x',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write `SellerOrderAutoCancelledNotification`**

Create `api/app/Modules/Notifications/Notifications/SellerOrderAutoCancelledNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class SellerOrderAutoCancelledNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Orders,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Order auto-cancelled & relisted — #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.seller-auto-cancelled', [
                'user' => $notifiable,
                'order' => $this->order,
                'ctaUrl' => config('app.frontend_url').'/seller/orders/'.$this->order->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Order auto-cancelled',
            'body' => "Order #{$this->shortId()} missed its ship deadline — items relisted",
            'cta_url' => '/seller/orders/'.$this->order->id,
            'icon' => 'x',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 5: Write the two templates**

Create `api/resources/views/emails/order/buyer-auto-cancelled.blade.php`:

```blade
@component('mail::message')
# Your order was auto-cancelled

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** didn't ship within the seller's deadline, so we've cancelled it on your behalf and issued a full refund.

Your refund should appear on your statement within 5–10 business days.

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Sorry for the inconvenience,<br>
Alqove
@endcomponent
```

Create `api/resources/views/emails/order/seller-auto-cancelled.blade.php`:

```blade
@component('mail::message')
# Order auto-cancelled

Hi {{ $user->name }},

**Order #{{ strtoupper(substr($order->id, 0, 4)) }}** was auto-cancelled because it missed the ship-by deadline. The buyer has been refunded and the items have been relisted.

To avoid this going forward, please buy labels promptly when new orders come in.

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 6: Write the listeners**

Create `api/app/Modules/Notifications/Listeners/SendBuyerOrderAutoCancelledEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderAutoCancelledNotification;
use App\Modules\Orders\Events\OrderAutoCancelled;

class SendBuyerOrderAutoCancelledEmail
{
    public function handle(OrderAutoCancelled $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderAutoCancelledNotification($event->order));
    }
}
```

Create `api/app/Modules/Notifications/Listeners/SendSellerOrderAutoCancelledEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\SellerOrderAutoCancelledNotification;
use App\Modules\Orders\Events\OrderAutoCancelled;

class SendSellerOrderAutoCancelledEmail
{
    public function handle(OrderAutoCancelled $event): void
    {
        $owner = $event->order->store?->owner;
        if (! $owner) {
            return;
        }

        $owner->notify(new SellerOrderAutoCancelledNotification($event->order));
    }
}
```

- [ ] **Step 7: Register in `EventServiceProvider`**

Add imports:

```php
use App\Modules\Notifications\Listeners\SendBuyerOrderAutoCancelledEmail;
use App\Modules\Notifications\Listeners\SendSellerOrderAutoCancelledEmail;
use App\Modules\Orders\Events\OrderAutoCancelled;
```

Add to `$listen`:

```php
        OrderAutoCancelled::class => [
            SendBuyerOrderAutoCancelledEmail::class,
            SendSellerOrderAutoCancelledEmail::class,
        ],
```

- [ ] **Step 8: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=OrderAutoCancelledNotificationTest`
Expected: PASS.

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderAutoCancelledNotification.php api/app/Modules/Notifications/Notifications/SellerOrderAutoCancelledNotification.php api/app/Modules/Notifications/Listeners/SendBuyerOrderAutoCancelledEmail.php api/app/Modules/Notifications/Listeners/SendSellerOrderAutoCancelledEmail.php api/resources/views/emails/order/buyer-auto-cancelled.blade.php api/resources/views/emails/order/seller-auto-cancelled.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderAutoCancelledNotificationTest.php
git commit -m "feat(notifications): OrderAutoCancelled buyer+seller emails"
```

---

## Task 14: `OrderDeliveryFailed` notification (buyer)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerOrderDeliveryFailedNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendOrderDeliveryFailedEmail.php`
- Create: `api/resources/views/emails/order/buyer-delivery-failed.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/OrderDeliveryFailedNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/OrderDeliveryFailedNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderDeliveryFailedNotification;
use App\Modules\Orders\Events\OrderDeliveryFailed;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderDeliveryFailedNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_notifies_buyer_on_delivery_failed(): void
    {
        Notification::fake();

        $buyer = User::factory()->create();
        $store = Store::factory()->verified()->create();
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
        ]);

        OrderDeliveryFailed::dispatch($order);

        Notification::assertSentTo($buyer, BuyerOrderDeliveryFailedNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDeliveryFailedNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write the notification class**

Create `api/app/Modules/Notifications/Notifications/BuyerOrderDeliveryFailedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Services\NotificationPreferenceGate;
use App\Support\Enums\NotificationCategory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerOrderDeliveryFailedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Order $order) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Shipping,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $storeName = $this->order->store?->name ?? 'Alqove';

        return (new MailMessage)
            ->subject("Delivery issue — Order #{$this->shortId()}")
            ->from(config('mail.from.address'), "{$storeName} via Alqove")
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.order.buyer-delivery-failed', [
                'user' => $notifiable,
                'order' => $this->order,
                'storeName' => $storeName,
                'ctaUrl' => config('app.frontend_url').'/purchases/'.$this->order->purchase_id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Delivery issue',
            'body' => "Order #{$this->shortId()} — delivery failed, action needed",
            'cta_url' => '/purchases/'.$this->order->purchase_id,
            'icon' => 'alert',
            'context_type' => 'order',
            'context_id' => $this->order->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->order->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write the template**

Create `api/resources/views/emails/order/buyer-delivery-failed.blade.php`:

```blade
@component('mail::message')
# There was a problem delivering your order

Hi {{ $user->name }},

Our carrier reported a delivery failure for **Order #{{ strtoupper(substr($order->id, 0, 4)) }}** from **{{ $storeName }}**.

Common causes: wrong address, nobody home, damaged package. Please review the tracking details and contact the seller if you need help.

@component('mail::button', ['url' => $ctaUrl])
View Order
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 5: Write the listener**

Create `api/app/Modules/Notifications/Listeners/SendOrderDeliveryFailedEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\BuyerOrderDeliveryFailedNotification;
use App\Modules\Orders\Events\OrderDeliveryFailed;

class SendOrderDeliveryFailedEmail
{
    public function handle(OrderDeliveryFailed $event): void
    {
        $buyer = $event->order->purchase?->buyer;
        if (! $buyer) {
            return;
        }

        $buyer->notify(new BuyerOrderDeliveryFailedNotification($event->order));
    }
}
```

- [ ] **Step 6: Register in `EventServiceProvider`**

Add import:

```php
use App\Modules\Notifications\Listeners\SendOrderDeliveryFailedEmail;
use App\Modules\Orders\Events\OrderDeliveryFailed;
```

Add to `$listen`:

```php
        OrderDeliveryFailed::class => [
            SendOrderDeliveryFailedEmail::class,
        ],
```

- [ ] **Step 7: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=OrderDeliveryFailedNotificationTest`
Expected: PASS.

```bash
git add api/app/Modules/Notifications/Notifications/BuyerOrderDeliveryFailedNotification.php api/app/Modules/Notifications/Listeners/SendOrderDeliveryFailedEmail.php api/resources/views/emails/order/buyer-delivery-failed.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/OrderDeliveryFailedNotificationTest.php
git commit -m "feat(notifications): OrderDeliveryFailed buyer email"
```

---

## Task 15: `PurchaseDisputed` admin notification (fan-out)

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/AdminPurchaseDisputedNotification.php`
- Create: `api/app/Modules/Notifications/Listeners/SendAdminPurchaseDisputedEmail.php`
- Create: `api/resources/views/emails/admin/purchase-disputed.blade.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Test: `api/tests/Feature/Notifications/PurchaseDisputedNotificationTest.php`

- [ ] **Step 1: Write the failing feature test**

Create `api/tests/Feature/Notifications/PurchaseDisputedNotificationTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Purchase;
use App\Models\User;
use App\Modules\Notifications\Notifications\AdminPurchaseDisputedNotification;
use App\Modules\Orders\Events\PurchaseDisputed;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class PurchaseDisputedNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_fans_out_to_every_admin_user_only(): void
    {
        Notification::fake();

        $admin1 = User::factory()->create();
        $admin1->assignRole('admin');
        $admin2 = User::factory()->create();
        $admin2->assignRole('admin');
        $buyer = User::factory()->create();
        $buyer->assignRole('buyer');

        $purchase = Purchase::factory()->create(['disputed' => true]);

        PurchaseDisputed::dispatch($purchase);

        Notification::assertSentTo($admin1, AdminPurchaseDisputedNotification::class);
        Notification::assertSentTo($admin2, AdminPurchaseDisputedNotification::class);
        Notification::assertNotSentTo($buyer, AdminPurchaseDisputedNotification::class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=PurchaseDisputedNotificationTest`
Expected: FAIL.

- [ ] **Step 3: Write the notification class (bypasses gate)**

Create `api/app/Modules/Notifications/Notifications/AdminPurchaseDisputedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Purchase;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class AdminPurchaseDisputedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Purchase $purchase) {}

    /** @return array<int, string> */
    public function via(User $notifiable): array
    {
        // Admin notifications bypass NotificationPreferenceGate entirely (spec Q5c).
        return ['mail', 'database'];
    }

    public function toMail(User $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Dispute filed — Purchase #{$this->shortId()}")
            ->from(config('mail.from.address'), 'Alqove')
            ->replyTo(config('mail.support_address', 'support@alqove.com'))
            ->markdown('emails.admin.purchase-disputed', [
                'user' => $notifiable,
                'purchase' => $this->purchase,
                'ctaUrl' => config('app.frontend_url').'/admin/disputes/'.$this->purchase->id,
            ]);
    }

    /** @return array<string, mixed> */
    public function toDatabase(User $notifiable): array
    {
        return [
            'title' => 'Dispute filed',
            'body' => "Purchase #{$this->shortId()} — Stripe dispute received",
            'cta_url' => '/admin/disputes/'.$this->purchase->id,
            'icon' => 'alert',
            'context_type' => 'purchase',
            'context_id' => $this->purchase->id,
        ];
    }

    private function shortId(): string
    {
        return strtoupper(substr($this->purchase->id, 0, 4));
    }
}
```

- [ ] **Step 4: Write the template**

Create `api/resources/views/emails/admin/purchase-disputed.blade.php`:

```blade
@component('mail::message')
# Stripe dispute filed

Hi {{ $user->name }},

A payment dispute has been filed on **Purchase #{{ strtoupper(substr($purchase->id, 0, 4)) }}**.

- Total: ${{ number_format($purchase->total / 100, 2) }}
- Buyer ID: {{ $purchase->buyer_id }}
- All pending seller transfers for this purchase are on hold.

Please review in the Stripe dashboard and resolve via the admin console once Layer 8 ships.

@component('mail::button', ['url' => $ctaUrl])
Review Dispute
@endcomponent

Thanks,<br>
Alqove
@endcomponent
```

- [ ] **Step 5: Write the fan-out listener**

Create `api/app/Modules/Notifications/Listeners/SendAdminPurchaseDisputedEmail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Listeners;

use App\Modules\Notifications\Notifications\AdminPurchaseDisputedNotification;
use App\Modules\Notifications\Services\AdminRecipients;
use App\Modules\Orders\Events\PurchaseDisputed;

class SendAdminPurchaseDisputedEmail
{
    public function __construct(private readonly AdminRecipients $adminRecipients) {}

    public function handle(PurchaseDisputed $event): void
    {
        foreach ($this->adminRecipients->all() as $admin) {
            $admin->notify(new AdminPurchaseDisputedNotification($event->purchase));
        }
    }
}
```

- [ ] **Step 6: Register in `EventServiceProvider`**

Add imports:

```php
use App\Modules\Notifications\Listeners\SendAdminPurchaseDisputedEmail;
use App\Modules\Orders\Events\PurchaseDisputed;
```

Add to `$listen`:

```php
        PurchaseDisputed::class => [
            SendAdminPurchaseDisputedEmail::class,
        ],
```

- [ ] **Step 7: Run test, commit**

Run: `docker compose exec laravel.test php artisan test --filter=PurchaseDisputedNotificationTest`
Expected: PASS.

```bash
git add api/app/Modules/Notifications/Notifications/AdminPurchaseDisputedNotification.php api/app/Modules/Notifications/Listeners/SendAdminPurchaseDisputedEmail.php api/resources/views/emails/admin/purchase-disputed.blade.php api/app/Providers/EventServiceProvider.php api/tests/Feature/Notifications/PurchaseDisputedNotificationTest.php
git commit -m "feat(notifications): PurchaseDisputed admin fan-out email"
```

- [ ] **Step 8: Run full test suite to catch regressions**

Run: `docker compose exec laravel.test php artisan test`
Expected: All tests pass (full suite).

---

## Task 16: OpenAPI contract — inbox + preferences endpoints

**Files:**
- Modify: `api/contracts/openapi.yaml`

- [ ] **Step 1: Verify current structure**

Run: `head -60 Alqove/api/contracts/openapi.yaml` to confirm the `paths:` and `components.schemas:` sections and their formatting conventions (indentation, tag-grouping patterns).

- [ ] **Step 2: Add schemas**

Append the following schemas under `components.schemas:` in `api/contracts/openapi.yaml` (alphabetical or after existing `Notification*`-named schemas if any):

```yaml
    NotificationInboxItem:
      type: object
      required:
        - id
        - type
        - title
        - body
        - cta_url
        - icon
        - context_type
        - context_id
        - read_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          description: Short class name (e.g., BuyerOrderShippedNotification)
        title:
          type: string
        body:
          type: string
        cta_url:
          type: string
        icon:
          type: string
          enum: [package, truck, check, alert, x]
        context_type:
          type: string
          enum: [order, purchase]
        context_id:
          type: string
          format: uuid
        read_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    NotificationInboxList:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/NotificationInboxItem'
        meta:
          type: object
          required: [unread_count, current_page, last_page, per_page, total]
          properties:
            unread_count: { type: integer }
            current_page: { type: integer }
            last_page: { type: integer }
            per_page: { type: integer }
            total: { type: integer }

    NotificationUnreadCount:
      type: object
      required: [unread_count]
      properties:
        unread_count:
          type: integer

    NotificationPreference:
      type: object
      required: [channel, category, enabled, is_transactional]
      properties:
        channel:
          type: string
          enum: [email, push]
        category:
          type: string
          enum: [orders, shipping, promotions, price_drops, account]
        enabled:
          type: boolean
        is_transactional:
          type: boolean

    NotificationPreferencesUpdateRequest:
      type: object
      required: [preferences]
      properties:
        preferences:
          type: array
          items:
            type: object
            required: [channel, category, enabled]
            properties:
              channel: { type: string, enum: [email, push] }
              category: { type: string, enum: [orders, shipping, promotions, price_drops, account] }
              enabled: { type: boolean }
```

- [ ] **Step 3: Add path entries**

Append under `paths:` (grouped with a `tags: [Notifications]` entry on each operation):

```yaml
  /v1/me/notifications:
    get:
      tags: [Notifications]
      summary: List notifications for the authenticated user
      parameters:
        - in: query
          name: filter
          schema: { type: string, enum: [all, unread], default: all }
        - in: query
          name: per_page
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: Paginated list of notifications
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NotificationInboxList' }

  /v1/me/notifications/unread-count:
    get:
      tags: [Notifications]
      summary: Unread count for the authenticated user
      responses:
        '200':
          description: Unread count
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/NotificationUnreadCount' }

  /v1/me/notifications/{id}/read:
    patch:
      tags: [Notifications]
      summary: Mark a notification as read
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: No content }
        '404': { description: Not found (or not owned) }

  /v1/me/notifications/mark-all-read:
    post:
      tags: [Notifications]
      summary: Mark all unread notifications as read
      responses:
        '200':
          description: Bulk update result
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [updated]
                    properties:
                      updated: { type: integer }

  /v1/me/notification-preferences:
    get:
      tags: [Notifications]
      summary: List notification preferences for the authenticated user
      responses:
        '200':
          description: Preferences list
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/NotificationPreference' }
    patch:
      tags: [Notifications]
      summary: Update notification preferences
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NotificationPreferencesUpdateRequest' }
      responses:
        '200':
          description: Updated preferences list
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/NotificationPreference' }
        '422':
          description: Validation error (e.g. disabling a transactional category)
```

- [ ] **Step 4: Validate YAML**

Run: `docker compose exec laravel.test php -r "var_dump(yaml_parse_file('/var/www/html/contracts/openapi.yaml') !== false);"` if PHP has `yaml` extension, OR use Python:

```bash
python -c "import yaml; yaml.safe_load(open('Alqove/api/contracts/openapi.yaml'))" && echo OK
```

Expected: `OK` (no parse error).

- [ ] **Step 5: Commit**

```bash
git add api/contracts/openapi.yaml
git commit -m "feat(notifications): openapi schemas + paths for inbox and preferences"
```

---

## Task 17: Regenerate `@alqove/types` + write api-client module

**Files:**
- Run (generated): types under `Alqove/packages/types/`
- Create: `Alqove/packages/api-client/src/endpoints/notifications.ts`
- Modify: `Alqove/packages/api-client/src/index.ts`

- [ ] **Step 1: Regenerate types from OpenAPI**

From repo root:

```bash
npm run build:types
```

Expected: types package updates without errors. If the repo uses a specific path layout for generated types, verify with:

```bash
grep -r "NotificationInboxItem\|NotificationPreference" Alqove/packages/types/src/ | head
```

Expected: matches in generated output.

- [ ] **Step 2: Create the notifications endpoint module**

Create `Alqove/packages/api-client/src/endpoints/notifications.ts`:

```ts
import type { AlqoveClient } from '../client';

export interface NotificationInboxItem {
  id: string;
  type: string;
  title: string;
  body: string;
  cta_url: string;
  icon: 'package' | 'truck' | 'check' | 'alert' | 'x';
  context_type: 'order' | 'purchase';
  context_id: string;
  read_at: string | null;
  created_at: string;
}

export interface NotificationInboxMeta {
  unread_count: number;
  current_page: number;
  last_page: number;
  per_page: number;
  total: number;
}

export interface NotificationInboxList {
  data: NotificationInboxItem[];
  meta: NotificationInboxMeta;
}

export interface NotificationUnreadCount {
  unread_count: number;
}

export interface NotificationPreference {
  channel: 'email' | 'push';
  category: 'orders' | 'shipping' | 'promotions' | 'price_drops' | 'account';
  enabled: boolean;
  is_transactional: boolean;
}

export interface NotificationPreferencesUpdate {
  preferences: Array<Pick<NotificationPreference, 'channel' | 'category' | 'enabled'>>;
}

export function createNotificationEndpoints(client: AlqoveClient) {
  return {
    list(params?: { filter?: 'all' | 'unread'; per_page?: number; page?: number }) {
      return client.get<NotificationInboxList>(
        '/v1/me/notifications',
        params as Record<string, string> | undefined,
      );
    },
    unreadCount() {
      return client.get<{ data: NotificationUnreadCount }>('/v1/me/notifications/unread-count');
    },
    markRead(id: string) {
      return client.patch<void>(`/v1/me/notifications/${id}/read`, {});
    },
    markAllRead() {
      return client.post<{ data: { updated: number } }>(
        '/v1/me/notifications/mark-all-read',
        {},
      );
    },
    listPreferences() {
      return client.get<{ data: NotificationPreference[] }>('/v1/me/notification-preferences');
    },
    updatePreferences(body: NotificationPreferencesUpdate) {
      return client.patch<{ data: NotificationPreference[] }>(
        '/v1/me/notification-preferences',
        body,
      );
    },
  };
}
```

- [ ] **Step 3: Verify client supports `patch` method**

Run: `grep -n "patch" Alqove/packages/api-client/src/client.ts`. Expected: a `patch<T>(...)` method. If missing (only `get`/`post` exist), add it now by editing `client.ts` — see existing `post<T>` implementation and mirror it using `PATCH` verb.

- [ ] **Step 4: Export the endpoints module**

Edit `Alqove/packages/api-client/src/index.ts`. Add under the existing `createXEndpoints` exports:

```ts
export { createNotificationEndpoints } from './endpoints/notifications';
export type {
  NotificationInboxItem,
  NotificationInboxMeta,
  NotificationInboxList,
  NotificationUnreadCount,
  NotificationPreference,
  NotificationPreferencesUpdate,
} from './endpoints/notifications';
```

- [ ] **Step 5: Typecheck the packages**

From repo root: `npm run typecheck`
Expected: no type errors.

- [ ] **Step 6: Commit**

```bash
git add Alqove/packages/api-client/src/endpoints/notifications.ts Alqove/packages/api-client/src/index.ts Alqove/packages/api-client/src/client.ts Alqove/packages/types/
git commit -m "feat(notifications): api-client endpoints and regenerated types"
```

---

## Task 18: `InboxController` + routes + resource

**Files:**
- Create: `api/app/Modules/Notifications/Controllers/InboxController.php`
- Create: `api/app/Modules/Notifications/Resources/InboxItemResource.php`
- Modify: `api/app/Modules/Notifications/routes.php`
- Test: `api/tests/Feature/Notifications/InboxControllerTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Feature/Notifications/InboxControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class InboxControllerTest extends TestCase
{
    use RefreshDatabase;

    private function seedNotification(User $user, bool $read = false): string
    {
        $id = (string) Str::uuid();
        DB::table('notifications')->insert([
            'id' => $id,
            'type' => 'App\\Modules\\Notifications\\Notifications\\BuyerOrderShippedNotification',
            'notifiable_type' => User::class,
            'notifiable_id' => $user->id,
            'data' => json_encode([
                'title' => 'Shipped',
                'body' => 'Order #A7F4',
                'cta_url' => '/purchases/abc',
                'icon' => 'truck',
                'context_type' => 'order',
                'context_id' => 'xxx',
            ]),
            'read_at' => $read ? now() : null,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return $id;
    }

    public function test_list_returns_user_notifications_with_unread_count(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);
        $this->seedNotification($user, read: false);
        $this->seedNotification($user, read: true);

        $res = $this->getJson('/v1/me/notifications');

        $res->assertOk()
            ->assertJsonStructure([
                'data' => [['id', 'type', 'title', 'body', 'cta_url', 'icon', 'context_type', 'context_id', 'read_at', 'created_at']],
                'meta' => ['unread_count', 'current_page', 'last_page', 'per_page', 'total'],
            ])
            ->assertJsonPath('meta.unread_count', 1);
    }

    public function test_filter_unread_returns_only_unread(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);
        $this->seedNotification($user, read: false);
        $this->seedNotification($user, read: true);

        $res = $this->getJson('/v1/me/notifications?filter=unread');

        $res->assertOk()->assertJsonCount(1, 'data');
    }

    public function test_unread_count_endpoint(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);
        $this->seedNotification($user, read: false);
        $this->seedNotification($user, read: false);
        $this->seedNotification($user, read: true);

        $res = $this->getJson('/v1/me/notifications/unread-count');

        $res->assertOk()->assertJsonPath('data.unread_count', 2);
    }

    public function test_mark_read_sets_read_at(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);
        $id = $this->seedNotification($user, read: false);

        $this->patchJson("/v1/me/notifications/{$id}/read")->assertNoContent();

        $this->assertNotNull(DB::table('notifications')->where('id', $id)->value('read_at'));
    }

    public function test_mark_read_cross_user_404s(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $id = $this->seedNotification($alice, read: false);

        Sanctum::actingAs($bob);
        $this->patchJson("/v1/me/notifications/{$id}/read")->assertNotFound();
    }

    public function test_mark_all_read_updates_only_current_user(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $this->seedNotification($alice, read: false);
        $this->seedNotification($alice, read: false);
        $this->seedNotification($bob, read: false);

        Sanctum::actingAs($alice);
        $this->postJson('/v1/me/notifications/mark-all-read')
            ->assertOk()
            ->assertJsonPath('data.updated', 2);

        $this->assertSame(1, DB::table('notifications')->whereNull('read_at')->count());
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=InboxControllerTest`
Expected: FAIL — routes not registered.

- [ ] **Step 3: Write the resource**

Create `api/app/Modules/Notifications/Resources/InboxItemResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class InboxItemResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        $data = is_array($this->data) ? $this->data : json_decode($this->data, true);

        return [
            'id' => $this->id,
            'type' => class_basename($this->type),
            'title' => $data['title'] ?? '',
            'body' => $data['body'] ?? '',
            'cta_url' => $data['cta_url'] ?? '',
            'icon' => $data['icon'] ?? 'alert',
            'context_type' => $data['context_type'] ?? null,
            'context_id' => $data['context_id'] ?? null,
            'read_at' => optional($this->read_at)->toIso8601String(),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
```

- [ ] **Step 4: Write the controller**

Create `api/app/Modules/Notifications/Controllers/InboxController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Controllers;

use App\Models\User;
use App\Modules\Notifications\Resources\InboxItemResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;

class InboxController
{
    public function index(Request $request): JsonResponse
    {
        /** @var User $user */
        $user = $request->user();

        $query = DB::table('notifications')
            ->where('notifiable_type', User::class)
            ->where('notifiable_id', $user->id);

        if ($request->query('filter') === 'unread') {
            $query->whereNull('read_at');
        }

        $perPage = min(50, max(1, (int) $request->query('per_page', 20)));
        $page = max(1, (int) $request->query('page', 1));
        $total = (clone $query)->count();

        $rows = $query->orderByDesc('created_at')
            ->forPage($page, $perPage)
            ->get();

        $collection = $rows->map(fn ($row) => (object) [
            'id' => $row->id,
            'type' => $row->type,
            'data' => $row->data,
            'read_at' => $row->read_at ? now()->parse($row->read_at) : null,
            'created_at' => now()->parse($row->created_at),
        ]);

        $unreadCount = DB::table('notifications')
            ->where('notifiable_type', User::class)
            ->where('notifiable_id', $user->id)
            ->whereNull('read_at')
            ->count();

        return response()->json([
            'data' => InboxItemResource::collection($collection),
            'meta' => [
                'unread_count' => $unreadCount,
                'current_page' => $page,
                'last_page' => (int) max(1, ceil($total / $perPage)),
                'per_page' => $perPage,
                'total' => $total,
            ],
        ]);
    }

    public function unreadCount(Request $request): JsonResponse
    {
        $count = DB::table('notifications')
            ->where('notifiable_type', User::class)
            ->where('notifiable_id', $request->user()->id)
            ->whereNull('read_at')
            ->count();

        return response()->json(['data' => ['unread_count' => $count]]);
    }

    public function markRead(Request $request, string $id): Response
    {
        $affected = DB::table('notifications')
            ->where('id', $id)
            ->where('notifiable_type', User::class)
            ->where('notifiable_id', $request->user()->id)
            ->whereNull('read_at')
            ->update(['read_at' => now(), 'updated_at' => now()]);

        if ($affected === 0) {
            $exists = DB::table('notifications')
                ->where('id', $id)
                ->where('notifiable_type', User::class)
                ->where('notifiable_id', $request->user()->id)
                ->exists();

            if (! $exists) {
                abort(404);
            }
        }

        return response()->noContent();
    }

    public function markAllRead(Request $request): JsonResponse
    {
        $updated = DB::table('notifications')
            ->where('notifiable_type', User::class)
            ->where('notifiable_id', $request->user()->id)
            ->whereNull('read_at')
            ->update(['read_at' => now(), 'updated_at' => now()]);

        return response()->json(['data' => ['updated' => $updated]]);
    }
}
```

- [ ] **Step 5: Register routes in module `routes.php`**

Overwrite `api/app/Modules/Notifications/routes.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Notifications\Controllers\InboxController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me/notifications', [InboxController::class, 'index']);
    Route::get('/me/notifications/unread-count', [InboxController::class, 'unreadCount']);
    Route::patch('/me/notifications/{id}/read', [InboxController::class, 'markRead'])
        ->whereUuid('id');
    Route::post('/me/notifications/mark-all-read', [InboxController::class, 'markAllRead']);
});
```

- [ ] **Step 6: Wire module routes into central API router**

Edit `api/routes/api.php`. In the `Route::prefix('v1')->group(...)` block, after the `Shipping` module require, add:

```php
    require app_path('Modules/Notifications/routes.php');
```

- [ ] **Step 7: Run tests**

Run: `docker compose exec laravel.test php artisan test --filter=InboxControllerTest`
Expected: PASS (6 tests).

- [ ] **Step 8: Commit**

```bash
git add api/app/Modules/Notifications/Controllers/InboxController.php api/app/Modules/Notifications/Resources/InboxItemResource.php api/app/Modules/Notifications/routes.php api/routes/api.php api/tests/Feature/Notifications/InboxControllerTest.php
git commit -m "feat(notifications): InboxController with list, unread-count, read, mark-all-read"
```

---

## Task 19: `PreferenceController` + request + resource

**Files:**
- Create: `api/app/Modules/Notifications/Controllers/PreferenceController.php`
- Create: `api/app/Modules/Notifications/Requests/UpdatePreferencesRequest.php`
- Create: `api/app/Modules/Notifications/Resources/PreferenceResource.php`
- Modify: `api/app/Modules/Notifications/routes.php`
- Test: `api/tests/Feature/Notifications/PreferenceControllerTest.php`

- [ ] **Step 1: Write the failing test**

Create `api/tests/Feature/Notifications/PreferenceControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use App\Modules\Notifications\Services\SeedDefaultPreferences;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class PreferenceControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_index_returns_all_preferences_with_is_transactional_flag(): void
    {
        $user = User::factory()->create();
        app(SeedDefaultPreferences::class)->forUser($user);
        Sanctum::actingAs($user);

        $res = $this->getJson('/v1/me/notification-preferences');

        $res->assertOk()
            ->assertJsonStructure(['data' => [['channel', 'category', 'enabled', 'is_transactional']]]);

        $body = $res->json('data');
        $orders = collect($body)->firstWhere('category', 'orders');
        $promotions = collect($body)->firstWhere('category', 'promotions');

        $this->assertTrue($orders['is_transactional']);
        $this->assertFalse($promotions['is_transactional']);
    }

    public function test_update_succeeds_for_non_transactional(): void
    {
        $user = User::factory()->create();
        app(SeedDefaultPreferences::class)->forUser($user);
        Sanctum::actingAs($user);

        $res = $this->patchJson('/v1/me/notification-preferences', [
            'preferences' => [
                ['channel' => 'email', 'category' => 'promotions', 'enabled' => false],
            ],
        ]);

        $res->assertOk();
        $updated = collect($res->json('data'))
            ->first(fn ($p) => $p['channel'] === 'email' && $p['category'] === 'promotions');
        $this->assertFalse($updated['enabled']);
    }

    public function test_update_rejects_disabling_transactional(): void
    {
        $user = User::factory()->create();
        app(SeedDefaultPreferences::class)->forUser($user);
        Sanctum::actingAs($user);

        $this->patchJson('/v1/me/notification-preferences', [
            'preferences' => [
                ['channel' => 'email', 'category' => 'orders', 'enabled' => false],
            ],
        ])->assertStatus(422)
          ->assertJsonValidationErrors(['preferences']);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec laravel.test php artisan test --filter=PreferenceControllerTest`
Expected: FAIL.

- [ ] **Step 3: Write the resource**

Create `api/app/Modules/Notifications/Resources/PreferenceResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Resources;

use App\Support\Enums\NotificationCategory;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PreferenceResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        $category = $this->category instanceof NotificationCategory
            ? $this->category
            : NotificationCategory::from($this->category);

        return [
            'channel' => is_object($this->channel) ? $this->channel->value : $this->channel,
            'category' => $category->value,
            'enabled' => (bool) $this->enabled,
            'is_transactional' => in_array($category, [
                NotificationCategory::Orders,
                NotificationCategory::Shipping,
            ], true),
        ];
    }
}
```

- [ ] **Step 4: Write the request**

Create `api/app/Modules/Notifications/Requests/UpdatePreferencesRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Requests;

use App\Support\Enums\NotificationCategory;
use App\Support\Enums\NotificationChannel;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class UpdatePreferencesRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'preferences' => ['required', 'array', 'min:1', $this->noDisablingTransactional()],
            'preferences.*.channel' => ['required', 'string', 'in:'.implode(',', array_column(NotificationChannel::cases(), 'value'))],
            'preferences.*.category' => ['required', 'string', 'in:'.implode(',', array_column(NotificationCategory::cases(), 'value'))],
            'preferences.*.enabled' => ['required', 'boolean'],
        ];
    }

    private function noDisablingTransactional(): ValidationRule
    {
        return new class implements ValidationRule
        {
            public function validate(string $attribute, mixed $value, \Closure $fail): void
            {
                if (! is_array($value)) {
                    return;
                }
                foreach ($value as $pref) {
                    $category = $pref['category'] ?? null;
                    $enabled = $pref['enabled'] ?? null;
                    if (in_array($category, ['orders', 'shipping'], true) && $enabled === false) {
                        $fail('Transactional categories (orders, shipping) cannot be disabled.');

                        return;
                    }
                }
            }
        };
    }
}
```

- [ ] **Step 5: Write the controller**

Create `api/app/Modules/Notifications/Controllers/PreferenceController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Controllers;

use App\Models\NotificationPreference;
use App\Modules\Notifications\Requests\UpdatePreferencesRequest;
use App\Modules\Notifications\Resources\PreferenceResource;
use App\Modules\Notifications\Services\SeedDefaultPreferences;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PreferenceController
{
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        if ($user->notificationPreferences()->count() === 0) {
            app(SeedDefaultPreferences::class)->forUser($user);
        }

        return response()->json([
            'data' => PreferenceResource::collection($user->notificationPreferences()->get()),
        ]);
    }

    public function update(UpdatePreferencesRequest $request): JsonResponse
    {
        $user = $request->user();

        foreach ($request->validated()['preferences'] as $pref) {
            NotificationPreference::updateOrCreate(
                [
                    'user_id' => $user->id,
                    'channel' => $pref['channel'],
                    'category' => $pref['category'],
                ],
                [
                    'enabled' => $pref['enabled'],
                ],
            );
        }

        return response()->json([
            'data' => PreferenceResource::collection($user->notificationPreferences()->get()),
        ]);
    }
}
```

- [ ] **Step 6: Add routes**

Edit `api/app/Modules/Notifications/routes.php`, inside the existing `Route::middleware('auth:sanctum')->group(...)`:

```php
    Route::get('/me/notification-preferences', [\App\Modules\Notifications\Controllers\PreferenceController::class, 'index']);
    Route::patch('/me/notification-preferences', [\App\Modules\Notifications\Controllers\PreferenceController::class, 'update']);
```

- [ ] **Step 7: Run tests**

Run: `docker compose exec laravel.test php artisan test --filter=PreferenceControllerTest`
Expected: PASS (3 tests).

- [ ] **Step 8: Commit**

```bash
git add api/app/Modules/Notifications/Controllers/PreferenceController.php api/app/Modules/Notifications/Requests/UpdatePreferencesRequest.php api/app/Modules/Notifications/Resources/PreferenceResource.php api/app/Modules/Notifications/routes.php api/tests/Feature/Notifications/PreferenceControllerTest.php
git commit -m "feat(notifications): PreferenceController with transactional lock"
```

---

## Task 20: Web — wire notifications endpoints into `api.ts` + query hooks

**Files:**
- Modify: `web/src/lib/api.ts`
- Create: `web/src/lib/queries/use-notifications.ts`

- [ ] **Step 1: Add notifications to the api assembly**

Edit `Alqove/web/src/lib/api.ts`. Add to the import block:

```ts
  createNotificationEndpoints,
```

(placed alphabetically between `createItemEndpoints` and `createOrderEndpoints` — or in the existing order the other exports follow).

Then add a property to the exported `api` object:

```ts
  notifications: createNotificationEndpoints(client),
```

- [ ] **Step 2: Verify the `AlqoveClient` exposes `patch` and `post` the way our endpoint module expects**

Run: `grep -n "post\|patch\|get" Alqove/packages/api-client/src/client.ts`. If `patch` is missing, it was added in Task 17/Step 3 — confirm it's present.

- [ ] **Step 3: Write the query hooks**

Create `Alqove/web/src/lib/queries/use-notifications.ts`:

```ts
"use client";

import {
  useQuery,
  useMutation,
  useQueryClient,
  type InfiniteData,
  useInfiniteQuery,
} from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
  NotificationInboxList,
  NotificationInboxItem,
} from "@alqove/api-client";

export const NOTIFICATION_KEYS = {
  unreadCount: ["notifications", "unread-count"] as const,
  list: (filter: "all" | "unread") => ["notifications", "list", filter] as const,
};

const POLL_INTERVAL_MS = 60_000;

export function useUnreadCount() {
  return useQuery({
    queryKey: NOTIFICATION_KEYS.unreadCount,
    queryFn: async () => {
      const res = await api.notifications.unreadCount();
      return res.data.unread_count;
    },
    refetchInterval: POLL_INTERVAL_MS,
    refetchOnWindowFocus: true,
    staleTime: 30_000,
  });
}

export function useNotifications(filter: "all" | "unread" = "all") {
  return useInfiniteQuery<NotificationInboxList>({
    queryKey: NOTIFICATION_KEYS.list(filter),
    initialPageParam: 1,
    queryFn: async ({ pageParam }) => {
      return api.notifications.list({
        filter,
        page: pageParam as number,
        per_page: 20,
      });
    },
    getNextPageParam: (lastPage) =>
      lastPage.meta.current_page < lastPage.meta.last_page
        ? lastPage.meta.current_page + 1
        : undefined,
  });
}

export function useMarkNotificationRead() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: string) => api.notifications.markRead(id),
    onMutate: async (id: string) => {
      await qc.cancelQueries({ queryKey: ["notifications"] });

      qc.setQueryData<number | undefined>(NOTIFICATION_KEYS.unreadCount, (prev) =>
        typeof prev === "number" ? Math.max(0, prev - 1) : prev,
      );

      for (const filter of ["all", "unread"] as const) {
        qc.setQueryData<InfiniteData<NotificationInboxList> | undefined>(
          NOTIFICATION_KEYS.list(filter),
          (prev) => {
            if (!prev) return prev;
            return {
              ...prev,
              pages: prev.pages.map((page) => ({
                ...page,
                data: page.data.map((n): NotificationInboxItem =>
                  n.id === id && n.read_at === null
                    ? { ...n, read_at: new Date().toISOString() }
                    : n,
                ),
              })),
            };
          },
        );
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["notifications"] });
    },
  });
}

export function useMarkAllNotificationsRead() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: () => api.notifications.markAllRead(),
    onSuccess: () => {
      qc.setQueryData(NOTIFICATION_KEYS.unreadCount, 0);
      qc.invalidateQueries({ queryKey: ["notifications"] });
    },
  });
}
```

- [ ] **Step 4: Typecheck**

Run from repo root: `npm run typecheck`
Expected: no errors.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/lib/api.ts Alqove/web/src/lib/queries/use-notifications.ts
git commit -m "feat(web): notifications query hooks and api wiring"
```

---

## Task 21: Web — `NotificationRow` + `NotificationDropdown` components

**Files:**
- Create: `web/src/components/notifications/notification-row.tsx`
- Create: `web/src/components/notifications/notification-dropdown.tsx`

- [ ] **Step 1: Write `NotificationRow`**

Create `Alqove/web/src/components/notifications/notification-row.tsx`:

```tsx
"use client";

import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import type { NotificationInboxItem } from "@alqove/api-client";
import { useMarkNotificationRead } from "@/lib/queries/use-notifications";

const ICONS: Record<NotificationInboxItem["icon"], string> = {
  package: "📦",
  truck: "🚚",
  check: "✓",
  alert: "⚠",
  x: "✕",
};

export function NotificationRow({ item }: { item: NotificationInboxItem }) {
  const markRead = useMarkNotificationRead();
  const unread = item.read_at === null;

  const handleClick = () => {
    if (unread) markRead.mutate(item.id);
  };

  return (
    <Link
      href={item.cta_url}
      onClick={handleClick}
      className={`flex gap-3 px-4 py-3 hover:bg-bone/60 border-b border-forest/10 ${
        unread ? "bg-forest/5" : ""
      }`}
    >
      <span className="text-xl leading-none shrink-0" aria-hidden>
        {ICONS[item.icon] ?? "•"}
      </span>
      <div className="flex-1 min-w-0">
        <div className="flex items-center justify-between gap-2">
          <span className={`text-sm ${unread ? "font-semibold" : "font-medium"} text-forest truncate`}>
            {item.title}
          </span>
          {unread ? (
            <span className="h-2 w-2 rounded-full bg-terracotta shrink-0" aria-label="unread" />
          ) : null}
        </div>
        <p className="text-xs text-forest/70 truncate">{item.body}</p>
        <p className="text-[11px] text-forest/50 mt-0.5">
          {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
        </p>
      </div>
    </Link>
  );
}
```

- [ ] **Step 2: Write `NotificationDropdown`**

Create `Alqove/web/src/components/notifications/notification-dropdown.tsx`:

```tsx
"use client";

import Link from "next/link";
import {
  useNotifications,
  useMarkAllNotificationsRead,
} from "@/lib/queries/use-notifications";
import { NotificationRow } from "./notification-row";

export function NotificationDropdown({ onClose }: { onClose: () => void }) {
  const { data, isLoading } = useNotifications("all");
  const markAll = useMarkAllNotificationsRead();

  const first = data?.pages[0];
  const items = (first?.data ?? []).slice(0, 10);

  return (
    <div
      role="menu"
      className="absolute right-0 top-full mt-2 w-80 rounded-md border border-forest/20 bg-bone shadow-lg z-50"
    >
      <div className="flex items-center justify-between px-4 py-2 border-b border-forest/10">
        <span className="text-sm font-semibold text-forest">Notifications</span>
        <button
          type="button"
          onClick={() => markAll.mutate()}
          className="text-xs text-terracotta hover:underline"
        >
          Mark all read
        </button>
      </div>

      <div className="max-h-96 overflow-y-auto">
        {isLoading ? (
          <div className="p-6 text-sm text-forest/60 text-center">Loading…</div>
        ) : items.length === 0 ? (
          <div className="p-6 text-sm text-forest/60 text-center">No notifications yet.</div>
        ) : (
          items.map((item) => <NotificationRow key={item.id} item={item} />)
        )}
      </div>

      <Link
        href="/notifications"
        onClick={onClose}
        className="block px-4 py-2 text-center text-xs font-medium text-forest hover:bg-bone/60 border-t border-forest/10"
      >
        See all
      </Link>
    </div>
  );
}
```

- [ ] **Step 3: Typecheck**

Run: `npm run typecheck`
Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add Alqove/web/src/components/notifications/notification-row.tsx Alqove/web/src/components/notifications/notification-dropdown.tsx
git commit -m "feat(web): NotificationRow and NotificationDropdown components"
```

---

## Task 22: Web — `NotificationBell` component

**Files:**
- Create: `web/src/components/notifications/notification-bell.tsx`

- [ ] **Step 1: Write the bell**

Create `Alqove/web/src/components/notifications/notification-bell.tsx`:

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useUnreadCount } from "@/lib/queries/use-notifications";
import { NotificationDropdown } from "./notification-dropdown";

export function NotificationBell() {
  const [open, setOpen] = useState(false);
  const { data: unread = 0 } = useUnreadCount();
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  const badgeText = unread > 9 ? "9+" : String(unread);

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ""}`}
        className="relative text-lg text-bone/80 hover:text-terracotta"
      >
        <span aria-hidden>🔔</span>
        {unread > 0 ? (
          <span className="absolute -top-1 -right-2 min-w-[1.25rem] h-5 px-1 rounded-full bg-terracotta text-[10px] font-semibold text-bone flex items-center justify-center">
            {badgeText}
          </span>
        ) : null}
      </button>
      {open ? <NotificationDropdown onClose={() => setOpen(false)} /> : null}
    </div>
  );
}
```

- [ ] **Step 2: Typecheck**

Run: `npm run typecheck`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/components/notifications/notification-bell.tsx
git commit -m "feat(web): NotificationBell component with polling unread count"
```

---

## Task 23: Web — `/notifications` page (full list)

**Files:**
- Create: `web/src/app/(buyer)/notifications/page.tsx`
- Create: `web/src/app/(buyer)/notifications/notifications-client.tsx`

- [ ] **Step 1: Write the server-rendered page**

Create `Alqove/web/src/app/(buyer)/notifications/page.tsx`:

```tsx
import type { Metadata } from "next";
import { NotificationsClient } from "./notifications-client";

export const metadata: Metadata = {
  title: "Notifications — Alqove",
};

export default function NotificationsPage() {
  return (
    <div className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-2xl font-semibold text-forest mb-4">Notifications</h1>
      <NotificationsClient />
    </div>
  );
}
```

- [ ] **Step 2: Write the client component with infinite scroll**

Create `Alqove/web/src/app/(buyer)/notifications/notifications-client.tsx`:

```tsx
"use client";

import { useState } from "react";
import {
  useNotifications,
  useMarkAllNotificationsRead,
} from "@/lib/queries/use-notifications";
import { NotificationRow } from "@/components/notifications/notification-row";

export function NotificationsClient() {
  const [filter, setFilter] = useState<"all" | "unread">("all");
  const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } =
    useNotifications(filter);
  const markAll = useMarkAllNotificationsRead();

  const items = data?.pages.flatMap((p) => p.data) ?? [];

  return (
    <div className="rounded-md border border-forest/20 bg-bone">
      <div className="flex items-center justify-between px-4 py-3 border-b border-forest/10">
        <div className="flex gap-2">
          <button
            type="button"
            onClick={() => setFilter("all")}
            className={`text-xs px-3 py-1 rounded-full ${
              filter === "all"
                ? "bg-forest text-bone"
                : "bg-bone text-forest border border-forest/20"
            }`}
          >
            All
          </button>
          <button
            type="button"
            onClick={() => setFilter("unread")}
            className={`text-xs px-3 py-1 rounded-full ${
              filter === "unread"
                ? "bg-forest text-bone"
                : "bg-bone text-forest border border-forest/20"
            }`}
          >
            Unread
          </button>
        </div>
        <button
          type="button"
          onClick={() => markAll.mutate()}
          className="text-xs text-terracotta hover:underline"
        >
          Mark all read
        </button>
      </div>

      <div>
        {isLoading ? (
          <div className="p-6 text-sm text-forest/60 text-center">Loading…</div>
        ) : items.length === 0 ? (
          <div className="p-10 text-sm text-forest/60 text-center">
            No notifications yet.
          </div>
        ) : (
          items.map((item) => <NotificationRow key={item.id} item={item} />)
        )}
      </div>

      {hasNextPage ? (
        <div className="p-4 text-center border-t border-forest/10">
          <button
            type="button"
            onClick={() => fetchNextPage()}
            disabled={isFetchingNextPage}
            className="text-sm text-forest hover:text-terracotta disabled:opacity-50"
          >
            {isFetchingNextPage ? "Loading…" : "Load more"}
          </button>
        </div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 3: Typecheck**

Run: `npm run typecheck`
Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add "Alqove/web/src/app/(buyer)/notifications/page.tsx" "Alqove/web/src/app/(buyer)/notifications/notifications-client.tsx"
git commit -m "feat(web): /notifications page with filter tabs and load-more"
```

---

## Task 24: Web — wire `NotificationBell` into buyer layout

**Files:**
- Modify: `web/src/app/(buyer)/layout.tsx`

- [ ] **Step 1: Add the bell to the header**

Edit `Alqove/web/src/app/(buyer)/layout.tsx`.

Add to the imports:

```tsx
import { NotificationBell } from "@/components/notifications/notification-bell";
```

In the header `<div className="flex items-center gap-4 text-bone/80">` block (near the saved + cart icons), insert the bell before `<CartBadge />`:

```tsx
            <NotificationBell />
```

So the final block reads:

```tsx
          <div className="flex items-center gap-4 text-bone/80">
            <Link href="/saved" className="hover:text-terracotta text-lg" aria-label="Saved items">
              ♡
            </Link>
            <NotificationBell />
            <CartBadge />
          </div>
```

- [ ] **Step 2: Run the Next.js dev server and smoke-test manually**

Run: `cd Alqove/web && npm run dev`

Visit `http://localhost:3000` logged in as a seeded buyer with unread notifications. Verify:
- Bell appears in header.
- Unread badge shows correct number.
- Clicking bell opens dropdown.
- Clicking a row marks it read and navigates to the purchase.
- "See all" navigates to `/notifications`.
- `/notifications` page renders and paginates.

Stop the dev server with `Ctrl-C` when done.

- [ ] **Step 3: Typecheck + lint**

Run: `npm run typecheck && npm run lint`
Expected: clean.

- [ ] **Step 4: Commit**

```bash
git add "Alqove/web/src/app/(buyer)/layout.tsx"
git commit -m "feat(web): mount NotificationBell in buyer layout"
```

---

## Task 25: Documentation updates

**Files:**
- Modify: `api/app/Modules/Notifications/README.md`
- Modify: `api/CLAUDE.md` (if notification guidance belongs there — verify first)
- Create: `docs/adr/005-layer-6-notifications.md` (optional — only if scope warrants)

- [ ] **Step 1: Update `Notifications` module README**

Overwrite `Alqove/api/app/Modules/Notifications/README.md`:

```markdown
# Notifications Module

Email dispatch via Resend (prod) / Mailpit (local) and in-app inbox via Laravel's built-in `notifications` morph table.

## Responsibilities

- Consume domain events dispatched by other modules (Orders, Items) and dispatch user-facing notifications.
- Gate email delivery by `notification_preferences` per (channel × category).
- Persist an in-app inbox row for every notification regardless of email preference (preferences only gate the `mail` channel).

## Components

- `Notifications/*Notification.php` — Laravel `Notification` classes, one per (recipient × event). `via()` returns gated channels; `toMail()` renders a markdown template; `toDatabase()` returns the inbox payload.
- `Listeners/Send*Email.php` — thin listeners that resolve the recipient and call `$user->notify(...)`. One listener per (event × recipient).
- `Services/NotificationPreferenceGate.php` — single source of truth for channel gating.
- `Services/AdminRecipients.php` — resolves admin users for fan-out.
- `Services/SeedDefaultPreferences.php` — seeds default prefs on user registration (all categories enabled).
- `Controllers/InboxController.php`, `PreferenceController.php` — user-facing inbox + preferences endpoints under `/v1/me/*`.

## Preference semantics

- **Orders / Shipping** are transactional — always-on, cannot be disabled via API.
- **Promotions / PriceDrops / Account** honor `notification_preferences` rows.
- **Admin** notifications bypass the gate entirely (role-based).

## Deploy prerequisites

- Set `MAIL_MAILER=resend` and `RESEND_API_KEY=...` in production env.
- Verify the sending domain in Resend (DKIM). Set `MAIL_FROM_ADDRESS=notifications@alqove.com` and `MAIL_SUPPORT_ADDRESS=support@alqove.com`.
- Local dev uses Sail's Mailpit at `http://localhost:8025` (`MAIL_MAILER=smtp`, host `mailpit`).

## Not in V1

- Push (web or mobile).
- Real-time broadcasting.
- Dedup / idempotency table.
- Preferences UI (API-only; UI added when promotions/price-drops emails ship).
- Seller/admin inbox UIs (Layers 7 / 8).
- Price drops, item publish / relist / remove listeners.
```

- [ ] **Step 2: Skip ADR if no novel external integration**

We're not adding a new external provider beyond Resend (already in use via Laravel's mail transport). Skip creating an ADR unless the team convention explicitly requires one per layer.

- [ ] **Step 3: Commit**

```bash
git add api/app/Modules/Notifications/README.md
git commit -m "docs(notifications): update module README for Layer 6"
```

---

## Task 26: Final verification

**Files:** none (verification only)

- [ ] **Step 1: Full API test suite**

Run: `docker compose exec laravel.test php artisan test`
Expected: all tests pass.

- [ ] **Step 2: Pint (code style)**

Run: `docker compose exec laravel.test ./vendor/bin/pint --test`
Expected: no style issues. If any, run without `--test` to auto-fix, then re-run with `--test`.

- [ ] **Step 3: PHPStan**

Run: `docker compose exec laravel.test ./vendor/bin/phpstan analyse`
Expected: no errors.

- [ ] **Step 4: Frontend typecheck + lint**

Run (from repo root): `npm run typecheck && npm run lint`
Expected: clean.

- [ ] **Step 5: Next.js production build smoke**

Run: `cd Alqove/web && npm run build`
Expected: builds without errors.

- [ ] **Step 6: Manual end-to-end smoke (Mailpit)**

Run: `docker compose up -d` (ensure Mailpit is up).

With a seeded buyer + seller + paid order:
1. Trigger `OrderPaid::dispatch($order)` via Tinker or a reproducible seeder command.
2. Open Mailpit at `http://localhost:8025` — verify 2 emails (buyer receipt + seller new-order).
3. Open the web app as the buyer — verify bell shows badge 1, dropdown shows the payment-received row.
4. Click the row — verify it marks read and deep-links to `/purchases/{id}`.
5. Trigger `OrderShipped::dispatch($order)` — verify tracking email + new inbox row.
6. Trigger `PurchaseDisputed::dispatch($purchase)` with an admin user seeded — verify admin gets email + inbox row; buyer/seller do not.

- [ ] **Step 7: Commit any final fixes discovered during smoke**

If any test or smoke check revealed a real issue, fix inline and commit. Otherwise, no commit needed.

- [ ] **Step 8: Tag completion**

No tag push required — integration into `main` is governed by the finishing-a-development-branch workflow. When Layer 6 is ready for merge, invoke `superpowers:finishing-a-development-branch`.

---

## Spec Coverage Summary

| Spec requirement | Task |
|---|---|
| `notifications` morph table | 1 |
| Preference gate service + tests | 3 |
| Admin recipient resolution | 4 |
| Default prefs seeding | 5 |
| Branded mail layout | 6 |
| OrderPaid buyer + seller | 7 |
| OrderShipped buyer | 8 |
| OrderDelivered buyer | 9 |
| OrderDelayed buyer + seller | 10 |
| ShipByReminderDue seller | 11 |
| OrderCancelled counterparty | 12 |
| OrderAutoCancelled buyer + seller | 13 |
| OrderDeliveryFailed buyer | 14 |
| PurchaseDisputed admin fan-out | 15 |
| OpenAPI schemas + paths | 16 |
| api-client + generated types | 17 |
| Inbox endpoints + resource + tests | 18 |
| Preferences endpoints + request + tests | 19 |
| Query hooks | 20 |
| Row + dropdown components | 21 |
| Bell component | 22 |
| /notifications page | 23 |
| Layout integration | 24 |
| Docs | 25 |
| Full-suite verification | 26 |

