# Layer 9 Plan 3: Admin Intervention + Audit Log

> **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:** Wrap up Layer 9 by giving admins a moderation surface on `/admin/orders/[id]` — read every thread by default, post as "Alqove Support" via a small "Intervene" toggle, and have every admin-driven mutation written to the existing `spatie/laravel-activitylog` `admin` log. Also fix the long-standing stub bug from Plan 1: when an admin deletes a buyer/seller message, the counterparty currently sees "(deleted by author)" because `MessageRow` keys on `author_role` rather than the deleter's identity. Plan 3 surfaces a `deleted_by_admin` boolean and updates the stub copy accordingly.

**Architecture:** (1) Backend activity log — `MessagePoster` writes a `message.admin_posted` row when the post role is admin; `MessageController::destroy` writes `message.admin_deleted` whenever the deleter is an admin AND the original author was someone else. The buyer/seller self-delete path stays silent (the spec is explicit about that). (2) Backend resource — `MessageResource` gains a `deleted_by_admin` boolean computed from `deleted_by_user_id != author_user_id` so non-admin viewers can render the right stub. (3) Frontend — `<MessageRow>` reads `deleted_by_admin` instead of `author_role` for the stub copy. (4) Frontend — new `<AdminMessagesPanel>` rendered on `/admin/orders/[id]`: defaults to read-only, an "Intervene" toggle reveals the existing `<MessageThread>` reused with `viewerRole="admin"`, and a small banner explains that posts are public-facing. (5) The existing `MessageThreadAccess::roleFor` already returns `MessageRole::Admin` for any user with the admin role, and `MessagePoster` already fans out admin posts to both buyer and seller — so no auth/notification changes are required here.

**Tech Stack:** Laravel 11, Pest PHP, Postgres, `spatie/laravel-activitylog` (already wired in Layer 8), OpenAPI → `openapi-typescript`, Next.js 15, TanStack Query v5, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-06-layer-9-purchase-messaging-design.md`
**Prerequisites:** Plans 1 and 2 merged. The `<MessageThread>` component lives at `web/src/components/messaging/message-thread.tsx` and accepts `viewerRole: 'buyer' | 'seller' | 'admin'`. `MessageThreadAccess::roleFor` returns `MessageRole::Admin` when the user has the admin role. `MessagePoster::fanOut` already routes admin posts to **both** buyer and seller (one each) via the existing `MessagePostedTo*Notification` classes. The admin order detail page exists at `web/src/app/(admin)/admin/orders/[id]/order-detail-client.tsx` (Layer 8). `spatie/activitylog` is registered with the `admin` log_name; existing usage examples live in `app/Modules/Admin/Services/DisputeAdjudicator.php` (Layer 8). Last-known head: `95050cb` (api), `5064fb9` (web). Test counts at start: API **462 passing**, web **160 passing (1 skipped)**.

**Out of scope:**
- Admin canned responses / templates (deferred per the spec — "later admin tooling layer").
- Admin-only message search.
- Bulk moderation (delete-many).
- Admin-message email rendering polish — the existing `MessagePostedToBuyer/Seller` notifications already mention the post; no copy change for "from Alqove Support" this plan. (The notification's `body` is the message snippet, which is correct; what an email *visually* signals as platform-vs-counterparty can be a small follow-up.)

---

## Phase A — Backend audit log + resource field

### Task 1: `message.admin_posted` activity log entry

**Files:**
- Update: `api/app/Modules/Messaging/Services/MessagePoster.php`
- Test: `api/tests/Feature/Messaging/MessageAdminActivityLogTest.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\MessagePoster;
use App\Support\Enums\MessageRole;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;

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

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

        ['order' => $order] = $this->scenario();
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $msg = app(MessagePoster::class)->post(
            $order,
            $admin,
            MessageRole::Admin,
            'Hi — Alqove Support stepping in.',
        );

        $row = Activity::query()->where('description', 'message.admin_posted')->first();

        $this->assertNotNull($row);
        $this->assertSame('admin', $row->log_name);
        $this->assertSame($admin->id, $row->causer_id);
        $this->assertSame($msg->id, $row->subject_id);
        $this->assertSame($order->id, $row->properties['order_id']);
        $this->assertStringContainsString('Alqove Support', $row->properties['body_preview']);
    }

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

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

        $this->assertSame(0, Activity::query()->where('description', 'message.admin_posted')->count());
    }
}
```

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

Expected: zero activity rows for the admin-post case.

- [ ] **Step 3: Update `MessagePoster::post`**

After the existing `$this->fanOut(...)` call, but still inside the `DB::transaction` closure:

```php
if ($role === MessageRole::Admin) {
    activity('admin')
        ->causedBy($author)
        ->performedOn($message)
        ->withProperties([
            'thread_id' => $thread->id,
            'order_id' => $order->id,
            'body_preview' => mb_strimwidth($body, 0, 140, '…'),
            'attachment_count' => count($attachmentIds),
        ])
        ->log('message.admin_posted');
}
```

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

If the activity log is unwritten, double-check that `Spatie\Activitylog\ActivitylogServiceProvider` is registered in `bootstrap/providers.php` (it should be — Layer 8 set this up; the existing `DisputeAdjudicator` writes to the same `admin` log).

### Task 2: `message.admin_deleted` activity log entry

**Files:**
- Update: `api/app/Modules/Messaging/Controllers/MessageController.php`
- Test: extend `api/tests/Feature/Messaging/MessageAdminActivityLogTest.php`

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

In the same test file, append:

```php
public function test_admin_delete_of_someone_elses_message_writes_activity_log(): void
{
    ['buyer' => $buyer, 'order' => $order] = $this->scenario();
    $admin = User::factory()->create();
    $admin->assignRole('admin');

    $thread = \App\Models\MessageThread::factory()->create(['order_id' => $order->id]);
    $msg = \App\Models\Message::factory()->create([
        'thread_id' => $thread->id,
        'author_user_id' => $buyer->id,
        'author_role' => MessageRole::Buyer,
        'body' => 'I want to say something rude.',
    ]);

    \Laravel\Sanctum\Sanctum::actingAs($admin);

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

    $row = Activity::query()->where('description', 'message.admin_deleted')->first();
    $this->assertNotNull($row);
    $this->assertSame('admin', $row->log_name);
    $this->assertSame($admin->id, $row->causer_id);
    $this->assertSame($msg->id, $row->subject_id);
    $this->assertSame($buyer->id, $row->properties['original_author_user_id']);
    $this->assertStringContainsString('rude', $row->properties['body_preview']);
}

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

    $thread = \App\Models\MessageThread::factory()->create(['order_id' => $order->id]);
    $msg = \App\Models\Message::factory()->create([
        'thread_id' => $thread->id,
        'author_user_id' => $buyer->id,
        'author_role' => MessageRole::Buyer,
    ]);

    \Laravel\Sanctum\Sanctum::actingAs($buyer);

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

    $this->assertSame(0, Activity::query()->where('description', 'message.admin_deleted')->count());
}
```

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

- [ ] **Step 3: Update `MessageController::destroy`**

Replace the existing destroy implementation with:

```php
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);
    }

    $isAdminDeletingSomeoneElse = $isAdmin && $message->author_user_id !== $user->id;

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

    if ($isAdminDeletingSomeoneElse) {
        activity('admin')
            ->causedBy($user)
            ->performedOn($message)
            ->withProperties([
                'thread_id' => $message->thread_id,
                'message_id' => $message->id,
                'original_author_user_id' => $message->author_user_id,
                'body_preview' => mb_strimwidth((string) $message->body, 0, 140, '…'),
            ])
            ->log('message.admin_deleted');
    }

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

Note the carve-out: an admin who happens to delete *their own* admin-authored message (e.g. they posted by mistake) does NOT trigger the audit row — same logic as the spec's intent (we only audit moderation of others' content). This keeps the audit log focused.

- [ ] **Step 4: Run; iterate to 4/4 PASS** (2 prior + 2 new in this file)

### Task 3: `deleted_by_admin` field on `MessageResource`

**Files:**
- Update: `api/app/Modules/Messaging/Resources/MessageResource.php`
- Test: `api/tests/Feature/Messaging/MessageDeletedByAdminFlagTest.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 MessageDeletedByAdminFlagTest 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]);
        $thread = MessageThread::factory()->create(['order_id' => $order->id]);

        return compact('buyer', 'seller', 'order', 'thread');
    }

    public function test_admin_deleted_message_carries_deleted_by_admin_true(): void
    {
        ['buyer' => $buyer, 'order' => $order, 'thread' => $thread] = $this->scenario();
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $msg = Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
            'deleted_at' => now(),
            'deleted_by_user_id' => $admin->id,
        ]);

        Sanctum::actingAs($buyer);

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

        $this->assertTrue($row['deleted_by_admin']);
    }

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

        $msg = Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
            'deleted_at' => now(),
            'deleted_by_user_id' => $buyer->id,
        ]);

        Sanctum::actingAs($buyer);

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

        $this->assertFalse($row['deleted_by_admin']);
    }

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

        Message::factory()->create([
            'thread_id' => $thread->id,
            'author_user_id' => $buyer->id,
            'author_role' => MessageRole::Buyer,
        ]);

        Sanctum::actingAs($buyer);

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

        foreach ($resp->json('data') as $row) {
            $this->assertFalse($row['deleted_by_admin']);
        }
    }
}
```

- [ ] **Step 2: Run, confirm failure (key does not exist)**

- [ ] **Step 3: Update `MessageResource::toArray`**

After the existing `'deleted_by_user_id'` line, append:

```php
'deleted_by_admin' => $this->deleted_at !== null
    && $this->deleted_by_user_id !== null
    && $this->deleted_by_user_id !== $this->author_user_id,
```

> **Plan note:** This is a derivation, not a database column. We never persist a `deleted_by_role`; the role of the deleter is implied by "deleter ≠ author" because admins are the only non-authors permitted to delete (per the controller's auth gate). If the admin self-deletes their own admin-authored post, the flag is false — same carve-out as the audit log in Task 2.

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

---

## Phase B — OpenAPI + types

### Task 4: OpenAPI `Message` schema gains `deleted_by_admin`

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

- [ ] **Step 1: Edit the `Message` schema**

Find the `Message:` definition (last edited in Plan 2) and add to its `properties`:

```yaml
        deleted_by_admin: { type: boolean, description: "True when the message is soft-deleted and the deleter was an admin (not the author)." }
```

The `required` list does not need to change — the field is always present in the response. No new path; no other schema additions.

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

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

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

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

- [ ] **Step 4: Update the api-client `Message` interface**

In `web/packages/api-client/src/endpoints/messages.ts`, add to `Message`:

```ts
deleted_by_admin: boolean;
```

- [ ] **Step 5: Workspace typecheck** — clean.

---

## Phase C — Frontend deleted-stub fix

### Task 5: `<MessageRow>` reads `deleted_by_admin`

**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**

Replace the Plan 1 test that asserts the deleted stub (`shows a deleted stub when body is null`) and add a sibling case so both branches are covered:

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

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

Update `baseMessage` (and any test fixture that constructs a `Message` literally) to include `deleted_by_admin: false`. Search for constructors of `Message`-shaped objects across the test files:

```
git grep -l "thread_id: 't1'" web/src/components web/src/app
```

…and add `deleted_by_admin: false` to each.

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

Change:

```tsx
const isAdmin = message.author_role === 'admin';
// ...
return (
  <div className="text-xs italic text-slate-400 py-2">
    {isAdmin ? '(deleted by Alqove Support)' : '(deleted by author)'}
  </div>
);
```

…to:

```tsx
const isAdminAuthor = message.author_role === 'admin';
const deletedByAdmin = message.deleted_by_admin === true;
// ...
return (
  <div className="text-xs italic text-slate-400 py-2">
    {deletedByAdmin ? '(deleted by Alqove Support)' : '(deleted by author)'}
  </div>
);
```

The `isAdminAuthor` flag is still used downstream for the green badge / colour treatment on non-deleted admin posts; rename the local but keep the existing styling logic.

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

---

## Phase D — Admin Messages panel

### Task 6: `<AdminMessagesPanel>` component

**Files:**
- Create: `web/src/components/admin/admin-messages-panel.tsx`
- Create: `web/src/components/admin/__tests__/admin-messages-panel.test.tsx`

The panel is a thin wrapper around `<MessageThread>`:
- Read-only by default (no composer rendered).
- An "Intervene" toggle reveals the composer (we re-render `<MessageThread>` with `viewerRole="admin"`; everything else is identical).
- A small banner explains the post will be visible to both buyer and seller as "Alqove Support".

> **Plan note:** the simplest implementation is a `readOnly` prop on `<MessageThread>` that hides the `<MessageComposer>` + `<AttachmentUploader>`. We add it now as part of this task.

- [ ] **Step 1: Add a `readOnly` prop to `<MessageThread>`**

In `web/src/components/messaging/message-thread.tsx`, extend the props:

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

export function MessageThread({ /* ... */ readOnly }: Props) {
  // ...
  return (
    <div className="flex flex-col">
      {/* existing message list */}
      {!readOnly && (
        <>
          <AttachmentUploader /* ... */ />
          <MessageComposer /* ... */ />
        </>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Write the failing test for `<AdminMessagesPanel>`**

```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 { AdminMessagesPanel } from '../admin-messages-panel';

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

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

const adminProps = {
  orderId: 'o1',
  adminUserId: 'admin-1',
  buyerLabel: 'Jane D.',
  storeLabel: 'Revive Boutique',
};

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

  it('renders messages read-only by default — no composer visible', async () => {
    listMock.mockResolvedValue({
      data: [
        {
          id: 'm1',
          thread_id: 't1',
          author_user_id: 'buyer-1',
          author_role: 'buyer',
          body: 'Where is my package?',
          attachments: [],
          created_at: '2026-05-06T10:00:00Z',
          deleted_at: null,
          deleted_by_user_id: null,
          deleted_by_admin: false,
        },
      ],
      meta: { total: 1, has_more: false },
    });

    render(wrap(<AdminMessagesPanel {...adminProps} />));
    await waitFor(() => expect(screen.getByText('Where is my package?')).toBeInTheDocument());

    expect(screen.queryByLabelText('Message')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: /Intervene/i })).toBeInTheDocument();
  });

  it('Intervene toggle reveals the composer and a banner', async () => {
    listMock.mockResolvedValue({ data: [], meta: { total: 0, has_more: false } });
    render(wrap(<AdminMessagesPanel {...adminProps} />));
    await waitFor(() => expect(screen.getByRole('button', { name: /Intervene/i })).toBeInTheDocument());

    fireEvent.click(screen.getByRole('button', { name: /Intervene/i }));

    expect(screen.getByLabelText('Message')).toBeInTheDocument();
    expect(screen.getByText(/visible to both buyer and seller/i)).toBeInTheDocument();
  });

  it('posting from intervene mode sends the message with admin role implied', async () => {
    listMock.mockResolvedValue({ data: [], meta: { total: 0, has_more: false } });
    postMock.mockResolvedValue({
      data: {
        id: 'm2',
        thread_id: 't1',
        author_user_id: 'admin-1',
        author_role: 'admin',
        body: 'Alqove Support: please share tracking ASAP.',
        attachments: [],
        created_at: '2026-05-06T11:00:00Z',
        deleted_at: null,
        deleted_by_user_id: null,
        deleted_by_admin: false,
      },
    });

    render(wrap(<AdminMessagesPanel {...adminProps} />));
    await waitFor(() => expect(screen.getByRole('button', { name: /Intervene/i })).toBeInTheDocument());
    fireEvent.click(screen.getByRole('button', { name: /Intervene/i }));

    fireEvent.change(screen.getByLabelText('Message'), {
      target: { value: 'Please share tracking ASAP.' },
    });
    fireEvent.click(screen.getByRole('button', { name: /^Send$/ }));

    await waitFor(() => expect(postMock).toHaveBeenCalled());
    const [, body] = postMock.mock.calls[0];
    expect(body.body).toBe('Please share tracking ASAP.');
    // No attachment_ids — admin path doesn't stage uploads in Plan 3
  });
});
```

- [ ] **Step 3: Implement `<AdminMessagesPanel>`**

`web/src/components/admin/admin-messages-panel.tsx`:

```tsx
'use client';

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

interface Props {
  orderId: string;
  adminUserId: string;
  /** Short buyer label (e.g. "Jane D.") for context only — admin posts render
   *  as "Alqove Support"; the counterparty label here just tells the admin
   *  who they're moderating between. */
  buyerLabel: string;
  storeLabel: string;
}

export function AdminMessagesPanel({
  orderId,
  adminUserId,
  buyerLabel,
  storeLabel,
}: Props) {
  const [intervening, setIntervening] = useState(false);

  return (
    <section className="mt-4 rounded-lg border border-slate-200 bg-white p-4">
      <div className="flex items-center justify-between">
        <div>
          <h2 className="font-semibold text-sm text-slate-700">Messages</h2>
          <p className="mt-0.5 text-xs text-slate-500">
            Conversation between {buyerLabel} and {storeLabel}.
          </p>
        </div>
        {!intervening ? (
          <button
            type="button"
            onClick={() => setIntervening(true)}
            className="rounded-md border border-emerald-600 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50"
          >
            Intervene
          </button>
        ) : (
          <button
            type="button"
            onClick={() => setIntervening(false)}
            className="text-xs text-slate-500 hover:text-slate-700"
          >
            Cancel
          </button>
        )}
      </div>

      {intervening && (
        <p className="mt-3 rounded-md bg-emerald-50 p-2 text-xs text-emerald-900">
          Posting as <strong>Alqove Support</strong>. The message will be visible to both buyer and seller and is recorded in the admin audit log.
        </p>
      )}

      <div className="mt-3">
        <MessageThread
          orderId={orderId}
          counterpartyName={`${buyerLabel} ↔ ${storeLabel}`}
          viewerRole="admin"
          viewerUserId={adminUserId}
          readOnly={!intervening}
        />
      </div>
    </section>
  );
}
```

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

### Task 7: Render `<AdminMessagesPanel>` on `/admin/orders/[id]`

**Files:**
- Update: `web/src/app/(admin)/admin/orders/[id]/order-detail-client.tsx`
- Possibly update: existing admin-order-detail test to mock `api.messages.list`

- [ ] **Step 1: Locate the page anchors**

Open `order-detail-client.tsx`. Confirm the top-level layout — Layer 8's plan slotted the dispute UI here; the new Messages section lands **below the Stripe state section** (the existing layout's last block before any modal portals). The exact location is not critical — just keep it visible without scrolling past 2 panels.

- [ ] **Step 2: Pull the admin's user id**

The admin order detail page already runs inside the `(admin)` route group with the auth store available. Read it the same way the seller order detail does in Plan 2:

```tsx
import { useAuthStore } from '@/stores/auth';
// ...
const adminUserId = useAuthStore((s) => s.user?.id ?? null);
```

- [ ] **Step 3: Render the panel**

After the `Stripe state` `<section>`, before the closing fragment / modal portals:

```tsx
import { AdminMessagesPanel } from '@/components/admin/admin-messages-panel';
// ...
{adminUserId && (
  <AdminMessagesPanel
    orderId={detail.id}
    adminUserId={adminUserId}
    buyerLabel={
      [detail.buyer?.first_name, detail.buyer?.last_name?.[0]]
        .filter(Boolean)
        .join(' ') || 'Buyer'
    }
    storeLabel={detail.store.name}
  />
)}
```

If `detail.buyer` doesn't have first/last on the admin order resource, fall back to whatever is exposed (the spec calls for "Jane D." short-form; if only `name` is available, slice the first space-separated chunk + first letter of the next).

- [ ] **Step 4: Update the existing admin-order-detail test (if any)**

If `web/src/app/(admin)/admin/orders/[id]/__tests__/order-detail-client.test.tsx` exists, extend its `vi.mock('@/lib/api', ...)` block to include `messages.list` returning `{ data: [], meta: { total: 0, has_more: false } }` and add an assertion that the Messages section renders.

If it does not exist (Layer 8 may not have shipped one), do not create a new file just for this — the integration is covered by the standalone `admin-messages-panel.test.tsx` from Task 6.

---

## Phase E — Wrap-up

### Task 8: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: 462 → ≥ **470 passing** (4 admin-activity-log + 3 deleted_by_admin + 1 nominal overhead = ~8).
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint app/Modules/Messaging 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 Plan 2 baseline (8 pre-existing img warnings).
- [ ] **Step 5: Web tests** — `npm run test`. Expected: 160 → ≥ **165 passing** (1 row stub split + 3 admin-panel + small overhead).
- [ ] **Step 6: Manual QA**
  - As an admin: log in, visit `/admin/orders/<id>`. Confirm the Messages panel renders with the existing thread visible read-only.
  - Click "Intervene". Confirm the green-tinted banner appears and the composer is exposed.
  - Type a message and Send. Confirm it appears in the thread with the green "Alqove Support" badge from `<MessageRow>` (Plan 1 styling).
  - Open the thread as the buyer (in another browser): confirm the admin post is visible with the same badge.
  - As the same admin: delete a buyer's old message in the thread. Confirm both the buyer and the seller view show "(deleted by Alqove Support)" — not "(deleted by author)".
  - Open `/admin/activity` (Layer 8) and confirm two rows: `message.admin_posted` and `message.admin_deleted`, both causedBy the admin user.

### Task 9: Commit + push

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

```
git add app contracts tests docs
git commit -m "feat(messaging): admin intervention + audit log entries"
```

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

```
git add packages web contracts
git commit -m "feat(messaging): admin Messages panel + deleted-by-admin stub"
```

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

---

## Open items deferred to follow-up plans

- **Email-side admin signalling** — `MessagePostedToBuyer/SellerNotification` mail bodies don't currently distinguish "from Alqove Support" vs "from your counterparty". One-line conditional on `$this->message->author_role === MessageRole::Admin` to swap the subject + intro line; defer because the database channel already shows the distinct "Alqove Support" badge in the bell.
- **Admin canned responses / templates** — explicitly out of scope per the spec ("later admin tooling layer").
- **Bulk admin moderation** — delete-many, mark-many. Not flagged by support yet; revisit if volume demands.
- **Drop the legacy `attachments` JSON column on `messages`** — Plan 1 introduced it; Plan 2 made it unused; the column has now been unused for two plans. A small migration `Schema::table('messages', fn ($t) => $t->dropColumn('attachments'))` is safe to run.
- **Cursor pagination on `GET /messages`** — OpenAPI already declares `?after=`; un-blocked once threads grow long enough to warrant it. A small standalone follow-up.
- **Search across messages** — explicitly out of scope per the spec; revisit when admin support asks for it.
