# Layer 9 Plan 2: Attachments + Seller Inbox + Buyer Unread Badges

> **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:** Pick up the deferred items from Plan 1: ship image attachments end-to-end (upload → attach → render thumbnails → lightbox), expose the messaging surface to sellers via a new `/seller/inbox/messages` inbox + a per-order panel on `/seller/orders/[id]`, and turn the `// TODO(Plan 2)` marker on the buyer purchase-detail page into a real unread badge backed by the new `/v1/me/threads` endpoint.

**Architecture:** (1) Backend attachments — `Message` becomes a Spatie MediaLibrary model with a `message_attachments` collection, an `AttachmentController` accepts a multipart upload to a per-order endpoint and returns an attachment id, and `MessageController::store` now accepts `attachment_ids[]` which `MessagePoster` transfers from the upload-staging owner to the new `Message` row. A daily `messages:gc-orphan-attachments` Artisan command sweeps untransferred uploads. (2) Backend threads index — `MyThreadsController` returns the caller's participating threads (buyer + seller union, sorted by latest activity) with per-thread unread counts derived from `message_thread_reads`; the same shape powers the buyer badge and the seller inbox row. (3) Frontend attachments — `<AttachmentUploader>` handles file pick + progress + per-file remove, `<MessageRow>` renders a thumbnail grid that opens a `<MessageLightbox>` Radix dialog, `<MessageThread>` wires the uploader to a single POST that includes the staged ids. (4) Frontend seller — `useMyThreads`, `/seller/inbox/messages` list page, and a Messages panel rendered after Timeline / before Payout on the existing `/seller/orders/[id]`. (5) Frontend buyer badge — `useMyThreads` lookup + a small `count` rendered in the existing `<details>` summary on each `<OrderCard>`.

**Tech Stack:** Laravel 11, Pest PHP, Postgres, Spatie MediaLibrary v11, Stripe-PHP (unchanged), OpenAPI → `openapi-typescript`, Next.js 15, TanStack Query v5, Tailwind, Vitest + React Testing Library, Radix UI Dialog (already installed for the cancel-confirm dialog).

**Spec:** `docs/superpowers/specs/2026-05-06-layer-9-purchase-messaging-design.md`
**Prerequisites:** Plan 1 merged. `App\Modules\Messaging` exists with `MessageController`, `MessagePoster`, `MessageThreadAccess`. `Message` model has nullable `attachments` JSON column (Plan 1 introduced it as a placeholder; Plan 2 swaps it for MediaLibrary-backed media). `NotificationPreferenceGate` resolves the `Support` category. The web app's `<MessageThread>` lives at `web/src/components/messaging/message-thread.tsx`; the buyer purchase-detail page at `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx` already wraps it in a `<details>` element on each `<OrderCard>`. Last-known head: `771ae7c` (api), `71e6148` (web). Test counts at start: API **447 passing**, web **150 passing (1 skipped)**.

**Successor plans:**
- `2026-XX-XX-layer-9-admin-intervention.md` — admin Messages panel on `/admin/orders/[id]`, "Post as Alqove Support" toggle, deleted-by-admin stub variant, `message.admin_posted` / `message.admin_deleted` activity log entries.

**Out of scope for this plan:** anything in Plan 3 (admin intervention), search across messages, typing indicators, translation, threading, per-message read receipts, voice/video, link previews, off-platform reply-by-email. Cursor pagination on `GET /messages` is small and orthogonal — skipped this plan; the OpenAPI already declares the `?after=` query param so a follow-up patch can add it without contract changes.

---

## Phase A — Backend attachments

### Task 1: Make `Message` a MediaLibrary model

**Files:**
- Update: `api/app/Models/Message.php`
- Test: `api/tests/Feature/Messaging/MessageMediaCollectionTest.php`

The existing `attachments` JSON column on `messages` is Plan 1's stand-in. Plan 2 keeps the column (it doubles as a denormalised cache for `MessageResource` so callers don't have to issue an N+1 over the `media` table) but the source of truth becomes a Spatie collection.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\HasMedia;
use Tests\TestCase;

class MessageMediaCollectionTest extends TestCase
{
    use RefreshDatabase;

    public function test_message_implements_has_media(): void
    {
        $this->assertInstanceOf(HasMedia::class, Message::factory()->create());
    }

    public function test_message_attachments_collection_accepts_jpeg_and_rejects_pdf(): void
    {
        Storage::fake('public');
        $msg = Message::factory()->create();

        $jpeg = UploadedFile::fake()->image('damage.jpg', 800, 800);
        $msg->addMedia($jpeg->getPathname())->toMediaCollection('message_attachments');
        $this->assertCount(1, $msg->fresh()->getMedia('message_attachments'));

        $pdf = UploadedFile::fake()->create('receipt.pdf', 200, 'application/pdf');
        $this->expectException(\Spatie\MediaLibrary\MediaCollections\Exceptions\FileCannotBeAdded::class);
        $msg->addMedia($pdf->getPathname())->toMediaCollection('message_attachments');
    }
}
```

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

Expected: first assertion fails — `Message` does not implement `HasMedia`.

- [ ] **Step 3: Update the Message model**

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

```php
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;

class Message extends Model implements HasMedia
{
    use HasFactory;
    use HasUuid;
    use InteractsWithMedia;

    // ... existing fillable, casts, relations ...

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('message_attachments')
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heic']);
    }

    public function registerMediaConversions(?Media $media = null): void
    {
        $this->addMediaConversion('thumb')
            ->fit(Fit::Contain, 400, 400)
            ->format('webp')
            ->quality(80)
            ->nonQueued();
    }
}
```

- [ ] **Step 4: Run, confirm 2/2 PASS**

### Task 2: `AttachmentController` upload endpoint

**Files:**
- Create: `api/app/Modules/Messaging/Controllers/AttachmentController.php`
- Create: `api/app/Modules/Messaging/Requests/UploadAttachmentRequest.php`
- Update: `api/app/Modules/Messaging/routes.php` — add the new route
- Test: `api/tests/Feature/Messaging/UploadAttachmentEndpointTest.php`

The upload flow stages media on a sentinel "uploads" parent record. We create one `Message` row per upload session keyed to the buyer/seller pair (`thread_id` is null until the user actually sends), and the orphan-cleanup command in Task 4 sweeps anything ≥ 24h old that never got linked.

A simpler approach — the one this plan takes — is to create a stub `Message` row with a known `attachments_pending=true` flag, attach the upload to it, and let `MessagePoster` move the media to the real message on send. We use a dedicated upload-owner model only if the simpler path causes a problem.

For Plan 2 we use the simplest workable approach: stage uploads on a real `Message` row with `body=''` and `thread_id=null` … but `thread_id` is non-null in the schema. So instead we stage on the *thread* (lazily creating it) but mark a transient attribute, and rely on a column we can pivot on. Concrete decision: introduce a new `message_attachment_uploads` table keyed by `id` (uuid), `user_id`, `order_id`, with media attached. `MessagePoster` reads `attachment_ids[]`, validates ownership, and **moves** media from each upload row to the new message before deleting the upload row.

**Migration:** `api/database/migrations/2026_05_06_100001_create_message_attachment_uploads_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_attachment_uploads', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('order_id')->constrained('orders')->cascadeOnDelete();
            $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
            $table->timestamps();

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

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

**Model:** `api/app/Models/MessageAttachmentUpload.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 Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

/**
 * Transient upload-staging row. Media added to its `staged` collection are
 * moved to a real Message by MessagePoster, or swept by
 * `messages:gc-orphan-attachments` after 24h.
 */
class MessageAttachmentUpload extends Model implements HasMedia
{
    use HasFactory;
    use HasUuid;
    use InteractsWithMedia;

    protected $fillable = ['order_id', 'user_id'];

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('staged')
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/heic']);
    }
}
```

- [ ] **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 Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class UploadAttachmentEndpointTest extends TestCase
{
    use RefreshDatabase;

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

    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', 'order');
    }

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

        $file = UploadedFile::fake()->image('damage.jpg', 800, 800);

        $this->post("/v1/orders/{$order->id}/messages/attachments", [
            'file' => $file,
        ])->assertCreated()
          ->assertJsonStructure(['data' => ['id', 'url', 'content_type', 'size_bytes']]);

        $this->assertDatabaseCount('message_attachment_uploads', 1);
    }

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

        $this->post("/v1/orders/{$order->id}/messages/attachments", [
            'file' => UploadedFile::fake()->image('x.jpg'),
        ])->assertForbidden();
    }

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

        $this->post("/v1/orders/{$order->id}/messages/attachments", [
            'file' => UploadedFile::fake()->create('receipt.pdf', 200, 'application/pdf'),
        ])->assertStatus(422)
          ->assertJsonValidationErrors(['file']);
    }

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

        // 6 MB jpeg — over the 5 MB cap
        $file = UploadedFile::fake()->image('huge.jpg')->size(6 * 1024);

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

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

- [ ] **Step 3: Migrate + create the model + factory**

Run the migration above. Add `api/database/factories/MessageAttachmentUploadFactory.php` mirroring `MessageFactory`. Add the model file from above.

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

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

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadAttachmentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // controller checks MessageThreadAccess
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'file' => [
                'required',
                'file',
                'mimes:jpeg,jpg,png,heic',
                'max:5120', // 5 MB in KB
            ],
        ];
    }
}
```

- [ ] **Step 5: Implement `AttachmentController`**

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

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Controllers;

use App\Models\MessageAttachmentUpload;
use App\Models\Order;
use App\Modules\Messaging\Requests\UploadAttachmentRequest;
use App\Modules\Messaging\Services\MessageThreadAccess;
use Illuminate\Http\JsonResponse;

class AttachmentController
{
    public function __construct(private readonly MessageThreadAccess $access) {}

    public function store(UploadAttachmentRequest $request, Order $order): JsonResponse
    {
        $user = $request->user();
        if (! $this->access->canAccess($user, $order)) {
            abort(403);
        }

        $upload = MessageAttachmentUpload::create([
            'order_id' => $order->id,
            'user_id' => $user->id,
        ]);

        $media = $upload->addMedia($request->file('file')->getRealPath())
            ->usingFileName($request->file('file')->getClientOriginalName())
            ->toMediaCollection('staged');

        return response()->json([
            'data' => [
                'id' => $upload->id,
                'url' => $media->getUrl(),
                'content_type' => $media->mime_type,
                'size_bytes' => $media->size,
            ],
        ], 201);
    }
}
```

- [ ] **Step 6: Wire the route**

In `api/app/Modules/Messaging/routes.php`, inside the existing `auth:sanctum` group:

```php
Route::post('/orders/{order}/messages/attachments', [AttachmentController::class, 'store']);
```

Add the use-import.

- [ ] **Step 7: Re-run; iterate to 4/4 PASS**

If the size validation fails because Laravel's default upload limit is below 5 MB in PHP-FPM, set `php_admin_value[upload_max_filesize] = 6M` in the docker test image, or rely on Laravel's `max:5120` rule firing before PHP rejects.

### Task 3: Transfer staged media on POST + return Message with attachments

**Files:**
- Update: `api/app/Modules/Messaging/Requests/PostMessageRequest.php`
- Update: `api/app/Modules/Messaging/Services/MessagePoster.php`
- Update: `api/app/Modules/Messaging/Resources/MessageResource.php`
- Test: `api/tests/Feature/Messaging/MessagePostWithAttachmentsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\Message;
use App\Models\MessageAttachmentUpload;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class MessagePostWithAttachmentsTest extends TestCase
{
    use RefreshDatabase;

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

    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', 'order');
    }

    private function stagedUpload(User $user, Order $order): MessageAttachmentUpload
    {
        $upload = MessageAttachmentUpload::create([
            'order_id' => $order->id,
            'user_id' => $user->id,
        ]);
        $upload->addMedia(UploadedFile::fake()->image('a.jpg', 400, 400)->getPathname())
            ->toMediaCollection('staged');
        return $upload;
    }

    public function test_post_with_attachment_ids_moves_media_and_returns_urls(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        $up1 = $this->stagedUpload($buyer, $order);
        $up2 = $this->stagedUpload($buyer, $order);

        Sanctum::actingAs($buyer);

        $resp = $this->postJson("/v1/orders/{$order->id}/messages", [
            'body' => 'photos attached',
            'attachment_ids' => [$up1->id, $up2->id],
        ])->assertCreated();

        $resp->assertJsonCount(2, 'data.attachments');
        $resp->assertJsonStructure([
            'data' => ['attachments' => [['url', 'thumb_url', 'content_type', 'size_bytes']]],
        ]);

        // Upload rows are deleted after the move
        $this->assertDatabaseMissing('message_attachment_uploads', ['id' => $up1->id]);
        $this->assertDatabaseMissing('message_attachment_uploads', ['id' => $up2->id]);

        $msg = Message::query()->latest()->first();
        $this->assertCount(2, $msg->getMedia('message_attachments'));
    }

    public function test_post_rejects_attachment_owned_by_another_user(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        $someoneElse = User::factory()->create();
        $stolen = $this->stagedUpload($someoneElse, $order);

        Sanctum::actingAs($buyer);

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

    public function test_post_rejects_more_than_four_attachments(): void
    {
        ['buyer' => $buyer, 'order' => $order] = $this->scenario();
        $ids = collect(range(1, 5))->map(fn () => $this->stagedUpload($buyer, $order)->id)->all();
        Sanctum::actingAs($buyer);

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

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

- [ ] **Step 3: Extend `PostMessageRequest`**

```php
public function rules(): array
{
    return [
        'body' => ['required', 'string', 'min:1', 'max:5000'],
        'attachment_ids' => ['array', 'max:4'],
        'attachment_ids.*' => ['uuid', 'exists:message_attachment_uploads,id'],
    ];
}
```

- [ ] **Step 4: Update `MessagePoster::post()` to take `attachmentIds`**

Append `array $attachmentIds = []` to the method signature. Inside the transaction, after creating `$message`:

```php
if (! empty($attachmentIds)) {
    $uploads = MessageAttachmentUpload::query()
        ->whereIn('id', $attachmentIds)
        ->where('user_id', $author->id)        // ownership guard
        ->where('order_id', $order->id)        // scope guard
        ->get();

    if ($uploads->count() !== count($attachmentIds)) {
        abort(422, 'One or more attachments are invalid.');
    }

    foreach ($uploads as $upload) {
        foreach ($upload->getMedia('staged') as $media) {
            $media->move($message, 'message_attachments');
        }
        $upload->delete();
    }
}
```

The 422 here is a defensive fallback — the FormRequest's `exists:` rule + ownership in the query already block misuse, but the transaction-level check guards against TOCTOU races.

- [ ] **Step 5: Update `MessageController::store` to forward the ids**

```php
$message = $this->poster->post(
    order: $order,
    author: $user,
    role: $role,
    body: $request->validated('body'),
    attachmentIds: $request->validated('attachment_ids', []),
);
```

- [ ] **Step 6: Update `MessageResource` to render attachment URLs**

Replace the `'attachments'` key with a derivation from MediaLibrary:

```php
'attachments' => $hideContent
    ? []
    : $this->getMedia('message_attachments')->map(fn ($m) => [
        'url' => $m->getUrl(),
        'thumb_url' => $m->getUrl('thumb'),
        'content_type' => $m->mime_type,
        'size_bytes' => $m->size,
    ])->values(),
```

Note: the Plan 1 `attachments` JSON column on `messages` is now unused. Leave the column in place — it's nullable and harmless — and a separate small migration in a follow-up can drop it after confirming no callers read it.

- [ ] **Step 7: Re-run; iterate to 3/3 PASS**

The Plan 1 `MessagesStoreEndpointTest` should still pass (no `attachment_ids` ⇒ empty validated array ⇒ no-op).

### Task 4: `messages:gc-orphan-attachments` console command

**Files:**
- Create: `api/app/Console/Commands/GarbageCollectOrphanAttachments.php`
- Update: `api/routes/console.php` (or `app/Console/Kernel.php`) — daily schedule
- Test: `api/tests/Feature/Messaging/GarbageCollectOrphanAttachmentsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Messaging;

use App\Models\MessageAttachmentUpload;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class GarbageCollectOrphanAttachmentsTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        Storage::fake('public');
    }

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

        $stale = MessageAttachmentUpload::create(['order_id' => $order->id, 'user_id' => $user->id]);
        $stale->addMedia(UploadedFile::fake()->image('old.jpg')->getPathname())
            ->toMediaCollection('staged');
        $stale->update(['created_at' => now()->subDays(2)]);

        $fresh = MessageAttachmentUpload::create(['order_id' => $order->id, 'user_id' => $user->id]);
        $fresh->addMedia(UploadedFile::fake()->image('new.jpg')->getPathname())
            ->toMediaCollection('staged');

        Artisan::call('messages:gc-orphan-attachments');

        $this->assertDatabaseMissing('message_attachment_uploads', ['id' => $stale->id]);
        $this->assertDatabaseHas('message_attachment_uploads', ['id' => $fresh->id]);
    }
}
```

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

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

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\MessageAttachmentUpload;
use Illuminate\Console\Command;

class GarbageCollectOrphanAttachments extends Command
{
    protected $signature = 'messages:gc-orphan-attachments {--hours=24}';
    protected $description = 'Delete MessageAttachmentUpload rows older than the given window. Cascades media via Spatie.';

    public function handle(): int
    {
        $cutoff = now()->subHours((int) $this->option('hours'));

        $stale = MessageAttachmentUpload::query()
            ->where('created_at', '<', $cutoff)
            ->get();

        foreach ($stale as $upload) {
            $upload->delete(); // Spatie cascades media on the parent's deletion
        }

        $this->info("Deleted {$stale->count()} orphan upload(s).");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Schedule it daily**

In `api/routes/console.php` (Laravel 11 idiom):

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('messages:gc-orphan-attachments')->dailyAt('03:30');
```

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

---

## Phase B — Threads index endpoint

### Task 5: `MessageThreadSummaryResource`

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

(No standalone test — exercised by the controller tests in Task 6.)

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Resources;

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

/**
 * Carries the additional `unread_count` and `counterparty_name` fields
 * computed by MyThreadsController in `additional()`. The base attributes
 * are read off the underlying MessageThread model.
 */
class MessageThreadSummaryResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        $latest = $this->messages->last();

        return [
            'thread_id' => $this->id,
            'order_id' => $this->order_id,
            'counterparty_name' => $this->additional['counterparty_name'] ?? null,
            'unread_count' => $this->additional['unread_count'] ?? 0,
            'last_message_snippet' => $latest
                ? mb_strimwidth($latest->body ?? '', 0, 140, '…')
                : null,
            'last_message_at' => $latest?->created_at->toIso8601String(),
        ];
    }
}
```

> **Plan note:** `JsonResource::$additional` is the conventional bag for fields that aren't on the underlying model. The controller hydrates it via `->additional([...])` in Task 6.

### Task 6: `MyThreadsController` + `/v1/me/threads`

**Files:**
- Create: `api/app/Modules/Messaging/Controllers/MyThreadsController.php`
- Update: `api/app/Modules/Messaging/routes.php`
- Test: `api/tests/Feature/Messaging/MyThreadsEndpointTest.php`

The endpoint returns the union of:
- threads on Orders where `purchase.buyer_id = me.id` (buyer view: counterparty = store name)
- threads on Orders where `store.owner_user_id = me.id` (seller view: counterparty = buyer first name + last initial)

…sorted by latest activity. Per-thread `unread_count = count(messages where created_at > my last_read_at AND author_user_id != me.id AND deleted_at IS NULL)`.

- [ ] **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 Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class MyThreadsEndpointTest extends TestCase
{
    use RefreshDatabase;

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

    public function test_unauthenticated_returns_401(): void
    {
        $this->getJson('/v1/me/threads')->assertUnauthorized();
    }

    public function test_buyer_sees_their_threads_only_with_store_as_counterparty(): 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]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'body' => 'Tracking soon.',
        ]);

        // Unrelated thread that should NOT show up
        $other = Order::factory()->create();
        MessageThread::factory()->create(['order_id' => $other->id]);

        Sanctum::actingAs($buyer);

        $this->getJson('/v1/me/threads')
            ->assertOk()
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.counterparty_name', 'Revive Boutique')
            ->assertJsonPath('data.0.last_message_snippet', 'Tracking soon.');
    }

    public function test_unread_count_excludes_my_own_messages_and_deleted(): 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]);

        // Two from the seller (unread to the buyer), one from the buyer (own), one deleted
        Message::factory()->count(2)->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
        ]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
        ]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'deleted_at' => now(),
        ]);

        Sanctum::actingAs($buyer);

        $this->getJson('/v1/me/threads')
            ->assertOk()
            ->assertJsonPath('data.0.unread_count', 2);
    }

    public function test_last_read_at_drives_unread_count(): 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]);

        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'created_at' => now()->subHour(),
        ]);

        // Buyer read at 30 min ago — first message should be read
        DB::table('message_thread_reads')->insert([
            'thread_id' => $thread->id,
            'user_id' => $buyer->id,
            'last_read_at' => now()->subMinutes(30),
        ]);

        // New seller message after the read
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $seller->id,
            'author_role' => MessageRole::Seller,
            'created_at' => now()->subMinutes(10),
        ]);

        Sanctum::actingAs($buyer);

        $this->getJson('/v1/me/threads')
            ->assertOk()
            ->assertJsonPath('data.0.unread_count', 1);
    }

    public function test_seller_sees_threads_on_their_stores(): void
    {
        $buyer = User::factory()->create(['first_name' => 'Jane', 'last_name' => 'Doe']);
        $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]);
        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
        ]);

        Sanctum::actingAs($seller);

        $this->getJson('/v1/me/threads')
            ->assertOk()
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.counterparty_name', 'Jane D.');
    }
}
```

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

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

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

```php
<?php

declare(strict_types=1);

namespace App\Modules\Messaging\Controllers;

use App\Models\Message;
use App\Models\MessageThread;
use App\Modules\Messaging\Resources\MessageThreadSummaryResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class MyThreadsController
{
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        $userId = $user->id;

        // Threads I participate in: buyer of the purchase OR owner of the store.
        $threads = MessageThread::query()
            ->with(['order.purchase.buyer', 'order.store', 'messages'])
            ->whereHas('order.purchase', fn ($q) => $q->where('buyer_id', $userId))
            ->orWhereHas('order.store', fn ($q) => $q->where('owner_user_id', $userId))
            ->get();

        // Pre-load read marks
        $reads = DB::table('message_thread_reads')
            ->where('user_id', $userId)
            ->whereIn('thread_id', $threads->pluck('id'))
            ->pluck('last_read_at', 'thread_id');

        $resources = $threads
            ->map(function (MessageThread $thread) use ($user, $reads) {
                $isBuyer = $thread->order?->purchase?->buyer_id === $user->id;

                $counterparty = $isBuyer
                    ? ($thread->order->store->name ?? '—')
                    : $this->shortBuyerName($thread->order->purchase->buyer ?? null);

                $lastReadAt = $reads[$thread->id] ?? null;

                $unreadQuery = Message::query()
                    ->where('thread_id', $thread->id)
                    ->whereNull('deleted_at')
                    ->where('author_user_id', '!=', $user->id);

                if ($lastReadAt) {
                    $unreadQuery->where('created_at', '>', $lastReadAt);
                }

                return (new MessageThreadSummaryResource($thread))->additional([
                    'counterparty_name' => $counterparty,
                    'unread_count' => $unreadQuery->count(),
                ]);
            })
            ->sortByDesc(fn ($r) => optional($r->resource->messages->last())->created_at)
            ->values();

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

    private function shortBuyerName(?\App\Models\User $buyer): string
    {
        if (! $buyer) return '—';
        $first = $buyer->first_name ?? 'Buyer';
        $lastInitial = $buyer->last_name ? strtoupper(substr($buyer->last_name, 0, 1)).'.' : '';
        return trim("$first $lastInitial");
    }
}
```

- [ ] **Step 4: Wire the route**

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

```php
Route::get('/me/threads', [MyThreadsController::class, 'index']);
```

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

If `User` does not have `first_name`/`last_name` columns, look for `name`/`profile` and adjust `shortBuyerName()` to whatever the existing seller-orders search-q uses (the seller orders endpoint already searches by buyer first/last — copy that source).

---

## Phase C — OpenAPI + types

### Task 7: OpenAPI updates + sync + types regen

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

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

Under the existing `/v1/messages/{message}` block:

```yaml
  /v1/orders/{order}/messages/attachments:
    post:
      operationId: uploadMessageAttachment
      summary: Stage an image attachment for a subsequent message POST
      tags: [Messaging]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: order, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: JPEG, PNG, or HEIC, ≤ 5 MB
      responses:
        '201':
          description: Attachment id (use as element of `attachment_ids` on POST /messages)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/MessageAttachmentUpload' }
        '403': { description: Not a participant, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '422': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/ValidationError' } } } }

  /v1/me/threads:
    get:
      operationId: listMyThreads
      summary: My participating message threads (buyer + seller union)
      tags: [Messaging]
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Thread summaries sorted by latest activity
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MessageThreadSummary' }
        '401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
```

- [ ] **Step 2: Update the `Message` schema's `attachments` shape**

Find the Plan 1 `Message` schema and replace the `attachments` field with:

```yaml
        attachments:
          type: array
          items:
            type: object
            required: [url, thumb_url, content_type, size_bytes]
            properties:
              url: { type: string }
              thumb_url: { type: string }
              content_type: { type: string }
              size_bytes: { type: integer }
```

Also, in the `POST /v1/orders/{order}/messages` requestBody, add `attachment_ids` to `properties` and bump `required` semantics:

```yaml
              properties:
                body: { type: string, minLength: 1, maxLength: 5000 }
                attachment_ids:
                  type: array
                  maxItems: 4
                  items: { type: string, format: uuid }
```

- [ ] **Step 3: Append the two new schemas**

```yaml
    MessageAttachmentUpload:
      type: object
      required: [id, url, content_type, size_bytes]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        content_type: { type: string }
        size_bytes: { type: integer }

    MessageThreadSummary:
      type: object
      required: [thread_id, order_id, unread_count]
      properties:
        thread_id: { type: string, format: uuid }
        order_id: { type: string, format: uuid }
        counterparty_name: { type: [string, 'null'] }
        unread_count: { type: integer, minimum: 0 }
        last_message_snippet: { type: [string, 'null'] }
        last_message_at: { type: [string, 'null'], format: date-time }
```

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

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

- [ ] **Step 5: Sync to alqove-web and regenerate types**

```
cd ~/projects/alqove-web
./bin/sync-openapi.sh
npm run build:types
```

Confirm `web/packages/types/src/generated.ts` contains `MessageAttachmentUpload`, `MessageThreadSummary`, and the updated `Message.attachments` shape.

---

## Phase D — api-client

### Task 8: `messages.uploadAttachment()` + updated Message type

**Files:**
- Update: `web/packages/api-client/src/endpoints/messages.ts`
- Update: `web/packages/api-client/src/index.ts` (re-exports)

- [ ] **Step 1: Replace the `MessageAttachment` interface and add the upload helper**

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

export interface MessageAttachmentUpload {
  id: string;
  url: string;
  content_type: string;
  size_bytes: number;
}

export interface PostMessageBody {
  body: string;
  attachment_ids?: 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<MessageResponse>(`/v1/orders/${orderId}/messages`, body);
    },
    delete(messageId: string) {
      return client.delete<void>(`/v1/messages/${messageId}`);
    },
    uploadAttachment(orderId: string, file: File) {
      const fd = new FormData();
      fd.append('file', file);
      return client.postFormData<{ data: MessageAttachmentUpload }>(
        `/v1/orders/${orderId}/messages/attachments`,
        fd,
      );
    },
  };
}
```

- [ ] **Step 2: Re-export the new type from `index.ts`**

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

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

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

Expected: clean.

### Task 9: `me.threads` namespace

**Files:**
- Create: `web/packages/api-client/src/endpoints/me.ts`
- Update: `web/packages/api-client/src/index.ts`
- Update: `web/src/lib/api.ts`

- [ ] **Step 1: Create the endpoint module**

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

export interface MessageThreadSummary {
  thread_id: string;
  order_id: string;
  counterparty_name: string | null;
  unread_count: number;
  last_message_snippet: string | null;
  last_message_at: string | null;
}

export interface MyThreadsResponse {
  data: MessageThreadSummary[];
}

export function createMeEndpoints(client: AlqoveClient) {
  return {
    threads() {
      return client.get<MyThreadsResponse>('/v1/me/threads');
    },
  };
}
```

- [ ] **Step 2: Wire it up**

In `web/packages/api-client/src/index.ts`:

```ts
export { createMeEndpoints } from './endpoints/me';
export type { MessageThreadSummary, MyThreadsResponse } from './endpoints/me';
```

In `web/src/lib/api.ts`:

```ts
import { /* ... */ createMeEndpoints } from '@alqove/api-client';

export const api = {
  // ...
  me: createMeEndpoints(client),
};
```

- [ ] **Step 3: Typecheck root + web** — clean.

---

## Phase E — Frontend attachments

### Task 10: `<MessageLightbox>` modal

**Files:**
- Create: `web/src/components/messaging/message-lightbox.tsx`
- Create: `web/src/components/messaging/__tests__/message-lightbox.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 { MessageLightbox } from '../message-lightbox';

describe('MessageLightbox', () => {
  it('renders the active image and calls onClose on backdrop click', () => {
    const onClose = vi.fn();
    render(
      <MessageLightbox
        open
        onClose={onClose}
        attachments={[
          { url: '/a.jpg', thumb_url: '/a-thumb.jpg', content_type: 'image/jpeg', size_bytes: 1 },
          { url: '/b.jpg', thumb_url: '/b-thumb.jpg', content_type: 'image/jpeg', size_bytes: 2 },
        ]}
        startIndex={1}
      />,
    );
    const img = screen.getByRole('img', { name: /Attachment/i }) as HTMLImageElement;
    expect(img.src).toContain('/b.jpg');
    fireEvent.click(screen.getByRole('button', { name: /Close/i }));
    expect(onClose).toHaveBeenCalled();
  });

  it('arrow keys advance + retreat through attachments', () => {
    render(
      <MessageLightbox
        open
        onClose={() => {}}
        attachments={[
          { url: '/a.jpg', thumb_url: '/a-thumb.jpg', content_type: 'image/jpeg', size_bytes: 1 },
          { url: '/b.jpg', thumb_url: '/b-thumb.jpg', content_type: 'image/jpeg', size_bytes: 2 },
        ]}
        startIndex={0}
      />,
    );
    fireEvent.keyDown(document, { key: 'ArrowRight' });
    expect((screen.getByRole('img', { name: /Attachment/i }) as HTMLImageElement).src).toContain('/b.jpg');
    fireEvent.keyDown(document, { key: 'ArrowLeft' });
    expect((screen.getByRole('img', { name: /Attachment/i }) as HTMLImageElement).src).toContain('/a.jpg');
  });
});
```

- [ ] **Step 2: Implement** — a minimal Tailwind-only modal (no Radix needed):

```tsx
'use client';

import { useEffect, useState } from 'react';
import type { MessageAttachment } from '@alqove/api-client';

interface Props {
  open: boolean;
  onClose: () => void;
  attachments: MessageAttachment[];
  startIndex?: number;
}

export function MessageLightbox({ open, onClose, attachments, startIndex = 0 }: Props) {
  const [index, setIndex] = useState(startIndex);

  useEffect(() => {
    if (open) setIndex(startIndex);
  }, [open, startIndex]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'ArrowRight') setIndex((i) => Math.min(i + 1, attachments.length - 1));
      if (e.key === 'ArrowLeft') setIndex((i) => Math.max(i - 1, 0));
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, attachments.length, onClose]);

  if (!open) return null;
  const current = attachments[index];

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
      onClick={onClose}
    >
      <button
        type="button"
        onClick={(e) => { e.stopPropagation(); onClose(); }}
        aria-label="Close"
        className="absolute right-4 top-4 rounded-full bg-white/10 px-3 py-1 text-white hover:bg-white/20"
      >
        ✕
      </button>
      <img
        src={current.url}
        alt={`Attachment ${index + 1} of ${attachments.length}`}
        className="max-h-[90vh] max-w-full object-contain"
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  );
}
```

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

### Task 11: Render thumbnails in `<MessageRow>`

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

- [ ] **Step 1: Extend the existing test file with two new cases**

```tsx
it('renders a thumbnail grid when attachments are present', () => {
  render(
    <MessageRow
      message={{
        ...baseMessage,
        attachments: [
          { url: '/a.jpg', thumb_url: '/a-thumb.jpg', content_type: 'image/jpeg', size_bytes: 1 },
          { url: '/b.jpg', thumb_url: '/b-thumb.jpg', content_type: 'image/jpeg', size_bytes: 2 },
        ],
      }}
      viewerUserId="u1"
    />,
  );
  expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(2);
});

it('clicking a thumbnail opens the lightbox', () => {
  render(
    <MessageRow
      message={{
        ...baseMessage,
        attachments: [
          { url: '/a.jpg', thumb_url: '/a-thumb.jpg', content_type: 'image/jpeg', size_bytes: 1 },
        ],
      }}
      viewerUserId="u1"
    />,
  );
  fireEvent.click(screen.getByRole('button', { name: /Open attachment/i }));
  // The lightbox renders an img with the full URL
  const lightboxImg = screen.getAllByRole('img').find(
    (el) => (el as HTMLImageElement).src.endsWith('/a.jpg'),
  );
  expect(lightboxImg).toBeDefined();
});
```

(Add `fireEvent` to the imports.)

- [ ] **Step 2: Update `<MessageRow>`**

Inside the rendered message JSX, after the body `<p>`:

```tsx
import { useState } from 'react';
import { MessageLightbox } from './message-lightbox';

// inside the component
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const attachments = message.attachments ?? [];

// after the body <p>:
{attachments.length > 0 && (
  <div className={`mt-1 grid gap-1 max-w-[80%] ${attachments.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
    {attachments.map((a, i) => (
      <button
        key={a.url}
        type="button"
        onClick={() => setLightboxIndex(i)}
        aria-label={`Open attachment ${i + 1}`}
        className="overflow-hidden rounded-md border border-slate-200 bg-white"
      >
        <img
          src={a.thumb_url}
          alt=""
          className="h-32 w-full object-cover"
          loading="lazy"
        />
      </button>
    ))}
  </div>
)}
<MessageLightbox
  open={lightboxIndex !== null}
  onClose={() => setLightboxIndex(null)}
  attachments={attachments}
  startIndex={lightboxIndex ?? 0}
/>
```

- [ ] **Step 3: Run; confirm all 5 row tests PASS**

### Task 12: `<AttachmentUploader>` + `usePostMessage` extension

**Files:**
- Create: `web/src/components/messaging/attachment-uploader.tsx`
- Create: `web/src/components/messaging/__tests__/attachment-uploader.test.tsx`
- Update: `web/src/lib/queries/use-messages.ts`

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

```tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { AttachmentUploader } from '../attachment-uploader';

const uploadMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    messages: { uploadAttachment: (...a: unknown[]) => uploadMock(...a) },
  },
}));

describe('AttachmentUploader', () => {
  beforeEach(() => uploadMock.mockReset());

  it('uploads selected files and invokes onChange with attachment ids', async () => {
    uploadMock.mockResolvedValueOnce({
      data: { id: 'att-1', url: '/u1.jpg', content_type: 'image/jpeg', size_bytes: 100 },
    });
    const onChange = vi.fn();
    render(<AttachmentUploader orderId="o1" onChange={onChange} />);

    const file = new File(['x'], 'damage.jpg', { type: 'image/jpeg' });
    fireEvent.change(screen.getByLabelText(/attach/i), { target: { files: [file] } });

    await waitFor(() => expect(onChange).toHaveBeenCalledWith(['att-1']));
  });

  it('caps at 4 attachments', async () => {
    render(<AttachmentUploader orderId="o1" onChange={() => {}} />);
    // four files first
    uploadMock.mockResolvedValue({
      data: { id: 'a', url: '/x.jpg', content_type: 'image/jpeg', size_bytes: 1 },
    });
    const four = [0, 1, 2, 3].map((i) => new File(['x'], `${i}.jpg`, { type: 'image/jpeg' }));
    fireEvent.change(screen.getByLabelText(/attach/i), { target: { files: four } });
    await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(4));

    // fifth click should be blocked
    fireEvent.change(screen.getByLabelText(/attach/i), {
      target: { files: [new File(['x'], 'fifth.jpg', { type: 'image/jpeg' })] },
    });
    expect(screen.getByText(/Limit/i)).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Implement `<AttachmentUploader>`**

```tsx
'use client';

import { useRef, useState } from 'react';
import { api } from '@/lib/api';
import type { MessageAttachmentUpload } from '@alqove/api-client';

interface StagedAttachment extends MessageAttachmentUpload {
  // narrow client-side ergonomic alias
}

interface Props {
  orderId: string;
  onChange: (ids: string[]) => void;
  disabled?: boolean;
}

const MAX = 4;

export function AttachmentUploader({ orderId, onChange, disabled }: Props) {
  const [staged, setStaged] = useState<StagedAttachment[]>([]);
  const [error, setError] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleFiles = async (fileList: FileList | null) => {
    if (!fileList) return;
    const files = Array.from(fileList);
    if (staged.length + files.length > MAX) {
      setError(`Limit ${MAX} attachments per message.`);
      return;
    }
    setError(null);

    const uploaded: StagedAttachment[] = [];
    for (const file of files) {
      try {
        const resp = await api.messages.uploadAttachment(orderId, file);
        uploaded.push(resp.data);
      } catch (_e) {
        setError('Upload failed. Try a smaller image (≤ 5 MB) in JPEG, PNG, or HEIC.');
      }
    }

    const next = [...staged, ...uploaded];
    setStaged(next);
    onChange(next.map((a) => a.id));
    if (inputRef.current) inputRef.current.value = '';
  };

  const remove = (id: string) => {
    const next = staged.filter((a) => a.id !== id);
    setStaged(next);
    onChange(next.map((a) => a.id));
  };

  return (
    <div>
      <div className="flex flex-wrap gap-2">
        {staged.map((a) => (
          <div key={a.id} className="relative h-16 w-16 overflow-hidden rounded border border-slate-200">
            <img src={a.url} alt="" className="h-full w-full object-cover" />
            <button
              type="button"
              onClick={() => remove(a.id)}
              aria-label={`Remove attachment ${a.id}`}
              className="absolute right-0 top-0 m-0.5 rounded-full bg-black/60 px-1 text-xs text-white hover:bg-black/80"
            >
              ✕
            </button>
          </div>
        ))}
      </div>

      <label className="mt-2 inline-block cursor-pointer text-xs text-emerald-700 hover:underline">
        Attach photos
        <input
          ref={inputRef}
          type="file"
          accept="image/jpeg,image/png,image/heic"
          multiple
          aria-label="Attach images"
          className="hidden"
          disabled={disabled}
          onChange={(e) => handleFiles(e.target.files)}
        />
      </label>
      {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
    </div>
  );
}
```

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

- [ ] **Step 4: Update `usePostMessage` to accept `attachment_ids`**

The hook already passes `body` straight through. Plan 1 typed `PostMessageBody = { body: string }`; the api-client now exports `PostMessageBody = { body: string; attachment_ids?: string[] }`. The hook's `mutationFn` is `(body: PostMessageBody) => api.messages.post(orderId, body)` — no change needed beyond the type alias picking up the wider field automatically.

In the optimistic-insert branch, render the attachments client-side using the staged uploads' urls. Update:

```ts
const optimistic: Message = {
  // ...existing fields...
  attachments: (body.attachment_ids ?? []).map((id) => ({
    url: '',
    thumb_url: '',
    content_type: 'image/*',
    size_bytes: 0,
  })),
  // ...
};
```

The optimistic row will show a placeholder grid until the server response arrives with real `url`/`thumb_url`. (Optional polish; won't break tests.)

### Task 13: Wire `<AttachmentUploader>` into `<MessageThread>`

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

- [ ] **Step 1: Add a third test**

```tsx
it('posts the message with attachment_ids when uploader staged a file', async () => {
  listMock.mockResolvedValue({ data: messages, meta: { total: 2, has_more: false } });
  postMock.mockResolvedValue({ data: messages[0] });

  // mock the uploader's call too
  // (api is already mocked at the file level — extend it)
  // see: vi.mock at top of file. Add `uploadAttachment` to messages.
  // ...
});
```

For the third test, you also need to extend the `vi.mock('@/lib/api', ...)` block at the top to add `uploadAttachment: (...a) => uploadMock(...a)`. Then drive a file change on the uploader and assert `postMock` got `{ body: '...', attachment_ids: ['att-1'] }`.

- [ ] **Step 2: Update `<MessageThread>`**

```tsx
import { AttachmentUploader } from './attachment-uploader';

export function MessageThread(/* ... */) {
  const [pendingIds, setPendingIds] = useState<string[]>([]);
  // ...
  const onSend = (body: string) => {
    post.mutate(
      { body, attachment_ids: pendingIds.length ? pendingIds : undefined },
      { onSettled: () => setPendingIds([]) },
    );
  };
  // ...
  return (
    <div className="flex flex-col">
      {/* ... message list ... */}
      <AttachmentUploader orderId={orderId} onChange={setPendingIds} disabled={post.isPending} />
      <MessageComposer onSend={onSend} isPending={post.isPending} placeholder={...} />
    </div>
  );
}
```

- [ ] **Step 3: Run all messaging tests; confirm green**

---

## Phase F — Frontend seller surfaces

### Task 14: `useMyThreads` hook

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

```ts
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

export const MY_THREADS_KEY = ['me', 'threads'] as const;

export function useMyThreads() {
  return useQuery({
    queryKey: MY_THREADS_KEY,
    queryFn: () => api.me.threads(),
    staleTime: 15_000,
    refetchInterval: 30_000,
    refetchOnWindowFocus: true,
  });
}
```

Also extend `usePostMessage`'s `onSettled` in `use-messages.ts` to invalidate `MY_THREADS_KEY`:

```ts
onSettled: () => {
  qc.invalidateQueries({ queryKey: MESSAGE_KEYS.thread(orderId) });
  qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
  qc.invalidateQueries({ queryKey: ['me', 'threads'] });
},
```

### Task 15: `/seller/inbox/messages` route

**Files:**
- Create: `web/src/app/(seller)/seller/inbox/messages/page.tsx`
- Create: `web/src/app/(seller)/seller/inbox/messages/messages-inbox-client.tsx`
- Create: `web/src/app/(seller)/seller/inbox/messages/__tests__/messages-inbox-client.test.tsx`

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

```tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessagesInboxClient } from '../messages-inbox-client';

const threadsMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: { me: { threads: () => threadsMock() } },
}));

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

describe('MessagesInboxClient', () => {
  beforeEach(() => threadsMock.mockReset());

  it('renders rows with counterparty and unread count', async () => {
    threadsMock.mockResolvedValue({
      data: [
        {
          thread_id: 't1',
          order_id: 'o1',
          counterparty_name: 'Jane D.',
          unread_count: 2,
          last_message_snippet: 'Where is my package?',
          last_message_at: '2026-05-06T10:00:00Z',
        },
      ],
    });

    render(wrap(<MessagesInboxClient />));
    await waitFor(() => expect(screen.getByText('Jane D.')).toBeInTheDocument());
    expect(screen.getByText(/Where is my package/)).toBeInTheDocument();
    expect(screen.getByText('2')).toBeInTheDocument();
  });

  it('shows empty state when no threads', async () => {
    threadsMock.mockResolvedValue({ data: [] });
    render(wrap(<MessagesInboxClient />));
    await waitFor(() => expect(screen.getByText(/No conversations/i)).toBeInTheDocument());
  });
});
```

- [ ] **Step 2: Implement `MessagesInboxClient`**

```tsx
'use client';

import Link from 'next/link';
import { useMyThreads } from '@/lib/queries/use-my-threads';

export function MessagesInboxClient() {
  const { data, isLoading, isError } = useMyThreads();
  const rows = data?.data ?? [];

  return (
    <div className="mx-auto max-w-3xl px-4 py-6">
      <h1 className="text-xl font-bold text-slate-900">Messages</h1>
      <p className="mt-1 text-sm text-slate-500">Conversations across all your orders.</p>

      {isError && (
        <p className="mt-4 rounded bg-red-50 p-3 text-sm text-red-700">
          Couldn&apos;t load your messages.
        </p>
      )}

      <div className="mt-4 divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
        {isLoading && <p className="px-4 py-6 text-sm text-slate-400">Loading…</p>}
        {!isLoading && rows.length === 0 && (
          <p className="px-4 py-8 text-center text-sm text-slate-400">
            No conversations yet.
          </p>
        )}
        {rows.map((t) => (
          <Link
            key={t.thread_id}
            href={`/seller/orders/${t.order_id}#messages`}
            className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-slate-50"
          >
            <div className="min-w-0">
              <div className="font-medium text-slate-900">{t.counterparty_name ?? '—'}</div>
              <div className="truncate text-sm text-slate-500">
                {t.last_message_snippet ?? 'No messages yet.'}
              </div>
            </div>
            <div className="flex items-center gap-3 text-xs text-slate-400">
              {t.last_message_at && (
                <time>{new Date(t.last_message_at).toLocaleDateString()}</time>
              )}
              {t.unread_count > 0 && (
                <span className="rounded-full bg-emerald-600 px-2 py-0.5 text-white">
                  {t.unread_count}
                </span>
              )}
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Page wrapper**

```tsx
import { MessagesInboxClient } from './messages-inbox-client';

export const metadata = { title: 'Messages | Seller' };

export default function Page() {
  return <MessagesInboxClient />;
}
```

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

### Task 16: Seller messages panel on `/seller/orders/[id]`

**Files:**
- Update: `web/src/app/(seller)/seller/orders/[id]/order-detail-client.tsx` (or whichever file owns the seller order detail)

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

Open `web/src/app/(seller)/seller/orders/[id]/` and find the client component (likely `order-detail-client.tsx`). Identify the Timeline section and the Payout section — the spec wants Messages between them.

- [ ] **Step 2: Plumb the seller user id**

The component already loads the order detail (and probably has a `useCurrentUser`-style hook). The `<MessageThread>` needs the seller's user id for the "You" / counterparty label logic. Pass it through.

- [ ] **Step 3: Render**

```tsx
import { MessageThread } from '@/components/messaging/message-thread';
// ...
<section id="messages" className="mt-6 rounded-lg border border-slate-200 bg-white p-5">
  <h2 className="font-semibold text-slate-900">Messages</h2>
  <p className="mt-1 text-xs text-slate-500">
    Conversation with the buyer about this order.
  </p>
  <div className="mt-3">
    <MessageThread
      orderId={order.id}
      counterpartyName={order.buyer ? `${order.buyer.first_name ?? ''} ${order.buyer.last_name ? order.buyer.last_name.charAt(0) + '.' : ''}`.trim() || 'Buyer' : 'Buyer'}
      viewerRole="seller"
      viewerUserId={currentUserId}
    />
  </div>
</section>
```

The `id="messages"` anchor lets the inbox row's `#messages` hash deep-link straight to the panel.

- [ ] **Step 4: If a test exists for the seller order detail, extend it**

Add a smoke test that mocks `api.messages.list` returning empty and asserts the section renders.

### Task 17: Seller sidebar — messages link (optional polish)

**File:** `web/src/app/(seller)/layout.tsx` (or wherever the seller nav lives)

- [ ] **Step 1: Add a "Messages" entry** to the seller sidebar pointing at `/seller/inbox/messages`. Keep the existing Inbox/Notifications entry unchanged. This is a 2-line edit; no test needed.

---

## Phase G — Frontend buyer unread badge

### Task 18: Wire `useMyThreads` 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: Extend the existing test**

Add a new case using the existing `messagesListMock` plus a new `threadsMock`:

```tsx
const threadsMock = 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() },
    me: { threads: () => threadsMock() },
  },
}));

it('shows unread badge on order with unread messages', async () => {
  getMock.mockResolvedValue({ data: basePurchase });
  messagesListMock.mockResolvedValue({ data: [], meta: { total: 0, has_more: false } });
  threadsMock.mockResolvedValue({
    data: [
      {
        thread_id: 't1',
        order_id: baseOrder.id,
        counterparty_name: 'Revive',
        unread_count: 3,
        last_message_snippet: 'hi',
        last_message_at: '2026-05-06T10:00:00Z',
      },
    ],
  });

  await renderAndFlush(wrap(<PurchaseDetailClient params={makeParams()} />));
  expect(await screen.findByText(/3 new/i)).toBeInTheDocument();
});
```

- [ ] **Step 2: Update `<OrderCard>`**

Inside the component (above the existing JSX):

```tsx
import { useMyThreads } from '@/lib/queries/use-my-threads';
// ...

function OrderCard({ order, purchaseId, buyerUserId }) {
  const { data: threadsResp } = useMyThreads();
  const unread =
    threadsResp?.data.find((t) => t.order_id === order.id)?.unread_count ?? 0;
  const [showMessages, setShowMessages] = useState(false);
  // ...
}
```

In the `<summary>`:

```tsx
<summary className="cursor-pointer select-none text-sm font-medium text-slate-700">
  Messages with {order.store.name}
  {unread > 0 && (
    <span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-xs text-white">
      {unread} new
    </span>
  )}
</summary>
```

Remove the `// TODO(Plan 2): unread badge` comment.

- [ ] **Step 3: Run; iterate to 7/7 purchase-detail tests PASS**

---

## Phase H — Wrap-up

### Task 19: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: 447 → ≥ **463 passing** (2 media collection + 4 upload + 3 post-with-attachments + 1 GC + 4 my-threads = 14, plus a small amount of overlap).
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint app/Modules/Messaging app/Models/Message.php app/Models/MessageAttachmentUpload.php app/Console/Commands/GarbageCollectOrphanAttachments.php tests/Feature/Messaging`. Expected: PASS or auto-fix.
- [ ] **Step 3: Web typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Expected: clean.
- [ ] **Step 4: Web lint** — `npm run lint`. Expected: same baseline (5 pre-existing img warnings).
- [ ] **Step 5: Web tests** — `npm run test`. Expected: 150 → ≥ **162 passing** (2 lightbox + 2 row attachments + 2 uploader + 1 thread attachments + 2 inbox + 1 buyer badge + 1 seller smoke = 11; allow ±2 for measurement noise).
- [ ] **Step 6: Manual QA**
  - As buyer on `/purchases/<id>`: expand the messages section, attach a real JPEG, type a message, send. Confirm the thumbnail renders inline and clicking opens the lightbox.
  - Switch to the seller account: confirm `/seller/inbox/messages` lists the thread with `1` unread badge. Click → lands on `/seller/orders/<order>#messages` panel scrolled into view.
  - Reply as the seller; switch back to the buyer; refresh `/purchases/<id>`; confirm the order's section header shows "1 new" badge.
  - Run `php artisan messages:gc-orphan-attachments --hours=0` after staging an upload but not posting; confirm the upload row is gone.

### Task 20: Commit + push

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

```
git add app contracts database routes config tests docs
git commit -m "feat(messaging): attachments, /me/threads, orphan-cleanup"
```

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

```
git add packages web contracts
git commit -m "feat(messaging): attachments + seller inbox + buyer unread badge"
```

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

---

## Open items deferred to follow-up plans

- **Cursor pagination on `GET /messages`** — the OpenAPI already declares `?after=`. A small follow-up converts `MessageController::index` to slice on `created_at > (lookup created_at of the `after` id)` and updates the hook to merge pages. Land alongside Plan 3 or as standalone.
- **Drop the legacy `attachments` JSON column on `messages`** — Plan 1 introduced it; Plan 2 made it unused. Schedule a small migration to `Schema::table('messages', fn ($t) => $t->dropColumn('attachments'))` after one deploy cycle of confirming no callers read it.
- **HEIC client-side preview** — browsers don't render HEIC inline reliably. The thumbnail conversion uses Spatie's image driver to emit WebP, so server-rendered thumbs are fine, but the optimistic preview during upload may show a broken-image icon for HEIC sources. Acceptable for Plan 2; revisit if it surfaces in support.
- **Mobile camera capture** — out of scope for this plan; defer to a mobile-focused layer.
- **Seller-side rate limiting** — `POST attachments` currently inherits the api group's 60/min throttle. The spec calls for 10/min on uploads; bump in a small follow-up if abuse becomes visible in metrics. The per-message body POST should bump to 30/min at the same time (the spec's number).
- **Email-side attachment thumbnails** — `MessagePostedTo*Notification` emails carry only the body snippet today. A follow-up can render a single thumbnail in the mail body for messages with attachments; the data is already on the model.
- **Admin intervention** — the entire `/admin/orders/[id]` Messages panel, the "Post as Alqove Support" toggle, and the activity-log entries (`message.admin_posted` / `message.admin_deleted`) live in **Plan 3**.
