# Layer 8 Plan 4: Admin Inbox + Activity 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:** Close out Layer 8 by exposing the audit trail and admin-facing notifications that Plans 1–3 have been writing all along but no UI surfaces yet. Add an `/admin/inbox` (admin-scoped notification reader) and `/admin/activity` (read-only spatie/activitylog browser) plus the small backend pieces needed: a category-aware admin inbox endpoint, an activity log endpoint with filtering, two new notification categories (`disputes`, `account_admin`) so admin notifications can be opted in/out via the existing preferences system, and ensure every admin notification class tags itself with one of those categories.

**Architecture:** (1) Backend — extend the existing `/v1/me/notifications` endpoint isn't admin-specific; admin uses the same endpoint but the new admin notifications (already authored in Plans 1–3 as `BuyerRefundIssuedNotification` etc.) are sent to the buyer/seller, not admins. The actual *admin-targeted* notifications are `AdminPurchaseDisputedNotification` (already exists) and `AdminDisputeAdjudicatedNotification` (new — fans out to other admins when one teammate adjudicates a dispute). The admin inbox surface filters `/v1/me/notifications` to admin-only categories. (2) New `NotificationCategory` cases: `Disputes`, `AccountAdmin`. (3) Activity log endpoint — `GET /v1/admin/activity` paginated with `causer_id`, `subject_type`, date-range filters. (4) Frontend — `/admin/inbox` reuses `NotificationRow` + a category-tab strip; `/admin/activity` is a new table with filter inputs + side-panel detail on row click; admin top bar gains a `NotificationBell` polling for the admin's own notifications. (5) Wire `AdminDisputeAdjudicatedNotification` into the existing `DisputeAdjudicator` (Plan 1) so other admins are notified when a teammate decides a dispute.

**Tech Stack:** Laravel 11, Pest PHP, Postgres, `spatie/laravel-activitylog`, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, Tailwind, Vitest + RTL.

**Spec:** `docs/superpowers/specs/2026-05-04-layer-8-admin-console-disputes-design.md` (sections: "Admin inbox" lines 217–238, "Activity log" lines 240–260)
**Prerequisites:** Plans 1–3 merged. `NotificationCategory` enum exists with `Account`. `AdminPurchaseDisputedNotification` exists. `AdminDispute*` activity-log entries are already being written by Plans 1–3.
**Successor plans:** none — Layer 8 closes out with this plan.

---

## Phase A — Backend foundation

### Task 1: Extend `NotificationCategory` enum

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

Add `Disputes = 'disputes'` and `AccountAdmin = 'account_admin'` cases. The existing `Account` stays for buyer/seller account-state events; the new `AccountAdmin` is for admin-team coordination (e.g. "another admin adjudicated a dispute you might be watching").

- [ ] **Step 1: Failing test asserting the cases exist.**
- [ ] **Step 2: Add the cases.**
- [ ] **Step 3: PASS.**

### Task 2: Re-tag existing admin notifications

**Files:**
- Update: `api/app/Modules/Notifications/Notifications/AdminPurchaseDisputedNotification.php` (`via()` should use `NotificationCategory::Disputes` instead of whatever it has today)

Audit `AdminPurchaseDisputedNotification` and confirm its `via()` call uses the gate against the right category. If it currently uses no category gate or uses `Account`, switch to `Disputes`.

- [ ] **Step 1: Failing test — assert the existing `AdminPurchaseDisputedNotification`'s `toDatabase()` payload includes a category in the data array (or that the database row has `category='disputes'` after creation, depending on how Layer 7's category-stamping observer works).**
- [ ] **Step 2: Update the notification class's `via()` to gate via `NotificationCategory::Disputes`.**
- [ ] **Step 3: PASS.**

### Task 3: New `AdminDisputeAdjudicatedNotification`

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/AdminDisputeAdjudicatedNotification.php`
- Update: `api/app/Modules/Admin/Services/DisputeAdjudicator.php` (fan-out)
- Test: `api/tests/Feature/Notifications/AdminDisputeAdjudicatedNotificationTest.php`

When admin A adjudicates a dispute, every *other* admin (excluding A) receives this notification so the team has visibility on a teammate's decision. Body: "Dispute on Purchase #abcd1234 was {accepted/submitted_evidence/resolved} by Alice — refund: $128.00". Category: `Disputes`.

- [ ] **Step 1: Failing test seeds 3 admins, runs adjudicate as admin A, asserts both other admins received the notification and admin A did not.**
- [ ] **Step 2: Create the class (mirror `BuyerRefundIssuedNotification`'s shape from Plan 2).**
- [ ] **Step 3: In `DisputeAdjudicator::adjudicate`, after the `activity()` log call (still inside the transaction), query for users with role `admin` excluding the causer and notify each:**

```php
$otherAdmins = User::role('admin')->where('id', '!=', $admin->id)->get();
Notification::send($otherAdmins, new AdminDisputeAdjudicatedNotification($dispute, $admin, $input->outcome));
```

(Use `Illuminate\Support\Facades\Notification::send` for cleaner test assertions; bring the `use` import in.)

- [ ] **Step 4: PASS.** Adjust if existing dispute tests now assert `Notification::assertNothingSent()` somewhere — they should not, but verify.

### Task 4: Admin inbox endpoint (reuses existing /v1/me/notifications)

The existing `GET /v1/me/notifications?category=disputes` already filters by category (Layer 7). No new endpoint needed — admin inbox calls this endpoint with the admin's own bearer token. Confirm during planning that the existing endpoint accepts repeated `category` params (so the admin inbox can ask for `disputes,account_admin,system`) or extend the request to accept comma-separated values.

If extension is needed:

- [ ] **Step 1: Failing test — `GET /v1/me/notifications?category=disputes,account_admin` returns rows with either category and excludes others.**
- [ ] **Step 2: In `NotificationController::index`, parse the comma-separated list and use `whereIn`.**
- [ ] **Step 3: PASS.**

### Task 5: Activity log endpoint

**Files:**
- Create: `api/app/Modules/Admin/Controllers/AdminActivityController.php`
- Create: `api/app/Modules/Admin/Resources/AdminActivityEntry.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminActivityEndpointTest.php`

`GET /v1/admin/activity` returns paginated `Spatie\Activitylog\Models\Activity` rows scoped to the `admin` log name (already used by Plans 1–3). Optional filters:

- `causer_id` — filter to actions by a specific admin
- `subject_type` — filter to `Dispute`, `Order`, `Store`, `User` (full class names)
- `from`, `to` — date range

Resource shape:

```php
[
    'id' => $activity->id,
    'event' => $activity->description,         // e.g. "dispute.adjudicate"
    'actor' => $activity->causer ? ['id' => ..., 'name' => ...] : null,
    'subject' => [
        'type' => class_basename($activity->subject_type),  // 'Dispute', 'Order', 'Store'
        'id' => $activity->subject_id,
    ],
    'properties' => $activity->properties,     // raw — UI renders the justification + per-action context
    'created_at' => $activity->created_at->toIso8601String(),
]
```

- [ ] **Step 1: Failing test — seed an admin, write 3 activity entries (one per subject type), assert `GET /v1/admin/activity` returns them, asserts filter-by-causer narrows to one row, asserts non-admin gets 403.**
- [ ] **Step 2: Implement controller + resource, wire route under the existing admin route group.**
- [ ] **Step 3: PASS.**

---

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

### Task 6: Document the new endpoints

- `GET /v1/admin/activity` (operationId `adminListActivity`) → paginated `AdminActivityEntry`
- (Optional) extension to `/v1/me/notifications` `category` query param — if Task 4 extends to comma-separated, document that.

New schema: `AdminActivityEntry`.

- [ ] **Step 1: Append YAML, validate, sync, regen.**

### Task 7: api-client extensions

```ts
export interface AdminActivityEntry {
  id: number;
  event: string;
  actor: { id: string; name: string } | null;
  subject: { type: string; id: string | null };
  properties: Record<string, unknown>;
  created_at: string;
}

export interface AdminActivityQueryInput {
  causer_id?: string;
  subject_type?: 'Dispute' | 'Order' | 'Store' | 'User';
  from?: string;
  to?: string;
  page?: number;
  per_page?: number;
}

// Inside createAdminEndpoints():
listActivity(params: AdminActivityQueryInput = {}) {
  return client.get<PaginatedResponse<AdminActivityEntry>>(
    `/v1/admin/activity${toQuery({ ...params })}`,
  );
},
```

Re-export `AdminActivityEntry` + `AdminActivityQueryInput`.

- [ ] **Step 1: Add types + method.**
- [ ] **Step 2: typecheck workspace clean.**

---

## Phase C — Frontend

### Task 8: `useAdminInbox` + `useAdminActivity` hooks

**File:** `web/src/lib/queries/use-admin.ts`

```ts
import { useNotifications } from '@/lib/queries/use-notifications';

const ADMIN_CATEGORIES = ['disputes', 'account_admin', 'system'].join(',');

export function useAdminInbox() {
  // Reuses the existing buyer/seller-shared useNotifications hook with
  // the admin-scoped categories filter.
  return useNotifications({ category: ADMIN_CATEGORIES });
}

export function useAdminActivity(params: AdminActivityQueryInput = {}) {
  return useQuery({
    queryKey: ['admin', 'activity', params],
    queryFn: () => api.admin.listActivity(params),
  });
}
```

Confirm `useNotifications` already accepts `category` as a string and forwards it on (Layer 7 added that). If not, extend it.

### Task 9: Admin top bar with notification bell

**Files:**
- Update: `web/src/app/(admin)/layout.tsx`
- Create: `web/src/components/admin/admin-top-bar.tsx`

The existing `NotificationBell` component is already buyer/seller-shared. Mount it in a small admin top bar above the main content area. The bell uses `useNotifications` under the hood; pass it the same admin-scoped `category` filter so admins only see admin notifications in the dropdown.

- [ ] **Step 1: Create `AdminTopBar` (mirrors the `SellerTopBar` shape — bell on the right, optional avatar later).**
- [ ] **Step 2: Render it inside the layout, above `<main>`.**
- [ ] **Step 3: Test — render layout, assert the bell is mounted.**

### Task 10: Admin inbox page

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

Layout:

```
┌──────────────────────────────────────────────────────────────┐
│ Tabs:  All  ·  Disputes  ·  Stores  ·  Money movement  ·  System │
├──────────────────────────────────────────────────────────────┤
│ [All / Unread toggle]           [Mark all read]              │
├──────────────────────────────────────────────────────────────┤
│ NotificationRow × N  (reuses existing component)             │
│ [Load more]                                                  │
└──────────────────────────────────────────────────────────────┘
```

Reuse `NotificationRow` from `src/components/notifications/`. Tabs map to category subsets:

- All → `'disputes,account_admin,system'`
- Disputes → `'disputes'`
- Stores → `'account_admin'` (suspend/verify decisions land here when fan-out is added; for now this tab will be sparse)
- Money movement → `'disputes,account_admin'` (anything that touched money)
- System → `'system'`

- [ ] **Step 1: Failing tests (3 — render rows from a mocked notifications list, click tab → query gets new category param, mark-all-read button calls the existing markAllRead mutation).**
- [ ] **Step 2: Implement.**
- [ ] **Step 3: PASS.**

### Task 11: Admin activity log page

**Files:**
- Create: `web/src/app/(admin)/admin/activity/page.tsx`
- Create: `web/src/app/(admin)/admin/activity/activity-client.tsx`
- Create: `web/src/app/(admin)/admin/activity/__tests__/activity-client.test.tsx`

Layout:

```
┌──────────────────────────────────────────────────────────────┐
│ Filters:  Subject type [select]   Date range [from][to]      │
├──────────────────────────────────────────────────────────────┤
│ When · Actor · Action · Subject · Justification (truncated)  │
│ ⋯ rows ⋯                                                     │
│ Click row → side panel with full properties JSON             │
└──────────────────────────────────────────────────────────────┘
```

The side panel can be a simple inline expansion below the row instead of a real drawer to keep the surface tight.

- [ ] **Step 1: Failing tests (3 — render rows, filter by subject type passes the param, click row → expanded properties visible).**
- [ ] **Step 2: Implement.**
- [ ] **Step 3: PASS.**

### Task 12: Sidebar nav update

**File:** `web/src/app/(admin)/layout.tsx`

```ts
const navItems = [
  { href: '/admin', label: 'Dashboard' },
  { href: '/admin/disputes', label: 'Disputes' },
  { href: '/admin/inbox', label: 'Inbox' },
  { href: '/admin/stores', label: 'Stores' },
  { href: '/admin/orders', label: 'Orders' },
  { href: '/admin/activity', label: 'Activity' },
];
```

---

## Phase D — Wrap-up

### Task 13: Full sweep

- [ ] **Backend tests:** ≥ 391 + ~6 (1 enum + 1 retag + 2 dispute fan-out + 1 activity index + 1 categories filter) ≈ 397.
- [ ] **Pint** auto-fix.
- [ ] **Web typecheck + lint + test.** Expected: 133 → ~140 passing.
- [ ] **Manual QA:**
  - Seed 2 admins.
  - Admin A adjudicates a dispute → admin B sees a notification in `/admin/inbox` (Disputes tab) and a row in `/admin/activity` shows the action with actor=A, justification visible on click.
  - Suspend a store → activity row appears with subject_type=Store.

### Task 14: Commit + push

- [ ] **API:** `feat(admin): inbox category fan-out + activity log endpoint`
- [ ] **Web:** `feat(admin): inbox + activity log pages, top bar bell`

---

## Open items / follow-ups beyond Layer 8

- **CSV export of the activity log** — flagged in the spec but explicitly deferred to a financial-reporting layer.
- **Multi-admin RBAC** — out of Layer 8 by design.
- **Buyer-initiated dispute submission UI** — separate later feature.
- **Stripe Dispute::update evidence-payload submission** — flagged from Plan 1, still open. Small standalone follow-up.
- **Reconciliation extension** — `orders:reconcile-money` should also detect orphaned reversals; small follow-up.
- **Email-template polish** — admin notifications use `MailMessage->line()` rather than markdown templates. A polish pass adds proper `emails.admin.*.markdown` templates.
