# Layer 9 Plan 1: Foundation + Buyer Messaging Surface

> **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:** Lay the cross-cutting backend foundation Layer 9 needs (a new `Messaging` module, `message_threads` / `messages` / `message_thread_reads` tables, the `Support` notification category, `MessageRole` enum, `MessageThreadAccess` + `MessagePoster` services, `Buyer`/`Seller` notification classes, and the read GET / write POST / soft-delete DELETE endpoints) and ship the headline buyer surface: a per-order "Messages with {Store}" section on `/purchases/[id]` with unread badge, optimistic compose, and self-delete. Seller compose, admin intervention, attachments, and the seller inbox (`/v1/me/threads`, `/seller/inbox/messages`) are deferred to Plans 2 and 3.

**Architecture:** (1) Backend foundation — new `App\Modules\Messaging` module, three migrations, `MessageThread` + `Message` models with `MessageRole` enum and the `Support` `NotificationCategory` case. (2) Backend services — `MessageThreadAccess` (auth gate + role resolution) and `MessagePoster` (insert + notify) keep the controller thin. (3) Backend endpoints — `GET /v1/orders/{order}/messages`, `POST /v1/orders/{order}/messages`, `DELETE /v1/messages/{message}`. (4) Notifications — `MessagePostedToBuyerNotification` and `MessagePostedToSellerNotification`, both gated through the existing `NotificationPreferenceGate`. (5) Frontend — `useMessages` / `usePostMessage` / `useDeleteMessage` hooks, a shared `<MessageThread>` / `<MessageRow>` / `<MessageComposer>` set, and integration into the existing per-order `<OrderCard>` on `purchase-detail-client.tsx`.

**Tech Stack:** Laravel 11, Pest PHP tests, Postgres, Spatie Permission (existing), OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-06-layer-9-purchase-messaging-design.md`
**Prerequisites:** Layer 8 merged. `User` has `HasRoles` (Spatie Permission), `Order::store` and `Order::purchase`, `Purchase::buyer`, and `Store::owner` are wired. `NotificationCategory` enum lives at `app/Support/Enums/NotificationCategory.php`. `NotificationPreferenceGate::channelsFor()` resolves database+mail per category. The web app's purchase detail at `(buyer)/purchases/[id]/purchase-detail-client.tsx` renders an `<OrderCard>` per order. Last-known head: `f6e0ddb` (api), `40142e3` (web). Test counts at start: API **413 passing**, web **140 passing (1 skipped)**.

**Successor plans:**
- `2026-XX-XX-layer-9-attachments-seller-inbox.md` — image attachments + `/v1/me/threads` + `/seller/inbox/messages` + the seller-side panel on `/seller/orders/[id]`.
- `2026-XX-XX-layer-9-admin-intervention.md` — admin-read panel on `/admin/orders/[id]` with the "Post as Alqove Support" toggle, audit log entries, and the deleted-by-admin stub variant.

---

## Phase A — Backend foundation

### Task 1: Add `Support` to `NotificationCategory`

**Files:**
- Update: `api/app/Support/Enums/NotificationCategory.php`
- Test: `api/tests/Unit/NotificationCategorySupportCaseTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Support\Enums\NotificationCategory;
use PHPUnit\Framework\TestCase;

class NotificationCategorySupportCaseTest extends TestCase
{
    public function test_support_case_exists_and_is_transactional(): void
    {
        $this->assertSame('support', NotificationCategory::Support->value);
        $this->assertTrue(NotificationCategory::Support->isTransactional());
    }
}
```

- [ ] **Step 2: Run, confirm failure**

`docker compose exec laravel.test php artisan test --filter=NotificationCategorySupportCaseTest`

Expected: undefined enum case.

- [ ] **Step 3: Add the case**

In `app/Support/Enums/NotificationCategory.php`, alongside the existing cases:

```php
case Support = 'support';
```

In `isTransactional()`, add `self::Support` to the truthy match arm:

```php
return match ($this) {
    self::Orders, self::Shipping, self::Disputes, self::Support => true,
    default => false,
};
```

- [ ] **Step 4: Re-run; confirm PASS**

### Task 2: `MessageRole` enum

**Files:**
- Create: `api/app/Support/Enums/MessageRole.php`
- Test: `api/tests/Unit/MessageRoleEnumTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Support\Enums\MessageRole;
use PHPUnit\Framework\TestCase;

class MessageRoleEnumTest extends TestCase
{
    public function test_three_roles_exist(): void
    {
        $this->assertSame('buyer', MessageRole::Buyer->value);
        $this->assertSame('seller', MessageRole::Seller->value);
        $this->assertSame('admin', MessageRole::Admin->value);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Create the enum**

`api/app/Support/Enums/MessageRole.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum MessageRole: string
{
    case Buyer = 'buyer';
    case Seller = 'seller';
    case Admin = 'admin';
}
```

- [ ] **Step 4: Re-run; confirm PASS**

### Task 3: `message_threads` migration + model + factory

**Files:**
- Create: `api/database/migrations/2026_05_06_000001_create_message_threads_table.php`
- Create: `api/app/Models/MessageThread.php`
- Create: `api/database/factories/MessageThreadFactory.php`
- Test: `api/tests/Feature/Messaging/MessageThreadModelTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\MessageThread;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class MessageThreadModelTest extends TestCase
{
    use RefreshDatabase;

    public function test_table_exists_with_unique_order_constraint(): void
    {
        $this->assertTrue(Schema::hasTable('message_threads'));
        $this->assertTrue(Schema::hasColumn('message_threads', 'order_id'));
    }

    public function test_thread_belongs_to_an_order(): void
    {
        $order = Order::factory()->create();
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);

        $this->assertTrue($order->is($thread->order));
    }

    public function test_only_one_thread_per_order(): void
    {
        $order = Order::factory()->create();
        MessageThread::factory()->create(['order_id' => $order->id]);

        $this->expectException(\Illuminate\Database\UniqueConstraintViolationException::class);
        MessageThread::factory()->create(['order_id' => $order->id]);
    }
}
```

- [ ] **Step 2: Run, confirm failure (no table / no model)**

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

`api/database/migrations/2026_05_06_000001_create_message_threads_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('message_threads', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('order_id')->unique()->constrained('orders')->cascadeOnDelete();
            $table->timestamps();
        });
    }

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

- [ ] **Step 4: Create the model**

`api/app/Models/MessageThread.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * @property string $id
 * @property string $order_id
 * @property-read Order $order
 * @property-read \Illuminate\Database\Eloquent\Collection<int, Message> $messages
 */
class MessageThread extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = ['order_id'];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function messages(): HasMany
    {
        return $this->hasMany(Message::class, 'thread_id')->orderBy('created_at');
    }
}
```

- [ ] **Step 5: Create the factory**

`api/database/factories/MessageThreadFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\MessageThread;
use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;

class MessageThreadFactory extends Factory
{
    protected $model = MessageThread::class;

    public function definition(): array
    {
        return [
            'order_id' => Order::factory(),
        ];
    }
}
```

- [ ] **Step 6: Migrate and re-run tests**

```
docker compose exec laravel.test php artisan migrate
docker compose exec laravel.test php artisan test --filter=MessageThreadModelTest
```

Expected: 3/3 PASS.

### Task 4: `messages` migration + model + factory

**Files:**
- Create: `api/database/migrations/2026_05_06_000002_create_messages_table.php`
- Create: `api/app/Models/Message.php`
- Create: `api/database/factories/MessageFactory.php`
- Test: `api/tests/Feature/Messaging/MessageModelTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\User;
use App\Support\Enums\MessageRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MessageModelTest extends TestCase
{
    use RefreshDatabase;

    public function test_message_belongs_to_thread_and_author(): void
    {
        $thread = MessageThread::factory()->create();
        $author = User::factory()->create();
        $msg = Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $author->id,
            'author_role' => MessageRole::Buyer,
        ]);

        $this->assertTrue($thread->is($msg->thread));
        $this->assertTrue($author->is($msg->author));
    }

    public function test_role_casts_to_enum(): void
    {
        $msg = Message::factory()->create(['author_role' => MessageRole::Seller]);

        $this->assertSame(MessageRole::Seller, $msg->fresh()->author_role);
    }

    public function test_soft_delete_is_supported(): void
    {
        $msg = Message::factory()->create();

        $msg->update([
            'deleted_at' => now(),
            'deleted_by_user_id' => $msg->author_user_id,
        ]);

        $fresh = $msg->fresh();
        $this->assertNotNull($fresh->deleted_at);
        $this->assertSame($msg->author_user_id, $fresh->deleted_by_user_id);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

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

`api/database/migrations/2026_05_06_000002_create_messages_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('messages', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('thread_id')->constrained('message_threads')->cascadeOnDelete();
            $table->foreignUuid('author_user_id')->nullable()->constrained('users');
            $table->string('author_role', 16);
            $table->text('body');
            $table->json('attachments')->nullable();
            $table->timestamp('deleted_at')->nullable();
            $table->foreignUuid('deleted_by_user_id')->nullable()->constrained('users');
            $table->timestamps();

            $table->index(['thread_id', 'created_at']);
        });
    }

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

- [ ] **Step 4: Create the model**

`api/app/Models/Message.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Enums\MessageRole;
use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * @property string $id
 * @property string $thread_id
 * @property string|null $author_user_id
 * @property MessageRole $author_role
 * @property string $body
 * @property array<int, array<string, mixed>>|null $attachments
 * @property \Illuminate\Support\Carbon|null $deleted_at
 * @property string|null $deleted_by_user_id
 * @property \Illuminate\Support\Carbon $created_at
 * @property-read MessageThread $thread
 * @property-read User|null $author
 */
class Message extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'thread_id',
        'author_user_id',
        'author_role',
        'body',
        'attachments',
        'deleted_at',
        'deleted_by_user_id',
    ];

    protected function casts(): array
    {
        return [
            'author_role' => MessageRole::class,
            'attachments' => 'array',
            'deleted_at' => 'datetime',
        ];
    }

    public function thread(): BelongsTo
    {
        return $this->belongsTo(MessageThread::class, 'thread_id');
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'author_user_id');
    }
}
```

> **Plan note:** Plan 1 does not use Laravel's `SoftDeletes` trait — the spec wants admins to read deleted bodies, and `SoftDeletes` would hide rows globally. We manage `deleted_at` manually so the controllers can decide what to show whom.

- [ ] **Step 5: Create the factory**

`api/database/factories/MessageFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\User;
use App\Support\Enums\MessageRole;
use Illuminate\Database\Eloquent\Factories\Factory;

class MessageFactory extends Factory
{
    protected $model = Message::class;

    public function definition(): array
    {
        return [
            'thread_id' => MessageThread::factory(),
            'author_user_id' => User::factory(),
            'author_role' => MessageRole::Buyer,
            'body' => $this->faker->sentence(8),
            'attachments' => null,
        ];
    }
}
```

- [ ] **Step 6: Migrate and re-run tests**

Expected: 3/3 PASS.

### Task 5: `message_thread_reads` migration

**Files:**
- Create: `api/database/migrations/2026_05_06_000003_create_message_thread_reads_table.php`
- Test: `api/tests/Feature/Messaging/MessageThreadReadsTableTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

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

class MessageThreadReadsTableTest extends TestCase
{
    use RefreshDatabase;

    public function test_table_exists_with_composite_pk(): void
    {
        $this->assertTrue(Schema::hasTable('message_thread_reads'));
        $this->assertTrue(Schema::hasColumns('message_thread_reads', [
            'thread_id', 'user_id', 'last_read_at',
        ]));
    }

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

        DB::table('message_thread_reads')->insert([
            'thread_id' => $thread->id,
            'user_id' => $user->id,
            'last_read_at' => now(),
        ]);

        $this->expectException(\Illuminate\Database\UniqueConstraintViolationException::class);

        DB::table('message_thread_reads')->insert([
            'thread_id' => $thread->id,
            'user_id' => $user->id,
            'last_read_at' => now(),
        ]);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

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

`api/database/migrations/2026_05_06_000003_create_message_thread_reads_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('message_thread_reads', function (Blueprint $table) {
            $table->foreignUuid('thread_id')->constrained('message_threads')->cascadeOnDelete();
            $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
            $table->timestamp('last_read_at');

            $table->primary(['thread_id', 'user_id']);
            $table->index('user_id'); // for "all my threads" unread queries (Plan 2)
        });
    }

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

- [ ] **Step 4: Migrate and re-run; confirm 2/2 PASS**

---

## Phase B — Backend services

### Task 6: `MessageThreadAccess` service

**Files:**
- Create: `api/app/Modules/Messaging/Services/MessageThreadAccess.php`
- Test: `api/tests/Feature/Messaging/MessageThreadAccessTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Messaging\Services\MessageThreadAccess;
use App\Support\Enums\MessageRole;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MessageThreadAccessTest extends TestCase
{
    use RefreshDatabase;

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

    private function setUpScenario(): array
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
        ]);

        return compact('buyer', 'seller', 'store', 'purchase', 'order');
    }

    public function test_buyer_of_purchase_can_access_and_resolves_to_buyer_role(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->setUpScenario();
        $svc = app(MessageThreadAccess::class);

        $this->assertTrue($svc->canAccess($buyer, $order));
        $this->assertSame(MessageRole::Buyer, $svc->roleFor($buyer, $order));
    }

    public function test_store_owner_can_access_and_resolves_to_seller_role(): void
    {
        ['seller' => $seller, 'order' => $order] = $this->setUpScenario();
        $svc = app(MessageThreadAccess::class);

        $this->assertTrue($svc->canAccess($seller, $order));
        $this->assertSame(MessageRole::Seller, $svc->roleFor($seller, $order));
    }

    public function test_admin_can_access_and_resolves_to_admin_role(): void
    {
        ['order' => $order] = $this->setUpScenario();
        $admin = User::factory()->create();
        $admin->assignRole('admin');
        $svc = app(MessageThreadAccess::class);

        $this->assertTrue($svc->canAccess($admin, $order));
        $this->assertSame(MessageRole::Admin, $svc->roleFor($admin, $order));
    }

    public function test_unrelated_user_is_denied(): void
    {
        ['order' => $order] = $this->setUpScenario();
        $stranger = User::factory()->create();
        $svc = app(MessageThreadAccess::class);

        $this->assertFalse($svc->canAccess($stranger, $order));
    }
}
```

- [ ] **Step 2: Run, confirm failure**

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

`api/app/Modules/Messaging/Services/MessageThreadAccess.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Services;

use App\Models\Order;
use App\Models\User;
use App\Support\Enums\MessageRole;

final class MessageThreadAccess
{
    public function canAccess(User $user, Order $order): bool
    {
        return $this->roleFor($user, $order) !== null;
    }

    public function roleFor(User $user, Order $order): ?MessageRole
    {
        if ($user->hasRole('admin')) {
            return MessageRole::Admin;
        }

        $order->loadMissing(['purchase:id,buyer_id', 'store:id,owner_user_id']);

        if ($order->purchase && $order->purchase->buyer_id === $user->id) {
            return MessageRole::Buyer;
        }

        if ($order->store && $order->store->owner_user_id === $user->id) {
            return MessageRole::Seller;
        }

        return null;
    }
}
```

- [ ] **Step 4: Re-run; confirm 4/4 PASS**

### Task 7: Notification classes — buyer + seller

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/MessagePostedToBuyerNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/MessagePostedToSellerNotification.php`
- Test: `api/tests/Feature/Messaging/MessagePostedNotificationsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\MessagePostedToBuyerNotification;
use App\Modules\Notifications\Notifications\MessagePostedToSellerNotification;
use App\Support\Enums\MessageRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MessagePostedNotificationsTest extends TestCase
{
    use RefreshDatabase;

    public function test_to_seller_database_payload_carries_snippet_and_cta(): void
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id, 'name' => 'Revive Boutique']);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id]);
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);
        $msg = Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
            'body' => str_repeat('a', 200),
        ]);

        $payload = (new MessagePostedToSellerNotification($msg))->toDatabase($seller);

        $this->assertSame('order', $payload['context_type']);
        $this->assertSame($order->id, $payload['context_id']);
        $this->assertStringStartsWith('/seller/orders/', $payload['cta_url']);
        $this->assertLessThanOrEqual(140, strlen($payload['body']));
    }

    public function test_to_buyer_database_payload_links_to_purchase(): void
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id]);
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);
        $msg = Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'body' => 'Tracking just updated.',
        ]);

        $payload = (new MessagePostedToBuyerNotification($msg))->toDatabase($buyer);

        $this->assertStringContainsString($purchase->id, $payload['cta_url']);
        $this->assertSame('Tracking just updated.', $payload['body']);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Implement `MessagePostedToSellerNotification`**

`api/app/Modules/Notifications/Notifications/MessagePostedToSellerNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Message;
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 MessagePostedToSellerNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Message $message) {}

    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Support,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $order = $this->message->thread->order;

        return (new MailMessage)
            ->subject('New message about your order')
            ->from(config('mail.from.address'), 'Alqove')
            ->line('A buyer has posted a message about an order.')
            ->line('"'.$this->snippet().'"')
            ->action(
                'Reply on Alqove',
                config('app.frontend_url').'/seller/orders/'.$order->id,
            );
    }

    public function toDatabase(User $notifiable): array
    {
        $order = $this->message->thread->order;

        return [
            'title' => 'New message',
            'body' => $this->snippet(),
            'cta_url' => '/seller/orders/'.$order->id,
            'icon' => 'message',
            'context_type' => 'order',
            'context_id' => $order->id,
        ];
    }

    private function snippet(): string
    {
        return mb_strimwidth($this->message->body, 0, 140, '…');
    }
}
```

- [ ] **Step 4: Implement `MessagePostedToBuyerNotification`**

`api/app/Modules/Notifications/Notifications/MessagePostedToBuyerNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Message;
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 MessagePostedToBuyerNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public readonly Message $message) {}

    public function via(User $notifiable): array
    {
        return app(NotificationPreferenceGate::class)->channelsFor(
            $notifiable,
            NotificationCategory::Support,
            ['mail', 'database'],
        );
    }

    public function toMail(User $notifiable): MailMessage
    {
        $purchase = $this->message->thread->order->purchase;

        return (new MailMessage)
            ->subject('New message about your purchase')
            ->from(config('mail.from.address'), 'Alqove')
            ->line('A new message was posted to your purchase.')
            ->line('"'.$this->snippet().'"')
            ->action(
                'Open conversation',
                config('app.frontend_url').'/purchases/'.$purchase->id,
            );
    }

    public function toDatabase(User $notifiable): array
    {
        $purchase = $this->message->thread->order->purchase;

        return [
            'title' => 'New message',
            'body' => $this->snippet(),
            'cta_url' => '/purchases/'.$purchase->id,
            'icon' => 'message',
            'context_type' => 'order',
            'context_id' => $this->message->thread->order_id,
        ];
    }

    private function snippet(): string
    {
        return mb_strimwidth($this->message->body, 0, 140, '…');
    }
}
```

- [ ] **Step 5: Re-run; confirm 2/2 PASS**

### Task 8: `MessagePoster` service

**Files:**
- Create: `api/app/Modules/Messaging/Services/MessagePoster.php`
- Test: `api/tests/Feature/Messaging/MessagePosterTest.php`
- (Optional, only if missing) Add `'messaging.compose_window_days' => 30` to `api/config/messaging.php`.

- [ ] **Step 1: Create the config file (skip if it exists)**

`api/config/messaging.php`:

```php
<?php

declare(strict_types=1);

return [
    'compose_window_days' => env('MESSAGING_COMPOSE_WINDOW_DAYS', 30),
];
```

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Messaging\Services\MessagePoster;
use App\Modules\Notifications\Notifications\MessagePostedToBuyerNotification;
use App\Modules\Notifications\Notifications\MessagePostedToSellerNotification;
use App\Support\Enums\MessageRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class MessagePosterTest extends TestCase
{
    use RefreshDatabase;

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

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

        $poster = app(MessagePoster::class);
        $msg = $poster->post($order, $buyer, MessageRole::Buyer, 'Where is my package?');

        $this->assertSame('Where is my package?', $msg->body);
        $this->assertSame(MessageRole::Buyer, $msg->author_role);
        $this->assertSame($buyer->id, $msg->author_user_id);

        Notification::assertSentTo($seller, MessagePostedToSellerNotification::class);
        Notification::assertNotSentTo($buyer, MessagePostedToBuyerNotification::class);
    }

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

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

        app(MessagePoster::class)->post($order, $seller, MessageRole::Seller, 'Tracking just updated.');

        Notification::assertSentTo($buyer, MessagePostedToBuyerNotification::class);
        Notification::assertNotSentTo($seller, MessagePostedToSellerNotification::class);
    }

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

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

        $this->assertDatabaseMissing('message_threads', ['order_id' => $order->id]);

        app(MessagePoster::class)->post($order, $buyer, MessageRole::Buyer, 'First.');

        $this->assertDatabaseHas('message_threads', ['order_id' => $order->id]);
    }

    public function test_post_outside_compose_window_throws_409(): void
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'cancelled_at' => now()->subDays(31),
        ]);

        $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
        $this->expectExceptionCode(409);

        app(MessagePoster::class)->post($order, $buyer, MessageRole::Buyer, 'Too late.');
    }
}
```

- [ ] **Step 3: Run, confirm failure**

- [ ] **Step 4: Implement the service**

`api/app/Modules/Messaging/Services/MessagePoster.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Services;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Notifications\MessagePostedToBuyerNotification;
use App\Modules\Notifications\Notifications\MessagePostedToSellerNotification;
use App\Support\Enums\MessageRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

final class MessagePoster
{
    public function post(
        Order $order,
        User $author,
        MessageRole $role,
        string $body,
    ): Message {
        $this->guardComposeWindow($order);

        return DB::transaction(function () use ($order, $author, $role, $body) {
            $thread = MessageThread::query()->firstOrCreate(['order_id' => $order->id]);

            $message = Message::query()->create([
                'thread_id' => $thread->id,
                'author_user_id' => $author->id,
                'author_role' => $role,
                'body' => $body,
            ]);

            $message->setRelation('thread', $thread->setRelation('order', $order));

            $this->fanOut($message, $role, $order);

            return $message;
        });
    }

    private function guardComposeWindow(Order $order): void
    {
        if ($order->cancelled_at === null) {
            return;
        }

        $days = (int) config('messaging.compose_window_days', 30);

        if ($order->cancelled_at->copy()->addDays($days)->isPast()) {
            abort(409, 'This conversation is closed.');
        }
    }

    private function fanOut(Message $message, MessageRole $role, Order $order): void
    {
        $order->loadMissing(['purchase.buyer', 'store.owner']);

        $buyer = $order->purchase?->buyer;
        $seller = $order->store?->owner;

        $notifyBuyer = in_array($role, [MessageRole::Seller, MessageRole::Admin], true);
        $notifySeller = in_array($role, [MessageRole::Buyer, MessageRole::Admin], true);

        if ($notifyBuyer && $buyer) {
            Notification::send($buyer, new MessagePostedToBuyerNotification($message));
        }
        if ($notifySeller && $seller) {
            Notification::send($seller, new MessagePostedToSellerNotification($message));
        }
    }
}
```

- [ ] **Step 5: Re-run; confirm 4/4 PASS**

---

## Phase C — Backend endpoints

### Task 9: Resources — `MessageResource`

**Files:**
- Create: `api/app/Modules/Messaging/Resources/MessageResource.php`

(No standalone test — exercised by the controller tests in Tasks 10-12.)

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Resources;

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

class MessageResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        $isDeleted = $this->deleted_at !== null;
        $viewerIsAdmin = $request->user()?->hasRole('admin') === true;
        $hideContent = $isDeleted && ! $viewerIsAdmin;

        return [
            'id' => $this->id,
            'thread_id' => $this->thread_id,
            'author_user_id' => $this->author_user_id,
            'author_role' => $this->author_role->value,
            'body' => $hideContent ? null : $this->body,
            'attachments' => $hideContent ? [] : ($this->attachments ?? []),
            'created_at' => $this->created_at->toIso8601String(),
            'deleted_at' => $this->deleted_at?->toIso8601String(),
            'deleted_by_user_id' => $viewerIsAdmin ? $this->deleted_by_user_id : null,
        ];
    }
}
```

### Task 10: `GET /v1/orders/{order}/messages`

**Files:**
- Create: `api/app/Modules/Messaging/Controllers/MessageController.php`
- Create: `api/app/Modules/Messaging/routes.php`
- Update: `api/routes/api.php` (require the messaging routes file)
- Test: `api/tests/Feature/Messaging/MessagesIndexEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\MessageRole;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class MessagesIndexEndpointTest extends TestCase
{
    use RefreshDatabase;

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

    private function scenario(): array
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id]);

        return compact('buyer', 'seller', 'store', 'purchase', 'order');
    }

    public function test_unauthenticated_returns_401(): void
    {
        ['order' => $order] = $this->scenario();
        $this->getJson("/v1/orders/{$order->id}/messages")->assertUnauthorized();
    }

    public function test_unrelated_user_returns_403(): void
    {
        ['order' => $order] = $this->scenario();
        Sanctum::actingAs(User::factory()->create());

        $this->getJson("/v1/orders/{$order->id}/messages")->assertForbidden();
    }

    public function test_buyer_lists_messages_and_thread_is_lazy_created(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        Sanctum::actingAs($buyer);

        $this->assertDatabaseMissing('message_threads', ['order_id' => $order->id]);

        $this->getJson("/v1/orders/{$order->id}/messages")
            ->assertOk()
            ->assertJsonStructure(['data', 'meta' => ['total', 'has_more']]);

        $this->assertDatabaseHas('message_threads', ['order_id' => $order->id]);
    }

    public function test_buyer_get_stamps_last_read_at(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => User::factory()->create()->id,
            'author_role' => MessageRole::Seller,
        ]);

        Sanctum::actingAs($buyer);

        $this->getJson("/v1/orders/{$order->id}/messages")->assertOk();

        $this->assertDatabaseHas('message_thread_reads', [
            'thread_id' => $thread->id,
            'user_id' => $buyer->id,
        ]);
    }

    public function test_deleted_messages_show_stub_to_buyer(): void
    {
        ['buyer' => $buyer, 'seller' => $seller, 'order' => $order] = $this->scenario();
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'body' => 'private body',
            'deleted_at' => now(),
            'deleted_by_user_id' => $seller->id,
        ]);

        Sanctum::actingAs($buyer);

        $resp = $this->getJson("/v1/orders/{$order->id}/messages")->assertOk();
        $row = $resp->json('data.0');

        $this->assertNull($row['body']);
        $this->assertNotNull($row['deleted_at']);
    }
}
```

- [ ] **Step 2: Run, confirm failure (no route)**

- [ ] **Step 3: Implement the controller**

`api/app/Modules/Messaging/Controllers/MessageController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Controllers;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\Order;
use App\Modules\Messaging\Requests\PostMessageRequest;
use App\Modules\Messaging\Resources\MessageResource;
use App\Modules\Messaging\Services\MessagePoster;
use App\Modules\Messaging\Services\MessageThreadAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class MessageController
{
    public function __construct(
        private readonly MessageThreadAccess $access,
        private readonly MessagePoster $poster,
    ) {}

    public function index(Request $request, Order $order): JsonResponse
    {
        $user = $request->user();

        if (! $this->access->canAccess($user, $order)) {
            abort(403);
        }

        $thread = MessageThread::query()->firstOrCreate(['order_id' => $order->id]);

        $messages = Message::query()
            ->where('thread_id', $thread->id)
            ->orderBy('created_at')
            ->get();

        DB::table('message_thread_reads')->updateOrInsert(
            ['thread_id' => $thread->id, 'user_id' => $user->id],
            ['last_read_at' => now()],
        );

        return response()->json([
            'data' => MessageResource::collection($messages)->resolve($request),
            'meta' => [
                'total' => $messages->count(),
                'has_more' => false, // Plan 1 ships full-thread reads; pagination lands in Plan 2
            ],
        ]);
    }

    public function store(PostMessageRequest $request, Order $order): JsonResponse
    {
        $user = $request->user();
        $role = $this->access->roleFor($user, $order);

        if ($role === null) {
            abort(403);
        }

        $message = $this->poster->post(
            order: $order,
            author: $user,
            role: $role,
            body: $request->validated('body'),
        );

        return response()->json([
            'data' => (new MessageResource($message))->resolve($request),
        ], 201);
    }

    public function destroy(Request $request, Message $message): JsonResponse
    {
        $user = $request->user();
        $isAdmin = $user->hasRole('admin');

        if (! $isAdmin && $message->author_user_id !== $user->id) {
            abort(403);
        }
        if ($message->deleted_at !== null) {
            return response()->json([], 204);
        }

        $message->update([
            'deleted_at' => now(),
            'deleted_by_user_id' => $user->id,
        ]);

        return response()->json([], 204);
    }
}
```

- [ ] **Step 4: Implement `PostMessageRequest`**

`api/app/Modules/Messaging/Requests/PostMessageRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PostMessageRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Authorization is enforced in the controller via MessageThreadAccess.
        return true;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'body' => ['required', 'string', 'min:1', 'max:5000'],
        ];
    }
}
```

- [ ] **Step 5: Wire the routes**

`api/app/Modules/Messaging/routes.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Messaging\Controllers\MessageController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/orders/{order}/messages', [MessageController::class, 'index']);
    Route::post('/orders/{order}/messages', [MessageController::class, 'store']);
    Route::delete('/messages/{message}', [MessageController::class, 'destroy']);
});
```

In `api/routes/api.php`, inside the existing `Route::prefix('v1')->group(...)` block, append:

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

- [ ] **Step 6: Re-run; confirm 5/5 PASS**

### Task 11: `POST /v1/orders/{order}/messages`

**Files:**
- Test: `api/tests/Feature/Messaging/MessagesStoreEndpointTest.php`

(Implementation already wired in Task 10; this task is the end-to-end POST coverage.)

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Notifications\Notifications\MessagePostedToSellerNotification;
use App\Support\Enums\MessageRole;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class MessagesStoreEndpointTest extends TestCase
{
    use RefreshDatabase;

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

    private function scenario(): array
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id]);

        return compact('buyer', 'seller', 'store', 'purchase', 'order');
    }

    public function test_buyer_post_persists_message_and_notifies_seller(): void
    {
        Notification::fake();
        ['buyer' => $buyer, 'seller' => $seller, 'order' => $order] = $this->scenario();
        Sanctum::actingAs($buyer);

        $this->postJson("/v1/orders/{$order->id}/messages", [
            'body' => 'Where is my package?',
        ])->assertCreated()
          ->assertJsonPath('data.author_role', MessageRole::Buyer->value)
          ->assertJsonPath('data.body', 'Where is my package?');

        $this->assertDatabaseHas('messages', [
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer->value,
            'body' => 'Where is my package?',
        ]);

        Notification::assertSentTo($seller, MessagePostedToSellerNotification::class);
    }

    public function test_unrelated_user_cannot_post(): void
    {
        ['order' => $order] = $this->scenario();
        Sanctum::actingAs(User::factory()->create());

        $this->postJson("/v1/orders/{$order->id}/messages", ['body' => 'hi'])
            ->assertForbidden();
    }

    public function test_empty_body_is_422(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        Sanctum::actingAs($buyer);

        $this->postJson("/v1/orders/{$order->id}/messages", ['body' => ''])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['body']);
    }

    public function test_body_too_long_is_422(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        Sanctum::actingAs($buyer);

        $this->postJson("/v1/orders/{$order->id}/messages", [
            'body' => str_repeat('x', 5001),
        ])->assertStatus(422)
          ->assertJsonValidationErrors(['body']);
    }

    public function test_post_to_long_cancelled_order_is_409(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        $order->update(['cancelled_at' => now()->subDays(31)]);
        Sanctum::actingAs($buyer);

        $this->postJson("/v1/orders/{$order->id}/messages", ['body' => 'late.'])
            ->assertStatus(409);
    }
}
```

- [ ] **Step 2: Run; expected: 5/5 PASS without further code changes** (the controller from Task 10 already covers this).

If something fails, iterate on the controller or `MessagePoster`.

### Task 12: `DELETE /v1/messages/{message}`

**Files:**
- Test: `api/tests/Feature/Messaging/MessagesDestroyEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use App\Models\MessageThread;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\MessageRole;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class MessagesDestroyEndpointTest extends TestCase
{
    use RefreshDatabase;

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

    private function buyerMessage(): Message
    {
        $buyer = User::factory()->create();
        $seller = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $seller->id]);
        $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id]);
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);
        return Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
        ]);
    }

    public function test_author_can_soft_delete_own_message(): void
    {
        $msg = $this->buyerMessage();
        Sanctum::actingAs($msg->author);

        $this->deleteJson("/v1/messages/{$msg->id}")->assertNoContent();

        $fresh = $msg->fresh();
        $this->assertNotNull($fresh->deleted_at);
        $this->assertSame($msg->author_user_id, $fresh->deleted_by_user_id);
    }

    public function test_other_user_cannot_delete(): void
    {
        $msg = $this->buyerMessage();
        Sanctum::actingAs(User::factory()->create());

        $this->deleteJson("/v1/messages/{$msg->id}")->assertForbidden();
    }

    public function test_admin_can_delete_anyones_message(): void
    {
        $msg = $this->buyerMessage();
        $admin = User::factory()->create();
        $admin->assignRole('admin');
        Sanctum::actingAs($admin);

        $this->deleteJson("/v1/messages/{$msg->id}")->assertNoContent();

        $this->assertSame($admin->id, $msg->fresh()->deleted_by_user_id);
    }

    public function test_already_deleted_returns_204_idempotent(): void
    {
        $msg = $this->buyerMessage();
        $msg->update(['deleted_at' => now(), 'deleted_by_user_id' => $msg->author_user_id]);
        Sanctum::actingAs($msg->author);

        $this->deleteJson("/v1/messages/{$msg->id}")->assertNoContent();
    }
}
```

- [ ] **Step 2: Run** — expected 4/4 PASS (controller already implements this).

---

## Phase D — OpenAPI + types + api-client

### Task 13: OpenAPI updates

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

- [ ] **Step 1: Append the three new paths**

Under the `/v1/admin/disputes` paths block (or anywhere alphabetically; this codebase orders by tag), add:

```yaml
  /v1/orders/{order}/messages:
    get:
      operationId: listOrderMessages
      summary: List messages on an order's thread
      tags: [Messaging]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: order, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: after, in: query, required: false, schema: { type: string, format: uuid }, description: "Incremental fetch — return messages created after the given message id." }
      responses:
        '200':
          description: Message list (full thread)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Message' }
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
                      has_more: { type: boolean }
        '401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '403': { description: Not a participant, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
    post:
      operationId: postOrderMessage
      summary: Post a message to an order's thread
      tags: [Messaging]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: order, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, minLength: 1, maxLength: 5000 }
      responses:
        '201':
          description: Message created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Message' }
        '403': { description: Not a participant, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '409': { description: Conversation closed (order cancelled longer than the compose window), content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '422': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/ValidationError' } } } }

  /v1/messages/{message}:
    delete:
      operationId: deleteMessage
      summary: Soft-delete a message (author or admin)
      tags: [Messaging]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: message, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Deleted (or already deleted — endpoint is idempotent) }
        '403': { description: Not the author and not an admin, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
```

- [ ] **Step 2: Append the schema**

In `components/schemas`, alongside other schemas, add:

```yaml
    Message:
      type: object
      required: [id, thread_id, author_role, created_at]
      properties:
        id: { type: string, format: uuid }
        thread_id: { type: string, format: uuid }
        author_user_id: { type: [string, 'null'], format: uuid }
        author_role: { type: string, enum: [buyer, seller, admin] }
        body: { type: [string, 'null'], description: "Null when the message is soft-deleted and the viewer is not an admin." }
        attachments:
          type: array
          items:
            type: object
            properties:
              url: { type: string }
              content_type: { type: string }
              size_bytes: { type: integer }
        created_at: { type: string, format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }
        deleted_by_user_id: { type: [string, 'null'], format: uuid, description: "Only populated for admin viewers." }
```

- [ ] **Step 3: Add the `Messaging` tag entry**

Under the top-level `tags:` list, append:

```yaml
  - name: Messaging
```

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

```
python3 -c "import yaml; yaml.safe_load(open('api/contracts/openapi.yaml'))"
```

### Task 14: Sync to alqove-web + regenerate types

- [ ] **Step 1:** In the alqove-web repo:

```
./bin/sync-openapi.sh
npm run build:types
```

- [ ] **Step 2:** Confirm `web/packages/types/src/generated.ts` contains a `Message` schema reference (`components.schemas.Message`).

### Task 15: api-client extensions

**Files:**
- Create: `web/packages/api-client/src/endpoints/messages.ts`
- Update: `web/packages/api-client/src/client.ts` (compose into `messages` namespace)
- Update: `web/packages/api-client/src/index.ts`

- [ ] **Step 1: Create `endpoints/messages.ts`**

```ts
import type { ApiResponse } from '@alqove/types';
import type { AlqoveClient } from '../client';

export interface MessageAttachment {
  url: string;
  content_type: string;
  size_bytes: number;
}

export interface Message {
  id: string;
  thread_id: string;
  author_user_id: string | null;
  author_role: 'buyer' | 'seller' | 'admin';
  body: string | null;
  attachments: MessageAttachment[];
  created_at: string;
  deleted_at: string | null;
  deleted_by_user_id: string | null;
}

export interface MessageList {
  data: Message[];
  meta: { total: number; has_more: boolean };
}

export interface PostMessageBody {
  body: string;
}

export function createMessageEndpoints(client: AlqoveClient) {
  return {
    list(orderId: string) {
      return client.get<MessageList>(`/v1/orders/${orderId}/messages`);
    },
    post(orderId: string, body: PostMessageBody) {
      return client.post<ApiResponse<Message>>(
        `/v1/orders/${orderId}/messages`,
        body,
      );
    },
    delete(messageId: string) {
      return client.delete<void>(`/v1/messages/${messageId}`);
    },
  };
}
```

- [ ] **Step 2: Wire into the client composition**

Find where `AlqoveClient` composes its `purchases`/`orders`/etc. namespaces (typically inside `client.ts` or wherever `createAuthEndpoints`/`createOrderEndpoints` are wired). Add a sibling line:

```ts
import { createMessageEndpoints } from './endpoints/messages';
// ...
this.messages = createMessageEndpoints(this);
```

If the client's surface is defined as a flat object literal, mirror the existing namespaces.

- [ ] **Step 3: Re-export from `index.ts`**

```ts
export { createMessageEndpoints } from './endpoints/messages';
export type {
  Message,
  MessageAttachment,
  MessageList,
  PostMessageBody,
} from './endpoints/messages';
```

- [ ] **Step 4: Typecheck the api-client**

```
npm run typecheck --workspace=@alqove/api-client
```

Expected: clean.

---

## Phase E — Frontend buyer surface

### Task 16: `useMessages`, `usePostMessage`, `useDeleteMessage` hooks

**Files:**
- Create: `web/src/lib/queries/use-messages.ts`

- [ ] **Step 1: Implement the hooks file**

```ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Message, PostMessageBody } from '@alqove/api-client';

export const MESSAGE_KEYS = {
  thread: (orderId: string) => ['messages', orderId] as const,
};

export function useMessages(orderId: string, opts?: { enabled?: boolean }) {
  return useQuery({
    queryKey: MESSAGE_KEYS.thread(orderId),
    queryFn: () => api.messages.list(orderId),
    staleTime: 15_000,
    refetchInterval: 30_000,
    refetchOnWindowFocus: true,
    enabled: opts?.enabled ?? Boolean(orderId),
  });
}

export function usePostMessage(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (body: PostMessageBody) => api.messages.post(orderId, body),
    onMutate: async (body) => {
      await qc.cancelQueries({ queryKey: MESSAGE_KEYS.thread(orderId) });
      const previous = qc.getQueryData(MESSAGE_KEYS.thread(orderId)) as
        | { data: Message[]; meta: { total: number; has_more: boolean } }
        | undefined;

      const optimistic: Message = {
        id: `optimistic-${Date.now()}`,
        thread_id: 'pending',
        author_user_id: null,
        author_role: 'buyer',
        body: body.body,
        attachments: [],
        created_at: new Date().toISOString(),
        deleted_at: null,
        deleted_by_user_id: null,
      };

      qc.setQueryData(MESSAGE_KEYS.thread(orderId), {
        data: [...(previous?.data ?? []), optimistic],
        meta: { total: (previous?.meta.total ?? 0) + 1, has_more: false },
      });

      return { previous };
    },
    onError: (_err, _vars, context) => {
      if (context?.previous) {
        qc.setQueryData(MESSAGE_KEYS.thread(orderId), context.previous);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: MESSAGE_KEYS.thread(orderId) });
      qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
    },
  });
}

export function useDeleteMessage(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (messageId: string) => api.messages.delete(messageId),
    onSuccess: () => qc.invalidateQueries({ queryKey: MESSAGE_KEYS.thread(orderId) }),
  });
}
```

- [ ] **Step 2: Workspace typecheck** (`npm run typecheck`) — clean.

### Task 17: `MessageRow` component

**Files:**
- Create: `web/src/components/messaging/message-row.tsx`
- Create: `web/src/components/messaging/__tests__/message-row.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MessageRow } from '../message-row';
import type { Message } from '@alqove/api-client';

const baseMessage: Message = {
  id: 'm1',
  thread_id: 't1',
  author_user_id: 'u1',
  author_role: 'buyer',
  body: 'Where is my package?',
  attachments: [],
  created_at: '2026-05-06T10:00:00Z',
  deleted_at: null,
  deleted_by_user_id: null,
};

describe('MessageRow', () => {
  it('renders the body and a buyer label when the viewer is the buyer', () => {
    render(<MessageRow message={baseMessage} viewerRole="buyer" viewerUserId="u1" />);
    expect(screen.getByText('Where is my package?')).toBeInTheDocument();
    expect(screen.getByText(/You/)).toBeInTheDocument();
  });

  it('shows a Store label when the post is from the seller', () => {
    render(
      <MessageRow
        message={{ ...baseMessage, author_role: 'seller', author_user_id: 'u2', body: 'Tracking is updated.' }}
        viewerRole="buyer"
        viewerUserId="u1"
        counterpartyName="Revive Boutique"
      />,
    );
    expect(screen.getByText('Revive Boutique')).toBeInTheDocument();
    expect(screen.getByText('Tracking is updated.')).toBeInTheDocument();
  });

  it('shows a deleted stub when body is null', () => {
    render(
      <MessageRow
        message={{ ...baseMessage, body: null, deleted_at: '2026-05-06T10:05:00Z' }}
        viewerRole="buyer"
        viewerUserId="u1"
      />,
    );
    expect(screen.getByText(/deleted by author/i)).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Implement**

```tsx
'use client';

import type { Message } from '@alqove/api-client';

type Role = 'buyer' | 'seller' | 'admin';

interface Props {
  message: Message;
  viewerRole: Role;
  viewerUserId: string;
  counterpartyName?: string;
  onDelete?: (messageId: string) => void;
}

function authorLabel(message: Message, viewerUserId: string, counterpartyName?: string): string {
  if (message.author_user_id === viewerUserId) return 'You';
  if (message.author_role === 'admin') return 'Alqove Support';
  return counterpartyName ?? (message.author_role === 'seller' ? 'Seller' : 'Buyer');
}

export function MessageRow({
  message,
  viewerRole: _viewerRole,
  viewerUserId,
  counterpartyName,
  onDelete,
}: Props) {
  const isMine = message.author_user_id === viewerUserId;
  const isDeleted = message.deleted_at !== null;
  const isAdmin = message.author_role === 'admin';

  if (isDeleted && !message.body) {
    return (
      <div className="text-xs italic text-slate-400 py-2">
        {isAdmin ? '(deleted by Alqove Support)' : '(deleted by author)'}
      </div>
    );
  }

  return (
    <div
      className={`flex flex-col py-2 ${isMine ? 'items-end' : 'items-start'}`}
      data-testid={`message-row-${message.id}`}
    >
      <div className="flex items-center gap-2 text-xs text-slate-400">
        <span className={isAdmin ? 'rounded bg-emerald-100 px-1.5 py-0.5 text-emerald-800 font-medium' : 'font-medium text-slate-500'}>
          {authorLabel(message, viewerUserId, counterpartyName)}
        </span>
        <time>{new Date(message.created_at).toLocaleString()}</time>
        {isMine && onDelete && !isDeleted && (
          <button
            onClick={() => onDelete(message.id)}
            className="text-xs text-slate-400 hover:text-red-600"
            aria-label="Delete message"
          >
            Delete
          </button>
        )}
      </div>
      <p
        className={`mt-1 max-w-[80%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm ${
          isMine
            ? 'bg-emerald-600 text-white'
            : isAdmin
              ? 'bg-emerald-50 text-emerald-900 border border-emerald-200'
              : 'bg-slate-100 text-slate-900'
        }`}
      >
        {message.body}
      </p>
    </div>
  );
}
```

- [ ] **Step 3: Run the test; iterate to PASS**

### Task 18: `MessageComposer` component

**Files:**
- Create: `web/src/components/messaging/message-composer.tsx`
- Create: `web/src/components/messaging/__tests__/message-composer.test.tsx`

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

```tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, describe, it, expect } from 'vitest';
import { MessageComposer } from '../message-composer';

describe('MessageComposer', () => {
  it('disables Send when textarea is empty', () => {
    render(<MessageComposer onSend={() => {}} />);
    expect(screen.getByRole('button', { name: /Send/ })).toBeDisabled();
  });

  it('calls onSend with the trimmed body and clears the field', () => {
    const onSend = vi.fn();
    render(<MessageComposer onSend={onSend} />);
    fireEvent.change(screen.getByLabelText(/Message/i), {
      target: { value: '  hello  ' },
    });
    fireEvent.click(screen.getByRole('button', { name: /Send/ }));
    expect(onSend).toHaveBeenCalledWith('hello');
    expect((screen.getByLabelText(/Message/i) as HTMLTextAreaElement).value).toBe('');
  });

  it('enforces the 5000-char cap visually', () => {
    render(<MessageComposer onSend={() => {}} />);
    const textarea = screen.getByLabelText(/Message/i) as HTMLTextAreaElement;
    expect(textarea.maxLength).toBe(5000);
  });
});
```

- [ ] **Step 2: Implement**

```tsx
'use client';

import { useState } from 'react';

interface Props {
  onSend: (body: string) => void;
  isPending?: boolean;
  placeholder?: string;
}

export function MessageComposer({ onSend, isPending, placeholder }: Props) {
  const [body, setBody] = useState('');
  const trimmed = body.trim();
  const disabled = trimmed.length === 0 || !!isPending;

  const submit = () => {
    if (disabled) return;
    onSend(trimmed);
    setBody('');
  };

  return (
    <div className="mt-3 rounded-md border border-slate-200 bg-slate-50 p-3">
      <label htmlFor="message-composer" className="sr-only">
        Message
      </label>
      <textarea
        id="message-composer"
        aria-label="Message"
        value={body}
        onChange={(e) => setBody(e.target.value)}
        maxLength={5000}
        rows={3}
        placeholder={placeholder ?? 'Write a message…'}
        className="w-full resize-none rounded-md border border-slate-300 bg-white p-2 text-sm focus:border-emerald-600 focus:outline-none"
        onKeyDown={(e) => {
          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit();
        }}
      />
      <div className="mt-2 flex items-center justify-between">
        <span className="text-xs text-slate-400">{trimmed.length} / 5000</span>
        <button
          type="button"
          onClick={submit}
          disabled={disabled}
          className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
        >
          {isPending ? 'Sending…' : 'Send'}
        </button>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Run; PASS 3/3**

### Task 19: `MessageThread` component

**Files:**
- Create: `web/src/components/messaging/message-thread.tsx`
- Create: `web/src/components/messaging/__tests__/message-thread.test.tsx`

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

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageThread } from '../message-thread';

const listMock = vi.fn();
const postMock = vi.fn();
const deleteMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    messages: {
      list: (...a: unknown[]) => listMock(...a),
      post: (...a: unknown[]) => postMock(...a),
      delete: (...a: unknown[]) => deleteMock(...a),
    },
  },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

const buyerView = {
  orderId: 'o1',
  counterpartyName: 'Revive Boutique',
  viewerRole: 'buyer' as const,
  viewerUserId: 'buyer-1',
};

const messages = [
  {
    id: 'm1',
    thread_id: 't1',
    author_user_id: 'buyer-1',
    author_role: 'buyer' as const,
    body: 'Hi.',
    attachments: [],
    created_at: '2026-05-06T10:00:00Z',
    deleted_at: null,
    deleted_by_user_id: null,
  },
  {
    id: 'm2',
    thread_id: 't1',
    author_user_id: 'seller-1',
    author_role: 'seller' as const,
    body: 'Tracking soon.',
    attachments: [],
    created_at: '2026-05-06T10:05:00Z',
    deleted_at: null,
    deleted_by_user_id: null,
  },
];

describe('MessageThread', () => {
  beforeEach(() => {
    listMock.mockReset();
    postMock.mockReset();
    deleteMock.mockReset();
  });

  it('renders both messages from the buyer perspective', async () => {
    listMock.mockResolvedValue({ data: messages, meta: { total: 2, has_more: false } });
    render(wrap(<MessageThread {...buyerView} />));

    await waitFor(() => expect(screen.getByText('Hi.')).toBeInTheDocument());
    expect(screen.getByText('Tracking soon.')).toBeInTheDocument();
    expect(screen.getByText('Revive Boutique')).toBeInTheDocument();
  });

  it('optimistically appends the message and rolls back on failure', async () => {
    listMock.mockResolvedValue({ data: messages, meta: { total: 2, has_more: false } });
    postMock.mockRejectedValue(new Error('boom'));

    render(wrap(<MessageThread {...buyerView} />));
    await waitFor(() => expect(screen.getByText('Hi.')).toBeInTheDocument());

    fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Optimistic test' } });
    fireEvent.click(screen.getByRole('button', { name: /Send/ }));

    await waitFor(() => expect(screen.getByText('Optimistic test')).toBeInTheDocument());
    await waitFor(() => expect(screen.queryByText('Optimistic test')).not.toBeInTheDocument());
  });

  it('shows error banner if list fails', async () => {
    listMock.mockRejectedValue(new Error('forbidden'));
    render(wrap(<MessageThread {...buyerView} />));

    await waitFor(() => expect(screen.getByText(/Couldn't load/i)).toBeInTheDocument());
  });
});
```

- [ ] **Step 2: Implement**

```tsx
'use client';

import { useEffect, useRef } from 'react';
import { useMessages, usePostMessage, useDeleteMessage } from '@/lib/queries/use-messages';
import { MessageRow } from './message-row';
import { MessageComposer } from './message-composer';

interface Props {
  orderId: string;
  counterpartyName?: string;
  viewerRole: 'buyer' | 'seller' | 'admin';
  viewerUserId: string;
}

export function MessageThread({ orderId, counterpartyName, viewerRole, viewerUserId }: Props) {
  const list = useMessages(orderId);
  const post = usePostMessage(orderId);
  const remove = useDeleteMessage(orderId);
  const scrollRef = useRef<HTMLDivElement | null>(null);

  const messages = list.data?.data ?? [];

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages.length]);

  if (list.isError) {
    return (
      <p className="rounded bg-red-50 p-3 text-sm text-red-700">
        Couldn&apos;t load this conversation. Try refreshing.
      </p>
    );
  }

  return (
    <div className="flex flex-col">
      <div
        ref={scrollRef}
        className="max-h-96 overflow-y-auto rounded-md border border-slate-200 bg-white p-3"
      >
        {list.isLoading && <p className="text-sm text-slate-400">Loading…</p>}
        {!list.isLoading && messages.length === 0 && (
          <p className="py-6 text-center text-sm text-slate-400">
            No messages yet. Start the conversation below.
          </p>
        )}
        {messages.map((m) => (
          <MessageRow
            key={m.id}
            message={m}
            viewerRole={viewerRole}
            viewerUserId={viewerUserId}
            counterpartyName={counterpartyName}
            onDelete={(id) => remove.mutate(id)}
          />
        ))}
      </div>

      <MessageComposer
        onSend={(body) => post.mutate({ body })}
        isPending={post.isPending}
        placeholder={`Message ${counterpartyName ?? 'the other party'}…`}
      />
    </div>
  );
}
```

- [ ] **Step 3: Run; iterate to 3/3 PASS**

### Task 20: Integrate into the buyer purchase-detail page

**Files:**
- Update: `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx`
- Update: `web/src/app/(buyer)/purchases/[id]/__tests__/purchase-detail-client.test.tsx`

- [ ] **Step 1: Read the existing component**

Open `purchase-detail-client.tsx`. Locate the `<OrderCard>` function (~line 125). Note the existing JSX structure: timeline → tracking → cancellation UI → items list → footer.

- [ ] **Step 2: Pass the buyer's user id into the component tree**

The page currently renders `purchase` from `usePurchase(id)`. It needs the authenticated user's id to pass to `<MessageThread>`. If `purchase.buyer.id` is available on the response, use that. Otherwise import `useCurrentUser` (or whatever the existing auth hook is). For Plan 1 we use `purchase.buyer_id`, which is already on `PurchaseDetail`.

Pass it down:

```tsx
{purchase.orders.map((order: OrderDetail) => (
  <OrderCard key={order.id} order={order} purchaseId={purchase.id} buyerUserId={purchase.buyer_id} />
))}
```

> **Plan note:** if `PurchaseDetail` does not expose `buyer_id`, add it in the resource (`PurchaseResource::toArray`) and the OpenAPI `PurchaseDetail` schema, regenerate types, and pass it down. This is a one-line resource addition; cover it with a snapshot-style assertion in an existing purchase resource test.

- [ ] **Step 3: Add the messaging section to `<OrderCard>`**

Inside `<OrderCard>`, after the tracking/cancellation UI, before the order items list, add a collapsible section:

```tsx
import { useState } from 'react';
import { MessageThread } from '@/components/messaging/message-thread';
// ...

function OrderCard({
  order,
  purchaseId,
  buyerUserId,
}: {
  order: OrderDetail;
  purchaseId: string;
  buyerUserId: string;
}) {
  const [showMessages, setShowMessages] = useState(false);
  const storeName = order.store?.name ?? 'this store';
  // ... existing JSX above ...

  return (
    <div className="bg-white rounded-lg border border-slate-200 p-5">
      {/* ... existing header / timeline / tracking / cancellation UI ... */}

      <details
        className="mt-4 border-t border-slate-100 pt-4"
        open={showMessages}
        onToggle={(e) => setShowMessages((e.target as HTMLDetailsElement).open)}
      >
        <summary className="cursor-pointer select-none text-sm font-medium text-slate-700">
          Messages with {storeName}
        </summary>
        {showMessages && (
          <div className="mt-3">
            <MessageThread
              orderId={order.id}
              counterpartyName={storeName}
              viewerRole="buyer"
              viewerUserId={buyerUserId}
            />
          </div>
        )}
      </details>

      {/* ... existing items list / footer ... */}
    </div>
  );
}
```

> **Plan note:** the spec's "unread count badge per order" requires an unread-count derivation. Plan 1 ships the section header without the badge — the count needs the `/v1/me/threads` endpoint that's deferred to Plan 2. Add a `// TODO(Plan 2): unread badge` comment next to `Messages with {storeName}` so it's findable.

- [ ] **Step 4: Extend the existing `purchase-detail-client.test.tsx`**

Add (do not replace) a new test case that mocks `api.messages.list` returning an empty list and asserts the section renders:

```tsx
const messagesListMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    purchases: { get: (...a: unknown[]) => getMock(...a) },
    orders: { cancel: (...a: unknown[]) => cancelMock(...a) },
    messages: {
      list: (...a: unknown[]) => messagesListMock(...a),
      post: vi.fn(),
      delete: vi.fn(),
    },
  },
}));

// inside describe(...)
it('renders the messages section per order', async () => {
  getMock.mockResolvedValue({ data: basePurchase });
  messagesListMock.mockResolvedValue({ data: [], meta: { total: 0, has_more: false } });

  render(wrap(<PurchaseDetailClient params={Promise.resolve({ id: 'pur-1' })} />));
  expect(await screen.findAllByText(/Messages with/)).toHaveLength(basePurchase.orders.length);
});
```

- [ ] **Step 5: Run web tests** — expected PASS.

---

## Phase F — Wrap-up

### Task 21: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: all green; total count grows from 413 by **at least 22** (1 NotificationCategory + 1 MessageRole + 3 thread model + 3 message model + 2 read-table + 4 thread access + 2 notifications + 4 poster + 5 messages-index + 5 messages-store + 4 messages-destroy = 34, with some overlap noise). Treat anything ≥ +20 as healthy.
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint --test app/Modules/Messaging app/Models/Message.php app/Models/MessageThread.php app/Support/Enums/MessageRole.php app/Support/Enums/NotificationCategory.php app/Modules/Notifications/Notifications/MessagePostedToBuyerNotification.php app/Modules/Notifications/Notifications/MessagePostedToSellerNotification.php tests/Feature/Messaging tests/Unit`. Expected: PASS.
- [ ] **Step 3: Web typecheck** — `npm run typecheck`. Expected: clean.
- [ ] **Step 4: Web lint** — `npm run lint`. Expected: same baseline as start (no new warnings).
- [ ] **Step 5: Web tests** — `npm run test`. Expected: 140 → ≥ 148 passing (3 message-row + 3 message-composer + 3 message-thread + 1 purchase-detail integration = 10 new; the existing skipped test stays skipped).
- [ ] **Step 6: Manual QA**
  - Seed: a buyer user, a seller user owning a Store, a Purchase by the buyer, an Order on that store.
  - Log in as the buyer; visit `/purchases/<id>`; expand "Messages with {Store}"; type a message; press Send. Confirm the message appears immediately (optimistic) and the spinner clears.
  - Log into the seller (separate browser/incognito); confirm the seller's NotificationBell shows the new "New message" item linking to `/seller/orders/<order>` (the seller-side route is shipped by Plan 2; for now the bell row is visible but the link will 404).
  - Back as the buyer: click the small `Delete` next to your own message. Confirm it becomes "(deleted by author)".
  - Cancel the order via the existing cancel button. Wait (or jump 31 days in DB) and confirm POST returns 409.

### Task 22: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`:

```
git add app contracts database tests config
git commit -m "feat(messaging): backend foundation + endpoints for purchase messaging"
```

- [ ] **Step 2:** In `~/projects/alqove-web`:

```
git add packages web contracts
git commit -m "feat(messaging): buyer purchase-detail messages section"
```

- [ ] **Step 3:** Push both. Watch CI for both repos.

---

## Open items deferred to follow-up plans

- **Image attachments** — `POST /v1/orders/{order}/messages/attachments`, MediaLibrary collection on `Message`, the `attachments[]` request shape on POST, the orphan-cleanup console command, and the inline-thumbnail / lightbox UI. **Plan 2.**
- **Seller surfaces** — `/v1/me/threads` index endpoint, `/seller/inbox/messages` route, and the messages panel on `/seller/orders/[id]`. **Plan 2.**
- **Unread badges** — the per-order unread count badge on the buyer side requires the `me.threads` payload (or a sibling derive endpoint). The component already has a `// TODO(Plan 2)` marker. **Plan 2.**
- **Admin intervention surface** — `/admin/orders/[id]` Messages panel with read-by-default + "Post as Alqove Support" toggle, the admin-coloured post styling, the `message.admin_posted` activity log entry, and the deleted-by-admin stub variant. **Plan 3.**
- **Pagination on `GET /messages`** — Plan 1 returns the full thread (`has_more: false`). Once threads grow, add cursor pagination using the `after` query param the OpenAPI already declares. Plan 2 or Plan 3.
- **Email rendering polish** — the notification mail bodies are functional but not styled; revisit when other Layer 9 emails (admin-posted, dispute-linked) land.
- **Buyer-side rate limiting tuning** — the 30/min POST throttle from the spec is inherited from the api group default; bump if abused. Track via dashboard metrics in Plan 3.
