# GSC Admin UX Fast Follow Implementation Plan

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

**Goal:** Ship the post-#849 GSC admin UX overhaul + resilient-403 handling in one PR - collapse duplicate modal buttons, add hover-tooltipped natural-English type labels, make all `/admin/gsc` stats clickable inline-expand drilldowns, redesign the Recent Failures table, unify subdomain + custom-domain dealers into one "Dealer Indexing" surface, and stop the perma-403 retry loop with automatic re-verification + dead-letter fallback.

**Architecture:**

- Frontend: shared CSS-only `<Tooltip>` primitive (no new deps, leans on existing CSS variables); new `<DrilldownStat>` primitive that wraps each clickable stat card so the inline-expand pattern is consistent across Queue Health + Dealer Indexing buckets; component rename `CustomDomainIndexing` → `DealerIndexing` with a `type` discriminator on rows.
- Backend: `/api/admin/gsc/summary` widens its dealer query to include all `gscEnabled` dealers (subdomain + custom), returning per-row `type: 'subdomain' | 'customDomain'`; Prisma migration adds `dead_letter` value space + `deadLetterReason` column to `GscJob`; worker catch-block grows a `tryAutoRecover()` step that calls `verifyOwnership()` inline before deciding retry-or-fail; two new admin endpoints surface release/won't-fix for dead-lettered jobs.
- Testing: TDD on every meaningful change. Worker logic exercised by Jest with mocked Google API. Admin endpoints exercised by integration tests against a real Postgres (the `*.int.test.ts` pattern already in this repo).

**Tech Stack:** Next.js 16 App Router, React 19, Prisma 7, PostgreSQL, Jest + Testing Library, the existing GSC module under `lib/google/`.

---

## File Structure

### New files

| Path                                                                 | Responsibility                                                                                                                  |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `components/ui/Tooltip.tsx`                                          | CSS-only hover tooltip primitive. Pure component, no JS state needed beyond CSS `:hover`.                                       |
| `components/ui/Tooltip.module.css`                                   | Tooltip positioning, fade-in transition, theme variables.                                                                       |
| `components/ui/__tests__/Tooltip.test.tsx`                           | Render + a11y test for the tooltip primitive.                                                                                   |
| `app/admin/gsc/DrilldownStat.tsx`                                    | Stat-card primitive: shows a number + label as a button; clicking expands a drawer below with arbitrary children.               |
| `app/admin/gsc/DrilldownStat.module.css`                             | Drawer animation, accent border, focused/expanded state styling.                                                                |
| `app/admin/gsc/__tests__/DrilldownStat.test.tsx`                     | Click → expand / click again → collapse / single-drawer-at-a-time within a group.                                               |
| `app/admin/gsc/DealerIndexing.tsx`                                   | Renamed + widened from `CustomDomainIndexing.tsx`. Adds Type column and subdomain rows.                                         |
| `app/admin/gsc/__tests__/DealerIndexing.test.tsx`                    | New test file mirroring the old `CustomDomainIndexing.test.tsx` but covering subdomain rows + Type pill rendering.              |
| `app/admin/gsc/FailuresTable.tsx`                                    | New full redesign of the failures table (dealer column, group-by-dealer, search, filter chips, pagination).                     |
| `app/admin/gsc/FailuresTable.module.css`                             | Failures table styling.                                                                                                         |
| `app/admin/gsc/__tests__/FailuresTable.test.tsx`                     | Grouping, search filter, type-chip filter, pagination, row → modal handoff.                                                     |
| `app/api/admin/gsc/dead-letter/[id]/release/route.ts`                | POST: move a dead-letter job back to `pending`, reset attempts. Admin-only.                                                     |
| `app/api/admin/gsc/dead-letter/[id]/release/__tests__/route.test.ts` | Auth gate + state transition coverage.                                                                                          |
| `app/api/admin/gsc/dead-letter/[id]/wontfix/route.ts`                | POST: mark a dead-letter job as `wont_fix` (terminal, separate from dead_letter), records admin actor.                          |
| `app/api/admin/gsc/dead-letter/[id]/wontfix/__tests__/route.test.ts` | Auth gate + state transition coverage.                                                                                          |
| `lib/__tests__/gsc-worker.dead-letter.int.test.ts`                   | Integration test against real Postgres: 403 → auto-reverify success → original op succeeds; 403 → reverify fails → dead_letter. |
| `prisma/migrations/<timestamp>_gsc_dead_letter/migration.sql`        | Add `deadLetterReason` column. (Status field stays a `String`, just adds new permitted value.)                                  |

### Modified files

| Path                                                | Change                                                                                                                                                                                                                                                                                                  |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components/admin/DealerDetailModal.tsx`            | Collapse the 3 GSC buttons (`Resubmit Sitemap` / `Force Reindex` / `Deep Audit`) into a single `Reindex Now`. Comment out `Deep Audit` with a TODO referencing issue #865. Wrap each row's `<td className={styles.jobType}>{job.type}</td>` in a `<Tooltip>`-wrapped pill with a natural-English label. |
| `components/admin/DealerDetailModal.module.css`     | Add `.gscTypePill` styles (rounded, theme background). Remove `.gscActionDisabled` if unused.                                                                                                                                                                                                           |
| `app/admin/gsc/page.tsx`                            | Replace `<QueueHealth>` with 4 `<DrilldownStat>` instances. Replace `<FailureTable>` with `<FailuresTable>`. Swap `<CustomDomainIndexing>` for `<DealerIndexing>`. Wire dead-letter drilldown.                                                                                                          |
| `app/api/admin/gsc/summary/route.ts`                | Widen dealer query to include subdomain dealers (`{ gscEnabled: true, customDomainStatus: { in: ['active', null] } }` per actual schema - final filter chosen in Task B1). Per-row return `type` discriminator. Compute and return dead-letter counts + list.                                           |
| `app/api/admin/gsc/summary/__tests__/route.test.ts` | Add coverage for new shape (Type discriminator, subdomain rows present, dead-letter list non-empty when applicable).                                                                                                                                                                                    |
| `prisma/schema.prisma`                              | Add `deadLetterReason String? @db.Text` to `GscJob`. Extend status doc comment to include `dead_letter` and `wont_fix`.                                                                                                                                                                                 |
| `lib/gsc-worker.ts`                                 | New helper `tryAutoRecover(siteUrl, error)` + integration into catch block. On auto-recover success: re-execute the same handler once. On auto-recover fail with 403/400-token-not-found: mark `status='dead_letter'` with `deadLetterReason`.                                                          |
| `lib/gsc-worker.test.ts`                            | New unit tests for `tryAutoRecover` + the dead-letter branch in the catch block.                                                                                                                                                                                                                        |

### Out-of-scope (filed/recorded, not implemented here)

- Deep Audit endpoint (issue [#865](https://github.com/aimclear/amsoil-dlp/issues/865))
- Future `wont_fix` automated cleanup job

---

## Section A: UX Overhaul

### Task A1: Shared `<Tooltip>` primitive (CSS-only)

Pure-CSS hover tooltip. Renders `children` and an absolutely-positioned `content` div that becomes visible on `:hover` and `:focus-within`. No JS state, no portal - relies on the natural CSS cascade. Touch users will not see tooltips; that is acceptable per user constraint (no iPad use, no focus support needed).

**Files:**

- Create: `components/ui/Tooltip.tsx`
- Create: `components/ui/Tooltip.module.css`
- Create: `components/ui/__tests__/Tooltip.test.tsx`

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

Create `components/ui/__tests__/Tooltip.test.tsx`:

```tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Tooltip } from '../Tooltip';

describe('Tooltip', () => {
  it('renders children visibly', () => {
    render(<Tooltip content="hello tip">trigger</Tooltip>);
    expect(screen.getByText('trigger')).toBeInTheDocument();
  });

  it('renders the tooltip content in the DOM so CSS :hover can show it', () => {
    render(<Tooltip content="hello tip">trigger</Tooltip>);
    expect(screen.getByText('hello tip')).toBeInTheDocument();
  });

  it('marks the tooltip with role="tooltip" for screen readers', () => {
    render(<Tooltip content="explained">label</Tooltip>);
    expect(screen.getByRole('tooltip')).toHaveTextContent('explained');
  });

  it('supports rich content nodes inside the bubble', () => {
    render(
      <Tooltip
        content={
          <>
            <strong>name</strong>: detail
          </>
        }
      >
        label
      </Tooltip>
    );
    expect(screen.getByRole('tooltip')).toHaveTextContent('name: detail');
  });
});
```

- [x] **Step 2: Run test, expect failure (module not found)**

Run: `npx jest components/ui/__tests__/Tooltip.test.tsx`
Expected: `Cannot find module '../Tooltip'`.

- [x] **Step 3: Implement the Tooltip component**

Create `components/ui/Tooltip.module.css`:

```css
.wrapper {
  position: relative;
  display: inline-flex;
  align-items: center;
}

.trigger {
  border-bottom: 1px dotted var(--color-text-secondary, #86868b);
  cursor: help;
}

.bubble {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  min-width: 180px;
  max-width: 320px;
  padding: 8px 12px;
  background: var(--color-surface, #2d2d2f);
  color: var(--color-text-primary, #f5f5f7);
  border: 1px solid var(--color-border, #424245);
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.4;
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
  white-space: normal;
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 120ms ease,
    visibility 120ms ease;
  pointer-events: none;
  z-index: 1000;
}

.bubble::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--color-border, #424245);
}

.wrapper:hover .bubble,
.wrapper:focus-within .bubble {
  opacity: 1;
  visibility: visible;
}
```

Create `components/ui/Tooltip.tsx`:

```tsx
import React from 'react';
import styles from './Tooltip.module.css';

interface TooltipProps {
  content: React.ReactNode;
  children: React.ReactNode;
  className?: string;
}

export function Tooltip({ content, children, className }: TooltipProps) {
  return (
    <span className={`${styles.wrapper} ${className ?? ''}`.trim()}>
      <span className={styles.trigger}>{children}</span>
      <span className={styles.bubble} role="tooltip">
        {content}
      </span>
    </span>
  );
}
```

- [x] **Step 4: Run test, expect pass**

Run: `npx jest components/ui/__tests__/Tooltip.test.tsx`
Expected: all 4 tests PASS.

- [x] **Step 5: Commit**

```bash
git add components/ui/Tooltip.tsx components/ui/Tooltip.module.css components/ui/__tests__/Tooltip.test.tsx
git commit -m "feat(ui): add CSS-only Tooltip primitive

Pure-CSS hover tooltip for admin surfaces. No new deps, no JS state.
Used in the next commit to surface raw GSC job type names on the
natural-English type pills.
"
```

---

### Task A2: DealerDetailModal - collapse 3 GSC buttons → 1 `Reindex Now`

The current modal renders three buttons (`Resubmit Sitemap`, `Force Reindex`, `Deep Audit`). The first two call the same `handleGscAction` (proven by the code comment at line 575-579), and `Deep Audit` is permanently disabled with `title="Coming soon"`. Replace all three with a single `Reindex Now` button. Comment out the `Deep Audit` block with a TODO line pointing at issue #865 so the design intent isn't lost.

**Files:**

- Modify: `components/admin/DealerDetailModal.tsx:1193-1222`
- Modify: `components/admin/__tests__/DealerDetailModal.gsc.test.tsx`

- [x] **Step 1: Add a failing test asserting the new button shape**

Append to `components/admin/__tests__/DealerDetailModal.gsc.test.tsx` (or modify the existing button test in that file - read the current shape first and adapt the test names):

```tsx
it('renders a single "Reindex Now" button (replaces the old triplet)', async () => {
  // Existing test scaffolding loads a dealer detail; reuse the pattern.
  await renderWithDealer({
    /* fixture */
  });
  expect(screen.getByRole('button', { name: /reindex now/i })).toBeInTheDocument();
  expect(screen.queryByRole('button', { name: /resubmit sitemap/i })).toBeNull();
  expect(screen.queryByRole('button', { name: /force reindex/i })).toBeNull();
  expect(screen.queryByRole('button', { name: /deep audit/i })).toBeNull();
});
```

If `renderWithDealer` doesn't exist in the test file, use the existing test's setup verbatim (read the file and copy its pattern - do not introduce a new helper).

- [x] **Step 2: Run test, expect failure**

Run: `npx jest components/admin/__tests__/DealerDetailModal.gsc.test.tsx -t "Reindex Now"`
Expected: FAIL - three matching buttons still present, no "Reindex Now" match.

- [x] **Step 3: Replace the button block**

In `components/admin/DealerDetailModal.tsx`, replace lines 1193-1222 (the `<div className={styles.gscActions}>` block) with:

```tsx
<div className={styles.gscActions}>
  <button
    onClick={handleGscAction}
    disabled={isGscActioning}
    className={styles.gscActionButton}
    title="Resubmit sitemap, request URL inspection, and reschedule indexing jobs"
  >
    <RefreshCw size={14} className={isGscActioning ? styles.spinningIcon : undefined} />
    {isGscActioning ? 'Queuing…' : 'Reindex Now'}
  </button>
  {/* TODO: re-enable Deep Audit button when issue #865 (per-dealer
      deep diagnostic endpoint) lands. The button was previously
      disabled with "Coming soon"; commented out to avoid dead UI.
      See: https://github.com/aimclear/amsoil-dlp/issues/865
  <button
    disabled
    className={`${styles.gscActionButton} ${styles.gscActionDisabled}`}
    title="Coming soon"
  >
    Deep Audit
  </button>
  */}
</div>
```

Also delete the obsolete comment at lines 575-579 inside `handleGscAction` (the "NOTE: the server route ignores the `mode` argument…" block is no longer relevant since only one button exists). Keep the function body - the network call is unchanged.

- [x] **Step 4: Run test, expect pass**

Run: `npx jest components/admin/__tests__/DealerDetailModal.gsc.test.tsx`
Expected: all GSC-modal tests PASS (the new test + any existing ones).

- [x] **Step 5: Commit**

```bash
git add components/admin/DealerDetailModal.tsx components/admin/__tests__/DealerDetailModal.gsc.test.tsx
git commit -m "refactor(admin-modal): collapse 3 GSC buttons into 'Reindex Now'

Resubmit Sitemap and Force Reindex called the same endpoint with no
behavioral difference. Deep Audit was permanently disabled with
'Coming soon' and no roadmap. One button is clearer + honest.

Deep Audit is commented out (not deleted) with a TODO pointing at
issue #865, so the design intent survives if/when we build it.
"
```

---

### Task A3: DealerDetailModal - Recent Activity table type pills + tooltips

The job table at lines 1229-1276 currently renders `{job.type}` as raw code. Replace with a `<Tooltip>`-wrapped pill that shows the natural-English label and reveals the raw type + one-line explanation on hover.

**Files:**

- Modify: `components/admin/DealerDetailModal.tsx:1242-1245` (just the `<td className={styles.jobType}>` cell)
- Create: `lib/gsc-job-type-labels.ts` - single source of truth mapping raw type to label + description.
- Create: `lib/__tests__/gsc-job-type-labels.test.ts`
- Modify: `components/admin/DealerDetailModal.module.css` (add `.gscTypePill`)
- Modify: `components/admin/__tests__/DealerDetailModal.gsc.test.tsx`

- [x] **Step 1: Failing test for the label map**

Create `lib/__tests__/gsc-job-type-labels.test.ts`:

```ts
import { describe, it, expect } from '@jest/globals';
import { jobTypeLabel, jobTypeDescription, ALL_JOB_TYPES } from '../gsc-job-type-labels';

describe('gsc-job-type-labels', () => {
  it('returns a natural-English noun phrase for inspect_url', () => {
    expect(jobTypeLabel('inspect_url')).toBe('URL Inspection');
  });

  it('returns a one-line description for inspect_url', () => {
    expect(jobTypeDescription('inspect_url')).toMatch(/current indexing status/i);
  });

  it('covers every job type the worker produces', () => {
    for (const t of ALL_JOB_TYPES) {
      expect(jobTypeLabel(t)).toBeTruthy();
      expect(jobTypeDescription(t)).toBeTruthy();
    }
  });

  it('falls back to the raw type for unknown values without crashing', () => {
    expect(jobTypeLabel('unknown_type_xyz')).toBe('unknown_type_xyz');
    expect(jobTypeDescription('unknown_type_xyz')).toBe('');
  });
});
```

- [x] **Step 2: Run test, expect failure**

Run: `npx jest lib/__tests__/gsc-job-type-labels.test.ts`
Expected: `Cannot find module '../gsc-job-type-labels'`.

- [x] **Step 3: Implement the label map**

Create `lib/gsc-job-type-labels.ts`:

```ts
// Canonical list of GscJob.type values the worker can produce.
// Source of truth: prisma/schema.prisma — model GscJob.type comment.
export const ALL_JOB_TYPES = [
  'verify_property',
  'register_property',
  'submit_sitemap',
  'inspect_url',
  'notify_indexing',
  'delete_property',
  'delete_sitemap',
] as const;

export type GscJobType = (typeof ALL_JOB_TYPES)[number];

const LABEL: Record<GscJobType, string> = {
  verify_property: 'Ownership Verification',
  register_property: 'Property Registration',
  submit_sitemap: 'Sitemap Submission',
  inspect_url: 'URL Inspection',
  notify_indexing: 'Indexing Notification',
  delete_property: 'Property Removal',
  delete_sitemap: 'Sitemap Removal',
};

const DESCRIPTION: Record<GscJobType, string> = {
  verify_property:
    'Proves to Google that our service account owns this property by checking the META tag is reachable.',
  register_property:
    'Adds the dealer site as a Search Console property and triggers ownership verification.',
  submit_sitemap: "Tells Google's Webmasters API to (re-)crawl the dealer's sitemap.xml.",
  inspect_url: 'Asks Google for the current indexing status of a specific URL.',
  notify_indexing:
    'Hints to the Indexing API that a specific URL has been updated and should be re-crawled.',
  delete_property:
    'Removes the dealer site from Search Console (e.g. when the dealer is deactivated).',
  delete_sitemap: 'Withdraws a previously-submitted sitemap from Search Console.',
};

export function jobTypeLabel(type: string): string {
  return (LABEL as Record<string, string | undefined>)[type] ?? type;
}

export function jobTypeDescription(type: string): string {
  return (DESCRIPTION as Record<string, string | undefined>)[type] ?? '';
}
```

- [x] **Step 4: Run test, expect pass**

Run: `npx jest lib/__tests__/gsc-job-type-labels.test.ts`
Expected: 4 tests PASS.

- [x] **Step 5: Failing test for the modal pill**

Append to `components/admin/__tests__/DealerDetailModal.gsc.test.tsx`:

```tsx
it('shows natural-English type pills in the GSC jobs table with tooltips', async () => {
  await renderWithDealer({
    gscJobs: [
      {
        id: '1',
        type: 'inspect_url',
        status: 'completed',
        lastError: null,
        attempts: 0,
        updatedAt: new Date().toISOString(),
        completedAt: new Date().toISOString(),
      },
    ],
  });
  expect(screen.getByText('URL Inspection')).toBeInTheDocument();
  expect(screen.queryByText('inspect_url')).toBeNull();
  // Tooltip content is in the DOM (CSS controls visibility):
  expect(screen.getByRole('tooltip', { name: /current indexing status/i })).toBeInTheDocument();
});
```

- [x] **Step 6: Run test, expect failure**

Run: `npx jest components/admin/__tests__/DealerDetailModal.gsc.test.tsx -t "natural-English type pills"`
Expected: FAIL - pill not rendered.

- [x] **Step 7: Add the pill CSS**

Append to `components/admin/DealerDetailModal.module.css` (find the section near `.jobType` and add):

```css
.gscTypePill {
  display: inline-block;
  padding: 2px 9px;
  border-radius: 10px;
  background: var(--color-surface);
  color: var(--color-text-primary);
  font-size: 11px;
  font-weight: 500;
}
```

- [x] **Step 8: Wire the Tooltip into the jobs table**

In `components/admin/DealerDetailModal.tsx`:

Add to the imports near the existing component imports:

```tsx
import { Tooltip } from '@/components/ui/Tooltip';
import { jobTypeLabel, jobTypeDescription } from '@/lib/gsc-job-type-labels';
```

Replace the `<td className={styles.jobType}>{job.type}</td>` cell (around line 1244) with:

```tsx
<td>
  <Tooltip
    content={
      <>
        <strong>{job.type}</strong>
        <br />
        {jobTypeDescription(job.type)}
      </>
    }
  >
    <span className={styles.gscTypePill}>{jobTypeLabel(job.type)}</span>
  </Tooltip>
</td>
```

- [x] **Step 9: Run tests, expect pass**

Run: `npx jest components/admin/__tests__/DealerDetailModal.gsc.test.tsx`
Expected: all GSC-modal tests PASS.

- [x] **Step 10: Commit**

```bash
git add lib/gsc-job-type-labels.ts lib/__tests__/gsc-job-type-labels.test.ts components/admin/DealerDetailModal.tsx components/admin/DealerDetailModal.module.css components/admin/__tests__/DealerDetailModal.gsc.test.tsx
git commit -m "feat(admin-modal): natural-English type pills with hover tooltips

Recent Activity table no longer shows raw API type names (inspect_url,
submit_sitemap, ...). Each type is rendered as a pill with a noun-
phrase label; hover surfaces the raw type + one-sentence explanation.

Labels live in lib/gsc-job-type-labels.ts so any future surface can
reuse them.
"
```

---

### Task A4: `<DrilldownStat>` primitive

Reusable stat card: shows a value + label, expands to reveal arbitrary content when clicked. Multiple stats inside the same `<DrilldownStat.Group>` collapse each other on open (only one drawer open per group). Same pattern used for both Queue Health and Dealer Indexing.

**Files:**

- Create: `app/admin/gsc/DrilldownStat.tsx`
- Create: `app/admin/gsc/DrilldownStat.module.css`
- Create: `app/admin/gsc/__tests__/DrilldownStat.test.tsx`

- [x] **Step 1: Failing test**

Create `app/admin/gsc/__tests__/DrilldownStat.test.tsx`:

```tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DrilldownStat } from '../DrilldownStat';

describe('DrilldownStat', () => {
  it('renders value + label', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="Failed" value={408}>
          drawer content
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    expect(screen.getByText('408')).toBeInTheDocument();
    expect(screen.getByText('Failed')).toBeInTheDocument();
  });

  it('keeps drawer content hidden until clicked', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="Failed" value={408}>
          SECRET
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    expect(screen.queryByText('SECRET')).toBeNull();
  });

  it('expands the drawer when the stat button is clicked', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="Failed" value={408}>
          SECRET
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    fireEvent.click(screen.getByRole('button', { name: /failed/i }));
    expect(screen.getByText('SECRET')).toBeInTheDocument();
  });

  it('collapses the drawer when clicked twice', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="Failed" value={408}>
          SECRET
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    const btn = screen.getByRole('button', { name: /failed/i });
    fireEvent.click(btn);
    fireEvent.click(btn);
    expect(screen.queryByText('SECRET')).toBeNull();
  });

  it('only opens one drawer at a time within a group', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="A" value={1}>
          FIRST
        </DrilldownStat>
        <DrilldownStat label="B" value={2}>
          SECOND
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    fireEvent.click(screen.getByRole('button', { name: /^a/i }));
    expect(screen.getByText('FIRST')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: /^b/i }));
    expect(screen.queryByText('FIRST')).toBeNull();
    expect(screen.getByText('SECOND')).toBeInTheDocument();
  });

  it('supports an accent color for the value (e.g. red for failures)', () => {
    render(
      <DrilldownStat.Group>
        <DrilldownStat label="Failed" value={5} accent="error">
          x
        </DrilldownStat>
      </DrilldownStat.Group>
    );
    const value = screen.getByText('5');
    expect(value.className).toMatch(/error/i);
  });
});
```

- [x] **Step 2: Run test, expect failure**

Run: `npx jest app/admin/gsc/__tests__/DrilldownStat.test.tsx`
Expected: `Cannot find module '../DrilldownStat'`.

- [x] **Step 3: Implement**

Create `app/admin/gsc/DrilldownStat.module.css`:

```css
.group {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 16px;
}

.row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.stat {
  background: var(--color-surface, #2d2d2f);
  border: 2px solid transparent;
  border-radius: 8px;
  padding: 10px 16px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-width: 110px;
  transition: border-color 120ms ease, background 120ms ease;
  font: inherit;
  text-align: left;
}
.stat:hover { border-color: var(--color-primary-accent, #0a84ff); }
.stat.open {
  border-color: var(--color-primary-accent, #0a84ff);
  background: rgba(56, 189, 248, 0.15));
}

.value {
  font-size: 24px;
  font-weight: 700;
  color: var(--color-text-primary);
  line-height: 1;
}
.value.success { color: var(--color-success, #34c759); }
.value.error { color: var(--color-error, #ff3b30); }
.value.warning { color: var(--color-warning, #ff9f0a); }

.label {
  font-size: 11px;
  color: var(--color-text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.4px;
  margin-top: 4px;
}

.caret {
  font-size: 10px;
  margin-left: 4px;
  opacity: 0.5;
}

.drawer {
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-primary-accent, #0a84ff);
  border-radius: 6px;
  padding: 14px;
}
```

Create `app/admin/gsc/DrilldownStat.tsx`:

```tsx
'use client';

import React, { createContext, useCallback, useContext, useId, useState } from 'react';
import styles from './DrilldownStat.module.css';

type Accent = 'default' | 'success' | 'error' | 'warning';

interface GroupCtx {
  openId: string | null;
  setOpenId: (id: string | null) => void;
}
const Ctx = createContext<GroupCtx | null>(null);

function Group({ children }: { children: React.ReactNode }) {
  const [openId, setOpenId] = useState<string | null>(null);
  return (
    <Ctx.Provider value={{ openId, setOpenId }}>
      <div className={styles.group}>
        <div className={styles.row}>{children}</div>
        {/* Drawer is rendered by the open stat itself via portal-less placement;
            see Stat component which conditionally renders its drawer below
            the row. */}
      </div>
    </Ctx.Provider>
  );
}

interface StatProps {
  label: string;
  value: number | string;
  accent?: Accent;
  children?: React.ReactNode;
}

function Stat({ label, value, accent = 'default', children }: StatProps) {
  const id = useId();
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error('DrilldownStat must be inside DrilldownStat.Group');
  const isOpen = ctx.openId === id;
  const toggle = useCallback(() => {
    ctx.setOpenId(isOpen ? null : id);
  }, [ctx, isOpen, id]);

  return (
    <>
      <button
        type="button"
        className={`${styles.stat} ${isOpen ? styles.open : ''}`}
        onClick={toggle}
        aria-expanded={isOpen}
        aria-controls={`${id}-drawer`}
      >
        <span className={`${styles.value} ${styles[accent]}`}>
          {value}
          <span className={styles.caret}>{isOpen ? '▲' : '▼'}</span>
        </span>
        <span className={styles.label}>{label}</span>
      </button>
      {isOpen && (
        <div id={`${id}-drawer`} className={styles.drawer}>
          {children}
        </div>
      )}
    </>
  );
}

export const DrilldownStat = Object.assign(Stat, { Group });
```

Note: the drawer renders inside the same flex row as the stat. To get the "full-width drawer below the row" layout the spec mocked up, the parent `<DrilldownStat.Group>` will be wrapped in a card; the drawer just stacks below the stat row naturally because flexbox wraps. If the visual ends up wrong at this stage, add `width: 100%; flex-basis: 100%;` to the drawer in CSS and re-verify in the browser at Task A5.

- [x] **Step 4: Run test, expect pass**

Run: `npx jest app/admin/gsc/__tests__/DrilldownStat.test.tsx`
Expected: 6 tests PASS.

- [x] **Step 5: Commit**

```bash
git add app/admin/gsc/DrilldownStat.tsx app/admin/gsc/DrilldownStat.module.css app/admin/gsc/__tests__/DrilldownStat.test.tsx
git commit -m "feat(admin-gsc): reusable DrilldownStat primitive

Clickable stat card that expands an inline drawer. Used in subsequent
commits for both Queue Health stats and Dealer Indexing buckets so the
inline-expand UX is consistent across the page.
"
```

---

### Task A5: Queue Health - apply DrilldownStat to all 4 stats

Replace the static `<QueueHealth>` component in `app/admin/gsc/page.tsx` with a `<DrilldownStat.Group>` wrapping four `<DrilldownStat>` cards. Each drawer shows a placeholder list for now - the Failures drawer wires into the new `<FailuresTable>` in Task A6; the other three (Pending / Running / Completed) get a minimal job-list view for this task.

**Files:**

- Create: `app/admin/gsc/QueueHealthDrawer.tsx` - small component that fetches + renders a list of jobs for a given status.
- Create: `app/admin/gsc/__tests__/QueueHealthDrawer.test.tsx`
- Modify: `app/admin/gsc/page.tsx` (replace the inline `<QueueHealth>` function)

- [x] **Step 1: Failing test for the drawer**

Create `app/admin/gsc/__tests__/QueueHealthDrawer.test.tsx`:

```tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueueHealthDrawer } from '../QueueHealthDrawer';

describe('QueueHealthDrawer', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });
  afterEach(() => {
    (global.fetch as jest.Mock).mockReset();
  });

  it('fetches /api/admin/gsc/jobs?status=pending and renders rows', async () => {
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        jobs: [
          {
            id: '1',
            type: 'inspect_url',
            dealerId: 'd1',
            dealerName: 'Acme',
            customDomain: null,
            attempts: 0,
            lastError: null,
            updatedAt: new Date().toISOString(),
          },
        ],
        total: 1,
      }),
    });
    render(<QueueHealthDrawer status="pending" />);
    await waitFor(() => {
      expect(screen.getByText(/acme/i)).toBeInTheDocument();
    });
    expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('status=pending'));
  });

  it('shows an empty state when total is 0', async () => {
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => ({ jobs: [], total: 0 }),
    });
    render(<QueueHealthDrawer status="completed" />);
    await waitFor(() => {
      expect(screen.getByText(/no jobs/i)).toBeInTheDocument();
    });
  });
});
```

- [x] **Step 2: Run test, expect failure**

Run: `npx jest app/admin/gsc/__tests__/QueueHealthDrawer.test.tsx`
Expected: `Cannot find module '../QueueHealthDrawer'`.

- [x] **Step 3: Implement the drawer component**

Create `app/admin/gsc/QueueHealthDrawer.tsx`:

```tsx
'use client';

import React, { useEffect, useState } from 'react';
import { JobTypePill } from '@/components/admin/JobTypePill';
import styles from './page.module.css';

interface JobRow {
  id: string;
  type: string;
  dealerId: string;
  dealerName: string | null;
  customDomain: string | null;
  attempts: number;
  lastError: string | null;
  updatedAt: string;
}

interface Props {
  status: 'pending' | 'running' | 'completed' | 'failed' | 'dead_letter';
  limit?: number;
}

export function QueueHealthDrawer({ status, limit = 25 }: Props) {
  const [jobs, setJobs] = useState<JobRow[] | null>(null);
  const [total, setTotal] = useState<number>(0);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/admin/gsc/jobs?status=${status}&limit=${limit}`)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((data) => {
        if (cancelled) return;
        setJobs(data.jobs);
        setTotal(data.total);
      })
      .catch((e) => !cancelled && setError(e.message));
    return () => {
      cancelled = true;
    };
  }, [status, limit]);

  if (error) return <p className={styles.errorBanner}>Failed to load: {error}</p>;
  if (!jobs) return <p>Loading…</p>;
  if (total === 0) return <p className={styles.emptyState}>No jobs in this state.</p>;

  return (
    <div className={styles.tableWrapper}>
      <table className={styles.table}>
        <thead>
          <tr>
            <th>Dealer</th>
            <th>Domain</th>
            <th>Type</th>
            <th>Attempts</th>
            <th>Updated</th>
          </tr>
        </thead>
        <tbody>
          {jobs.map((j) => (
            <tr key={j.id}>
              <td>{j.dealerName ?? j.dealerId}</td>
              <td>{j.customDomain ?? '—'}</td>
              <td>
                <JobTypePill type={j.type} />
              </td>
              <td>{j.attempts}</td>
              <td>{new Date(j.updatedAt).toLocaleString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
      {total > jobs.length && (
        <p className={styles.emptyState}>
          Showing {jobs.length} of {total}.
        </p>
      )}
    </div>
  );
}
```

- [x] **Step 4: Run test, expect pass**

Run: `npx jest app/admin/gsc/__tests__/QueueHealthDrawer.test.tsx`
Expected: 2 tests PASS.

- [x] **Step 5: Add the underlying jobs API endpoint**

The drawer fetches `/api/admin/gsc/jobs?status=…&limit=…` which doesn't exist yet.

Create `app/api/admin/gsc/jobs/route.ts`:

```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import { prisma } from '@/lib/prisma';

const VALID_STATUSES = new Set([
  'pending',
  'running',
  'completed',
  'failed',
  'dead_letter',
  'wont_fix',
]);

export async function GET(req: NextRequest) {
  const auth = await requireAdmin();
  if ('response' in auth) return auth.response;

  const url = new URL(req.url);
  const status = url.searchParams.get('status') ?? 'failed';
  if (!VALID_STATUSES.has(status)) {
    return NextResponse.json({ error: 'invalid status' }, { status: 400 });
  }
  const limit = Math.min(Number(url.searchParams.get('limit') ?? '25'), 200);
  const offset = Number(url.searchParams.get('offset') ?? '0');
  const q = url.searchParams.get('q')?.trim();
  const type = url.searchParams.get('type')?.trim();

  const where: Record<string, unknown> = { status };
  if (type) where.type = type;

  // Note: dealerName/email search uses a relational filter so Postgres can
  // index-scan the dealer table rather than table-scanning every job.
  if (q) {
    where.dealer = {
      OR: [
        { businessName: { contains: q, mode: 'insensitive' } },
        { email: { contains: q, mode: 'insensitive' } },
        { customDomain: { contains: q, mode: 'insensitive' } },
      ],
    };
  }

  const [jobs, total] = await Promise.all([
    prisma.gscJob.findMany({
      where,
      orderBy: { updatedAt: 'desc' },
      take: limit,
      skip: offset,
      select: {
        id: true,
        type: true,
        dealerId: true,
        attempts: true,
        lastError: true,
        updatedAt: true,
        dealer: {
          select: { businessName: true, email: true, customDomain: true },
        },
      },
    }),
    prisma.gscJob.count({ where }),
  ]);

  return NextResponse.json({
    jobs: jobs.map((j) => ({
      id: j.id,
      type: j.type,
      dealerId: j.dealerId,
      dealerName: j.dealer.businessName,
      dealerEmail: j.dealer.email,
      customDomain: j.dealer.customDomain,
      attempts: j.attempts,
      lastError: j.lastError,
      updatedAt: j.updatedAt.toISOString(),
    })),
    total,
  });
}
```

- [x] **Step 6: Wire QueueHealth in the page**

In `app/admin/gsc/page.tsx`, replace the entire `QueueHealth` function (lines 64-90) with:

```tsx
import { DrilldownStat } from './DrilldownStat';
import { QueueHealthDrawer } from './QueueHealthDrawer';

function QueueHealth({ counts }: { counts: QueueCounts & { dead_letter?: number } }) {
  return (
    <div className={styles.card}>
      <h2 className={styles.cardTitle}>Queue Health (last 24 h)</h2>
      <DrilldownStat.Group>
        <DrilldownStat label="Pending" value={counts.pending}>
          <QueueHealthDrawer status="pending" />
        </DrilldownStat>
        <DrilldownStat label="Running" value={counts.running}>
          <QueueHealthDrawer status="running" />
        </DrilldownStat>
        <DrilldownStat label="Completed" value={counts.completed24h} accent="success">
          <QueueHealthDrawer status="completed" />
        </DrilldownStat>
        <DrilldownStat
          label="Failed"
          value={counts.failed}
          accent={counts.failed > 0 ? 'error' : 'default'}
        >
          {/* The redesigned full failures table goes here in Task A6 — for
              now keep the simple drawer so this commit ships green. */}
          <QueueHealthDrawer status="failed" />
        </DrilldownStat>
      </DrilldownStat.Group>
    </div>
  );
}
```

- [x] **Step 7: Run the page-level test + e2e for the section**

Run: `npx jest app/admin/gsc/__tests__/page.test.tsx`
Expected: PASS (existing test should still find the queue counts, but may need an update if it asserted the exact old DOM shape - update the test to find the values by role/text rather than by the old `.countCell` class).

- [x] **Step 8: Commit**

```bash
git add app/admin/gsc/QueueHealthDrawer.tsx app/admin/gsc/__tests__/QueueHealthDrawer.test.tsx app/api/admin/gsc/jobs/route.ts app/admin/gsc/page.tsx app/admin/gsc/__tests__/page.test.tsx
git commit -m "feat(admin-gsc): Queue Health stats are now inline drilldowns

Every queue-health number (Pending/Running/Completed/Failed) is now a
clickable card that expands an inline drawer listing the underlying
jobs. New /api/admin/gsc/jobs endpoint backs the drawer with paginated,
searchable, filterable job rows.

Failures drawer still uses the simple drawer in this commit; the full
redesigned FailuresTable lands in the next commit.
"
```

---

### Task A6: Recent Failures table redesign

Build the full failures table per the approved mockup: Dealer column (name stacked over email), Domain link with ↗ icon, Type pill with tooltip, Error pill (red status code + short message), Attempts, When. Group-by-dealer toggle (default ON). Search input + type filter chips. Pagination.

**Files:**

- Create: `app/admin/gsc/FailuresTable.tsx`
- Create: `app/admin/gsc/FailuresTable.module.css`
- Create: `app/admin/gsc/__tests__/FailuresTable.test.tsx`
- Modify: `app/admin/gsc/page.tsx` (replace the inline `FailureTable` function + the old `<FailureTable>` usage; also wire the failures drilldown to use this)
- Modify: `app/api/admin/gsc/jobs/route.ts` to also surface `errorStatus` (parsed HTTP code from `lastError`) - Task A6 Step 5 adds this.

- [x] **Step 1: Failing test for FailuresTable**

Create `app/admin/gsc/__tests__/FailuresTable.test.tsx`:

```tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FailuresTable } from '../FailuresTable';

function mockFetchJobs(jobs: any[], total = jobs.length) {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ jobs, total }),
  });
}

const baseJob = {
  id: '1',
  type: 'inspect_url',
  dealerId: 'd1',
  dealerName: 'Bayshore Enterprises',
  dealerEmail: 'contact@bayshoreent.com',
  customDomain: 'bayshoreent.com',
  attempts: 1,
  lastError: 'Google API 403: { "error": { "message": "User not site owner" } }',
  errorStatus: 403,
  errorShort: 'User not site owner',
  updatedAt: new Date().toISOString(),
};

describe('FailuresTable', () => {
  beforeEach(() => mockFetchJobs([baseJob]));
  afterEach(() => (global.fetch as jest.Mock).mockReset());

  it('renders dealer name + email stacked', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    await waitFor(() => {
      expect(screen.getByText('Bayshore Enterprises')).toBeInTheDocument();
    });
    expect(screen.getByText('contact@bayshoreent.com')).toBeInTheDocument();
  });

  it('renders the domain as a link with external-link affordance', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    const link = await screen.findByRole('link', { name: /bayshoreent\.com/i });
    expect(link).toHaveAttribute('target', '_blank');
    expect(link).toHaveAttribute('href', 'https://bayshoreent.com');
  });

  it('shows the natural-English type with hover tooltip', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    expect(await screen.findByText('URL Inspection')).toBeInTheDocument();
  });

  it('renders the error as a red pill (status) + short message', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    await waitFor(() => {
      expect(screen.getByText('403')).toBeInTheDocument();
      expect(screen.getByText(/User not site owner/i)).toBeInTheDocument();
    });
  });

  it('clicking a dealer row calls onSelectDealer with the dealerId', async () => {
    const onSelect = jest.fn();
    render(<FailuresTable onSelectDealer={onSelect} />);
    await waitFor(() => screen.getByText('Bayshore Enterprises'));
    fireEvent.click(screen.getByText('Bayshore Enterprises'));
    expect(onSelect).toHaveBeenCalledWith('d1');
  });

  it('group-by-dealer is on by default and collapses repeated dealers', async () => {
    mockFetchJobs(
      [
        baseJob,
        { ...baseJob, id: '2', updatedAt: new Date(Date.now() - 60_000).toISOString() },
        {
          ...baseJob,
          id: '3',
          dealerId: 'd2',
          dealerName: 'AG Synthetics',
          dealerEmail: 'ag@example.com',
          customDomain: 'agsynthetics.com',
        },
      ],
      3
    );
    render(<FailuresTable onSelectDealer={() => {}} />);
    await waitFor(() => screen.getByText(/3 failures/i)); // group header for Bayshore? — actual count 2
    expect(screen.getByText(/Bayshore Enterprises/)).toBeInTheDocument();
    // Group header notation: "Bayshore Enterprises · 2 failures"
    expect(screen.getByText(/2 failure/i)).toBeInTheDocument();
  });

  it('typing in the search box re-queries the API with the q param', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    await waitFor(() => screen.getByText('Bayshore Enterprises'));
    fireEvent.change(screen.getByPlaceholderText(/search/i), {
      target: { value: 'agsyn' },
    });
    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('q=agsyn'));
    });
  });

  it('clicking a type chip filters the query', async () => {
    render(<FailuresTable onSelectDealer={() => {}} />);
    await waitFor(() => screen.getByText('Bayshore Enterprises'));
    fireEvent.click(screen.getByRole('button', { name: /sitemap submission/i }));
    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('type=submit_sitemap'));
    });
  });
});
```

- [x] **Step 2: Run test, expect failure**

Run: `npx jest app/admin/gsc/__tests__/FailuresTable.test.tsx`
Expected: module not found.

- [x] **Step 3: Extend jobs API to return parsed error pieces**

In `app/api/admin/gsc/jobs/route.ts`, add a small helper and surface the parsed pieces in the response:

```ts
// Add near the top of the file:
function parseError(raw: string | null): { status: number | null; short: string | null } {
  if (!raw) return { status: null, short: null };
  // GoogleApiError.toString() looks like:
  //   "Google API 403: { ... \"message\": \"User not site owner\" ... }"
  const statusMatch = raw.match(/Google API (\d{3})/);
  const msgMatch = raw.match(/"message"\s*:\s*"([^"]+)"/);
  return {
    status: statusMatch ? Number(statusMatch[1]) : null,
    short: msgMatch ? msgMatch[1] : raw.slice(0, 80),
  };
}
```

And in the response map (replace the current `jobs.map(...)` block) include:

```ts
jobs: jobs.map((j) => {
  const parsed = parseError(j.lastError);
  return {
    id: j.id,
    type: j.type,
    dealerId: j.dealerId,
    dealerName: j.dealer.businessName,
    dealerEmail: j.dealer.email,
    customDomain: j.dealer.customDomain,
    attempts: j.attempts,
    lastError: j.lastError,
    errorStatus: parsed.status,
    errorShort: parsed.short,
    updatedAt: j.updatedAt.toISOString(),
  };
}),
```

- [x] **Step 4: Implement FailuresTable**

Create `app/admin/gsc/FailuresTable.module.css`:

```css
.toolbar {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: 12px;
}
.search {
  background: var(--color-surface);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border);
  border-radius: 4px;
  padding: 5px 10px;
  font-size: 12px;
  min-width: 200px;
}
.chip {
  background: var(--color-surface);
  color: var(--color-text-secondary);
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 11px;
  border: 1px solid var(--color-border);
  cursor: pointer;
}
.chipActive {
  background: rgba(56, 189, 248, 0.15);
  color: var(--color-primary-accent);
  border-color: var(--color-primary-accent);
  font-weight: 600;
}
.toggle {
  margin-left: auto;
  font-size: 11px;
  color: var(--color-text-secondary);
  display: flex;
  align-items: center;
  gap: 6px;
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
  color: var(--color-text-primary);
}
.table th,
.table td {
  text-align: left;
  padding: 7px 8px;
  border-bottom: 1px solid var(--color-border);
  vertical-align: top;
}
.table th {
  background: var(--color-surface);
  color: var(--color-text-secondary);
  font-weight: 600;
  font-size: 11px;
}
.table tbody tr {
  cursor: pointer;
}
.table tbody tr:hover {
  background: var(--color-surface);
}

.dealerName {
  font-weight: 600;
}
.dealerEmail {
  font-size: 11px;
  color: var(--color-text-secondary);
}
.domainLink {
  color: var(--color-primary-accent);
  text-decoration: none;
}
.domainLink::after {
  content: ' ↗';
  font-size: 10px;
}

.errPill {
  background: rgba(255, 59, 48, 0.15);
  color: var(--color-error);
  padding: 1px 7px;
  border-radius: 8px;
  font-size: 10px;
  font-weight: 500;
  margin-right: 6px;
}

.groupRow {
  background: var(--color-surface);
  font-weight: 600;
  font-size: 12px;
}
.groupRow td {
  padding: 6px 8px;
}

.pagination {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 12px;
  font-size: 11px;
  color: var(--color-text-secondary);
}
.pageBtn {
  background: none;
  border: none;
  color: var(--color-primary-accent);
  cursor: pointer;
  font-size: 12px;
  padding: 0 6px;
}
.pageBtn[disabled] {
  color: var(--color-text-tertiary);
  cursor: not-allowed;
}
```

Create `app/admin/gsc/FailuresTable.tsx`:

```tsx
'use client';

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { JobTypePill } from '@/components/admin/JobTypePill';
import { jobTypeLabel, ALL_JOB_TYPES } from '@/lib/gsc-job-type-labels';
import styles from './FailuresTable.module.css';

interface FailureRow {
  id: string;
  type: string;
  dealerId: string;
  dealerName: string | null;
  dealerEmail: string | null;
  customDomain: string | null;
  attempts: number;
  lastError: string | null;
  errorStatus: number | null;
  errorShort: string | null;
  updatedAt: string;
}

interface Props {
  onSelectDealer: (dealerId: string) => void;
  pageSize?: number;
}

export function FailuresTable({ onSelectDealer, pageSize = 25 }: Props) {
  const [rows, setRows] = useState<FailureRow[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [q, setQ] = useState('');
  const [type, setType] = useState<string | null>(null);
  const [grouped, setGrouped] = useState(true);
  const [page, setPage] = useState(0);

  useEffect(() => {
    const params = new URLSearchParams({
      status: 'failed',
      limit: String(pageSize),
      offset: String(page * pageSize),
    });
    if (q) params.set('q', q);
    if (type) params.set('type', type);
    let cancelled = false;
    setLoading(true);
    fetch(`/api/admin/gsc/jobs?${params.toString()}`)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((data) => {
        if (cancelled) return;
        setRows(data.jobs);
        setTotal(data.total);
        setError(null);
      })
      .catch((e) => !cancelled && setError(e.message))
      .finally(() => !cancelled && setLoading(false));
    return () => {
      cancelled = true;
    };
  }, [q, type, page, pageSize]);

  const grouping = useMemo(() => {
    if (!grouped) return null;
    const groups = new Map<string, FailureRow[]>();
    for (const r of rows) {
      const arr = groups.get(r.dealerId) ?? [];
      arr.push(r);
      groups.set(r.dealerId, arr);
    }
    return groups;
  }, [rows, grouped]);

  const renderRow = useCallback(
    (r: FailureRow) => (
      <tr key={r.id} onClick={() => onSelectDealer(r.dealerId)}>
        <td>
          <div className={styles.dealerName}>{r.dealerName ?? r.dealerId}</div>
          {r.dealerEmail && <div className={styles.dealerEmail}>{r.dealerEmail}</div>}
        </td>
        <td>
          {r.customDomain ? (
            <a
              className={styles.domainLink}
              href={`https://${r.customDomain}`}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
            >
              {r.customDomain}
            </a>
          ) : (
            <span>—</span>
          )}
        </td>
        <td>
          <JobTypePill type={r.type} />
        </td>
        <td>
          {r.errorStatus != null && <span className={styles.errPill}>{r.errorStatus}</span>}
          {r.errorShort ?? r.lastError ?? '—'}
        </td>
        <td>{r.attempts}</td>
        <td>{new Date(r.updatedAt).toLocaleString()}</td>
      </tr>
    ),
    [onSelectDealer]
  );

  return (
    <div>
      <div className={styles.toolbar}>
        <input
          className={styles.search}
          placeholder="🔍 search dealer name, email, domain…"
          value={q}
          onChange={(e) => {
            setQ(e.target.value);
            setPage(0);
          }}
        />
        <button
          type="button"
          className={`${styles.chip} ${type === null ? styles.chipActive : ''}`}
          onClick={() => {
            setType(null);
            setPage(0);
          }}
        >
          all types
        </button>
        {ALL_JOB_TYPES.map((t) => (
          <button
            key={t}
            type="button"
            className={`${styles.chip} ${type === t ? styles.chipActive : ''}`}
            onClick={() => {
              setType(t);
              setPage(0);
            }}
          >
            {jobTypeLabel(t)}
          </button>
        ))}
        <label className={styles.toggle}>
          <input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
          Group by dealer
        </label>
      </div>

      {error && <p>Failed to load: {error}</p>}
      {loading && <p>Loading…</p>}

      <table className={styles.table}>
        <thead>
          <tr>
            <th>Dealer</th>
            <th>Domain</th>
            <th>Type</th>
            <th>Error</th>
            <th>Attempts</th>
            <th>When</th>
          </tr>
        </thead>
        <tbody>
          {grouping
            ? Array.from(grouping.entries()).flatMap(([dealerId, group]) => [
                <tr key={`g-${dealerId}`} className={styles.groupRow}>
                  <td colSpan={6}>
                    ▾ {group[0].dealerName ?? dealerId}
                    {' · '}
                    {group.length} failure{group.length === 1 ? '' : 's'}
                  </td>
                </tr>,
                ...group.map(renderRow),
              ])
            : rows.map(renderRow)}
        </tbody>
      </table>

      <div className={styles.pagination}>
        <span>
          Showing {rows.length ? page * pageSize + 1 : 0}–{page * pageSize + rows.length} of {total}
        </span>
        <span>
          <button
            type="button"
            className={styles.pageBtn}
            disabled={page === 0}
            onClick={() => setPage((p) => Math.max(0, p - 1))}
          >
            ←
          </button>
          Page {page + 1} of {Math.max(1, Math.ceil(total / pageSize))}
          <button
            type="button"
            className={styles.pageBtn}
            disabled={(page + 1) * pageSize >= total}
            onClick={() => setPage((p) => p + 1)}
          >
            →
          </button>
        </span>
      </div>
    </div>
  );
}
```

- [x] **Step 5: Run tests, expect pass**

Run: `npx jest app/admin/gsc/__tests__/FailuresTable.test.tsx`
Expected: 7 tests PASS.

- [x] **Step 6: Wire FailuresTable into the page**

In `app/admin/gsc/page.tsx`:

1. Delete the entire inline `FailureTable` function (lines 92-130) and its usage `<FailureTable failures={data.recentFailures} />`.
2. In the `QueueHealth` component (Task A5), replace the Failed drawer's content from `<QueueHealthDrawer status="failed" />` to:

   ```tsx
   <FailuresTable onSelectDealer={setSelectedDealerId} />
   ```

   This requires passing `setSelectedDealerId` down as a prop. Update `QueueHealth`'s signature to `{ counts, onSelectDealer }` and thread the handler.

3. Remove the obsolete `recentFailures` reference from the `SummaryData` consumption (it's still computed server-side but no longer rendered as a separate card).

- [x] **Step 7: Update the page test**

In `app/admin/gsc/__tests__/page.test.tsx`, remove any assertions about the old `<FailureTable>` markup; add an assertion that the failures drawer expands properly. (Read the existing test before editing - adapt to its patterns.)

- [x] **Step 8: Commit**

```bash
git add app/admin/gsc/FailuresTable.tsx app/admin/gsc/FailuresTable.module.css app/admin/gsc/__tests__/FailuresTable.test.tsx app/admin/gsc/page.tsx app/admin/gsc/__tests__/page.test.tsx app/api/admin/gsc/jobs/route.ts
git commit -m "feat(admin-gsc): redesigned Failures table

Failures now live as the inline-expand drawer of the Failed stat card.
The table:
  - groups failures by dealer (default ON)
  - lets admins filter by type (chips) or search by dealer name/email/domain
  - renders error status as a red pill + short message
  - links the custom domain in a new tab
  - row click opens DealerDetailModal

Server side: /api/admin/gsc/jobs gains errorStatus + errorShort fields
parsed from the GoogleApiError message format.
"
```

---

## Section B: Dealer Indexing unification

### Task B1: Summary route - include subdomain dealers + return Type discriminator

The current filter at line 42 (`customDomainStatus: 'active', customDomain: { not: null }`) excludes every subdomain dealer. Widen to all `gscEnabled` dealers (or whatever boolean we use to opt-in subdomain dealers - see Step 1 below). Add a `type: 'subdomain' | 'customDomain'` discriminator on each row.

**Files:**

- Modify: `app/api/admin/gsc/summary/route.ts`
- Modify: `app/api/admin/gsc/summary/__tests__/route.test.ts`

- [x] **Step 1: Determine the inclusion predicate**

Read the `Dealer` model in `prisma/schema.prisma` for a `gscEnabled` boolean or equivalent. If one exists, use it. If not, the inclusion predicate is:

```ts
{
  status: 'active',                       // dealer not deactivated
  OR: [
    { customDomainStatus: 'active', customDomain: { not: null } },
    { customDomain: null },                // subdomain-only
  ],
}
```

Document the chosen predicate at the top of the route file as a comment.

- [x] **Step 2: Failing test for subdomain rows**

In `app/api/admin/gsc/summary/__tests__/route.test.ts`, add (after reading the existing test patterns to mirror the setup):

```ts
it('includes subdomain-only dealers in the indexing rollup', async () => {
  // Seed: one custom-domain dealer + one subdomain-only dealer, both active.
  await prisma.dealer.create({
    data: {
      /* …subdomain-only fixture: customDomain: null, customDomainStatus: 'inactive' */
    },
  });
  await prisma.dealer.create({
    data: {
      /* …custom-domain fixture: customDomain: 'cdtest.com', customDomainStatus: 'active' */
    },
  });
  const res = await GET(new NextRequest('http://x/api/admin/gsc/summary'));
  const body = await res.json();
  expect(body.dealerIndexing.total).toBeGreaterThanOrEqual(2);
  expect(body.dealerIndexing.needsAttentionList.some((r: any) => r.type === 'subdomain')).toBe(
    true
  );
  expect(body.dealerIndexing.needsAttentionList.some((r: any) => r.type === 'customDomain')).toBe(
    true
  );
});
```

- [x] **Step 3: Run, expect failure**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts -t "subdomain-only"`
Expected: FAIL - subdomain dealer absent from list, or response shape doesn't include `type`.

- [x] **Step 4: Update the summary route**

In `app/api/admin/gsc/summary/route.ts`:

1. Rename the local variable + the response key from `customDomainIndexing` to `dealerIndexing` (keep the old key as a temporary alias if any consumer outside `/admin/gsc/page.tsx` reads it - grep to check; otherwise delete the old name).
2. Replace the dealer query (lines 41-50) with:

   ```ts
   prisma.dealer.findMany({
     where: {
       status: 'active',
       OR: [
         { customDomainStatus: 'active', customDomain: { not: null } },
         { customDomain: null },
       ],
     },
     select: {
       id: true,
       customDomain: true,
       subdomain: true,
       gscLastIndexStatus: true,
       gscLastInspectedAt: true,
       gscLastSubmittedAt: true,
     },
   }),
   ```

3. In the loop that builds `needsAttentionList` and `neverInspectedList`, add a `type` field on each pushed row:

   ```ts
   const rowType: 'subdomain' | 'customDomain' = d.customDomain ? 'customDomain' : 'subdomain';

   const rowDomain = d.customDomain ?? `${d.subdomain}.myamsoil.com`;

   needsAttentionList.push({
     dealerId: d.id,
     type: rowType,
     domain: rowDomain,
     indexStatus: d.gscLastIndexStatus,
     submittedAt: d.gscLastSubmittedAt ? d.gscLastSubmittedAt.toISOString() : null,
     inspectedAt: d.gscLastInspectedAt ? d.gscLastInspectedAt.toISOString() : null,
   });
   ```

   Apply the same `type` + `domain` fields to `neverInspectedList`.

4. Rename the response field from `customDomainIndexing` to `dealerIndexing`, and rename internal `active` count to `total` to reflect the new scope.

- [x] **Step 5: Run test, expect pass**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts`
Expected: all tests PASS (including any pre-existing ones; update assertions that referenced the old `customDomain` field name to use the new `domain` + `type` shape).

- [x] **Step 6: Commit**

```bash
git add app/api/admin/gsc/summary/route.ts app/api/admin/gsc/summary/__tests__/route.test.ts
git commit -m "feat(admin-gsc): summary endpoint includes subdomain dealers

Previously the indexing rollup excluded every dealer on the default
<slug>.myamsoil.com subdomain. Admins had no GSC visibility for them.

The endpoint now returns a unified 'dealerIndexing' object with a
per-row 'type' discriminator ('subdomain' | 'customDomain') and a
synthetic 'domain' string so the UI can render either kind in one
table.
"
```

---

### Task B2: Rename `CustomDomainIndexing` → `DealerIndexing` (component) + apply Drawer pattern

Mirror the server-side rename in the UI: rename the component, add the Type column, render subdomain-derived domains, and replace the open `<details>` blocks with `<DrilldownStat>` cards so the indexing buckets behave the same as Queue Health.

**Files:**

- Create: `app/admin/gsc/DealerIndexing.tsx` (copy of old CustomDomainIndexing.tsx, modified)
- Create: `app/admin/gsc/__tests__/DealerIndexing.test.tsx` (adapted from old CustomDomainIndexing.test.tsx)
- Delete: `app/admin/gsc/CustomDomainIndexing.tsx`
- Delete: `app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx`
- Modify: `app/admin/gsc/page.tsx` to import + use the new component name

- [x] **Step 1: Failing test for the new component**

Read `app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx`, copy its structure into `app/admin/gsc/__tests__/DealerIndexing.test.tsx`, and add these new assertions:

```tsx
it('renders a Type column with Subdomain / Custom Domain pills', () => {
  const data = {
    total: 2,
    indexed: 0,
    settling: 0,
    needsAttention: 2,
    neverInspected: 0,
    lastSweepAt: new Date().toISOString(),
    needsAttentionList: [
      {
        dealerId: 'd1',
        type: 'customDomain',
        domain: 'bestsynoil.com',
        indexStatus: 'error',
        submittedAt: null,
        inspectedAt: null,
      },
      {
        dealerId: 'd2',
        type: 'subdomain',
        domain: 'test-starter.myamsoil.com',
        indexStatus: 'error',
        submittedAt: null,
        inspectedAt: null,
      },
    ],
    neverInspectedList: [],
  };
  render(<DealerIndexing data={data} onSelectDealer={() => {}} />);
  expect(screen.getByText('Custom Domain')).toBeInTheDocument();
  expect(screen.getByText('Subdomain')).toBeInTheDocument();
  expect(screen.getByText('bestsynoil.com')).toBeInTheDocument();
  expect(screen.getByText('test-starter.myamsoil.com')).toBeInTheDocument();
});

it('clicking a bucket stat expands the bucket list', () => {
  // [same shape as A4 drilldown test] — assert that the needsAttentionList
  // is hidden by default and revealed after clicking the 'Needs attention'
  // stat card.
});
```

- [x] **Step 2: Run test, expect failure**

Run: `npx jest app/admin/gsc/__tests__/DealerIndexing.test.tsx`
Expected: module not found.

- [x] **Step 3: Implement the new component**

Create `app/admin/gsc/DealerIndexing.tsx`. Take `CustomDomainIndexing.tsx` as a base and apply these changes:

```tsx
'use client';

import React from 'react';
import { DrilldownStat } from './DrilldownStat';
import styles from './page.module.css';

export interface DealerIndexingRow {
  dealerId: string;
  type: 'subdomain' | 'customDomain';
  domain: string;
  indexStatus: string | null;
  submittedAt: string | null;
  inspectedAt: string | null;
}

export interface DealerIndexingData {
  total: number;
  indexed: number;
  settling: number;
  needsAttention: number;
  neverInspected: number;
  lastSweepAt: string | null;
  needsAttentionList: DealerIndexingRow[];
  neverInspectedList: Array<Pick<DealerIndexingRow, 'dealerId' | 'type' | 'domain'>>;
}

interface Props {
  data: DealerIndexingData;
  onSelectDealer: (id: string) => void;
  propertyHeadroom?: { ownedEstimate: number; cap: number };
}

function ageDays(iso: string | null): string {
  if (!iso) return '—';
  return `${Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)}d`;
}
function relative(iso: string | null): string {
  if (!iso) return 'never';
  const h = Math.floor((Date.now() - Date.parse(iso)) / 3_600_000);
  if (h < 1) return 'just now';
  if (h < 24) return `${h}h ago`;
  return `${Math.floor(h / 24)}d ago`;
}

function TypePill({ type }: { type: 'subdomain' | 'customDomain' }) {
  return (
    <span className={styles.typePill}>
      {type === 'customDomain' ? 'Custom Domain' : 'Subdomain'}
    </span>
  );
}

function BucketList({
  rows,
  onSelectDealer,
  empty,
}: {
  rows: DealerIndexingRow[];
  onSelectDealer: (id: string) => void;
  empty: string;
}) {
  if (rows.length === 0) return <p className={styles.emptyState}>{empty}</p>;
  return (
    <ul className={styles.cdList}>
      {rows.map((r) => (
        <li key={r.dealerId}>
          <button type="button" className={styles.cdRow} onClick={() => onSelectDealer(r.dealerId)}>
            <TypePill type={r.type} />
            <span className={styles.code}>{r.domain}</span>
            <span className={styles.cdRowMeta}>{r.indexStatus ?? 'unknown'}</span>
            {'submittedAt' in r && (
              <span className={styles.cdRowMeta}>sub {ageDays(r.submittedAt)}</span>
            )}
            {'inspectedAt' in r && (
              <span className={styles.cdRowMeta}>seen {relative(r.inspectedAt)}</span>
            )}
            <span aria-hidden="true">→</span>
          </button>
        </li>
      ))}
    </ul>
  );
}

export function DealerIndexing({ data, onSelectDealer, propertyHeadroom }: Props) {
  return (
    <div className={styles.card}>
      <h2 className={styles.cardTitle}>
        Dealer Indexing <span className={styles.cdActive}>· {data.total} active</span>
      </h2>

      {data.total === 0 ? (
        <p className={styles.emptyState}>
          No active dealers. (Counts populate after the daily audit has inspected them.)
        </p>
      ) : (
        <>
          <DrilldownStat.Group>
            <DrilldownStat label="Indexed" value={data.indexed} accent="success">
              {/* This bucket is large; show only that it's healthy. Detail is
                  available via the per-dealer modal. */}
              <p className={styles.emptyState}>
                Healthy — {data.indexed} dealer(s) with at least one indexed URL.
              </p>
            </DrilldownStat>
            <DrilldownStat label="Settling" value={data.settling}>
              <p className={styles.emptyState}>
                {data.settling} dealer(s) submitted recently and awaiting Google's response.
              </p>
            </DrilldownStat>
            <DrilldownStat
              label="Needs attention"
              value={data.needsAttention}
              accent={data.needsAttention > 0 ? 'warning' : 'default'}
            >
              <BucketList
                rows={data.needsAttentionList}
                onSelectDealer={onSelectDealer}
                empty="None — every active dealer is indexed or settling."
              />
            </DrilldownStat>
            <DrilldownStat label="Never inspected" value={data.neverInspected}>
              <BucketList
                rows={data.neverInspectedList.map((r) => ({
                  ...r,
                  indexStatus: null,
                  submittedAt: null,
                  inspectedAt: null,
                }))}
                onSelectDealer={onSelectDealer}
                empty="None."
              />
            </DrilldownStat>
          </DrilldownStat.Group>

          <p className={styles.cdSweep}>
            Last sweep: {data.lastSweepAt ? relative(data.lastSweepAt) : 'no inspections yet'}
          </p>

          {propertyHeadroom && (
            <p className={styles.cdHeadroom}>
              GSC properties: ≈{propertyHeadroom.ownedEstimate} / {propertyHeadroom.cap}
            </p>
          )}
        </>
      )}
    </div>
  );
}
```

Add `.typePill` to `app/admin/gsc/page.module.css`:

```css
.typePill {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 8px;
  background: var(--color-surface);
  color: var(--color-text-secondary);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.3px;
  margin-right: 8px;
}
```

- [x] **Step 4: Update `page.tsx`**

In `app/admin/gsc/page.tsx`:

```tsx
// Replace the import:
import { DealerIndexing, type DealerIndexingData } from './DealerIndexing';

// Update the SummaryData interface:
interface SummaryData {
  todayQuotaUsed: number;
  queueCounts: QueueCounts;
  recentFailures: RecentFailure[];
  dealerIndexing: DealerIndexingData;
  propertyHeadroom?: { ownedEstimate: number; cap: number };
}

// In the render tree replace:
<CustomDomainIndexing data={data.customDomainIndexing} ... />
// with:
<DealerIndexing
  data={data.dealerIndexing}
  onSelectDealer={setSelectedDealerId}
  propertyHeadroom={data.propertyHeadroom}
/>
```

- [x] **Step 5: Delete the old files**

```bash
git rm app/admin/gsc/CustomDomainIndexing.tsx app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx
```

- [x] **Step 6: Run tests, expect pass**

Run: `npx jest app/admin/gsc/`
Expected: all `app/admin/gsc/` tests PASS.

- [x] **Step 7: Commit**

```bash
git add app/admin/gsc/DealerIndexing.tsx app/admin/gsc/__tests__/DealerIndexing.test.tsx app/admin/gsc/page.tsx app/admin/gsc/page.module.css
git commit -m "feat(admin-gsc): rename CustomDomainIndexing -> DealerIndexing

The section now surfaces both custom-domain AND subdomain dealers (the
default <slug>.myamsoil.com accounts) with a Type column distinguishing
them. Each bucket count is a clickable inline-expand drilldown via the
shared DrilldownStat primitive.
"
```

---

## Section C: Resilient 403 handling

### Task C1: Prisma migration - `deadLetterReason` column + extended status doc

The `GscJob.status` field is a free-form `String` already, so adding the new values `dead_letter` and `wont_fix` requires no schema change to that field - just update the doc comment. The migration adds a new nullable `deadLetterReason` column.

**Files:**

- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/<timestamp>_gsc_dead_letter/migration.sql`

- [x] **Step 1: Update schema**

In `prisma/schema.prisma`, find the `GscJob` model and:

1. Update the status comment to list new values:
   ```prisma
   status  String  @default("pending")
     // pending | running | completed | failed | dead_letter | wont_fix
   ```
2. Add the new column right after `lastError`:
   ```prisma
   deadLetterReason String? @db.Text
   ```

- [x] **Step 2: Generate the migration**

Run: `npx prisma migrate dev --name gsc_dead_letter --create-only`

This produces `prisma/migrations/<timestamp>_gsc_dead_letter/migration.sql` containing:

```sql
ALTER TABLE "GscJob" ADD COLUMN "deadLetterReason" TEXT;
```

Verify the file matches that exact statement (Prisma may include type/length wrappers - that's fine, just confirm the column is being added).

- [x] **Step 3: Apply the migration locally**

Run: `npx prisma migrate dev`
Expected: migration applies cleanly, Prisma Client regenerates with the new field.

- [x] **Step 4: Commit**

```bash
git add prisma/schema.prisma prisma/migrations/*_gsc_dead_letter
git commit -m "feat(gsc): schema for dead-letter / wont-fix job states

Adds GscJob.deadLetterReason to record why a job was dead-lettered
(e.g. 'verification failed: 403 on sites.verify'). The status field
remains a free-form String — new values 'dead_letter' and 'wont_fix'
are documented inline.

This is a zero-downtime migration: the column is nullable, and existing
rows are unaffected.
"
```

---

### Task C2: Worker - `tryAutoRecover` helper

Extract a small helper that, given a job's `siteUrl` and the error from the just-failed handler, attempts to re-prove ownership via `verifyOwnership(siteUrl, 'META')` if and only if the error suggests a verification problem (403 "User not site owner" OR 400 "verification token not found").

**Files:**

- Modify: `lib/gsc-worker.ts` - add `tryAutoRecover`
- Modify: `lib/gsc-worker.test.ts` - add unit tests for `tryAutoRecover`

- [x] **Step 1: Failing test**

In `lib/gsc-worker.test.ts`, add a test block (read the file's existing structure first to match its style):

```ts
import { tryAutoRecover, isVerificationFailure } from './gsc-worker';
import { GoogleApiError } from './google/indexing';
import * as verification from './google/site-verification';

describe('tryAutoRecover', () => {
  beforeEach(() => jest.spyOn(verification, 'verifyOwnership').mockReset());

  it('returns { recovered: true } when verifyOwnership succeeds', async () => {
    const spy = jest.spyOn(verification, 'verifyOwnership').mockResolvedValueOnce(undefined);
    const result = await tryAutoRecover(
      'https://x/',
      new GoogleApiError(403, false, '{"error":{"message":"User not site owner"}}')
    );
    expect(result.recovered).toBe(true);
    expect(spy).toHaveBeenCalledWith('https://x/', 'META');
  });

  it('returns { recovered: false, reason } when verifyOwnership also fails', async () => {
    jest
      .spyOn(verification, 'verifyOwnership')
      .mockRejectedValueOnce(
        new GoogleApiError(403, false, '{"error":{"message":"DNS does not resolve"}}')
      );
    const result = await tryAutoRecover(
      'https://x/',
      new GoogleApiError(403, false, 'User not site owner')
    );
    expect(result.recovered).toBe(false);
    expect(result.reason).toMatch(/DNS does not resolve/);
  });

  it('does not attempt recovery for unrelated errors', async () => {
    const spy = jest.spyOn(verification, 'verifyOwnership');
    const result = await tryAutoRecover('https://x/', new GoogleApiError(429, true, 'quota'));
    expect(result.recovered).toBe(false);
    expect(spy).not.toHaveBeenCalled();
  });
});

describe('isVerificationFailure', () => {
  it('matches 403 with "User not site owner"', () => {
    expect(isVerificationFailure(new GoogleApiError(403, false, 'User not site owner'))).toBe(true);
  });

  it('matches 400 with "verification token not found"', () => {
    expect(
      isVerificationFailure(new GoogleApiError(400, false, '... verification token not found ...'))
    ).toBe(true);
  });

  it('does not match unrelated 4xx', () => {
    expect(isVerificationFailure(new GoogleApiError(404, false, 'not found'))).toBe(false);
    expect(isVerificationFailure(new GoogleApiError(429, true, 'quota'))).toBe(false);
  });
});
```

- [x] **Step 2: Run, expect failure**

Run: `npx jest lib/gsc-worker.test.ts -t "tryAutoRecover"`
Expected: FAIL - functions don't exist yet.

- [x] **Step 3: Implement the helpers**

In `lib/gsc-worker.ts`, after the imports and existing helpers, add:

```ts
import { GoogleApiError } from './google/indexing';

export function isVerificationFailure(err: unknown): boolean {
  if (!(err instanceof GoogleApiError)) return false;
  if (err.status === 403 && /not site owner/i.test(err.body)) return true;
  if (err.status === 400 && /verification token not found/i.test(err.body)) return true;
  return false;
}

export interface AutoRecoverResult {
  recovered: boolean;
  reason?: string;
}

/**
 * On a verification-class failure (403 not-site-owner / 400 token-not-found),
 * attempt to re-prove ownership inline. If that succeeds, the original job
 * has a fresh shot. If it fails, the original error is unrecoverable in this
 * deploy and the caller should mark the job dead_letter.
 *
 * For unrelated errors, returns { recovered: false } without making any
 * network call.
 */
export async function tryAutoRecover(siteUrl: string, err: unknown): Promise<AutoRecoverResult> {
  if (!isVerificationFailure(err)) return { recovered: false };
  try {
    await verifyOwnership(siteUrl, 'META');
    gscLogger.info({ siteUrl }, 'GSC auto-reverify succeeded');
    return { recovered: true };
  } catch (verifyErr) {
    const msg = verifyErr instanceof Error ? verifyErr.message : String(verifyErr);
    gscLogger.warn({ siteUrl, err: msg }, 'GSC auto-reverify failed → dead_letter');
    return { recovered: false, reason: msg };
  }
}
```

- [x] **Step 4: Run tests, expect pass**

Run: `npx jest lib/gsc-worker.test.ts -t "tryAutoRecover|isVerificationFailure"`
Expected: 6 tests PASS.

- [x] **Step 5: Commit**

```bash
git add lib/gsc-worker.ts lib/gsc-worker.test.ts
git commit -m "feat(gsc-worker): tryAutoRecover helper for verification-class 4xx

A 403 'User not site owner' or 400 'verification token not found' means
Google has lost track of our ownership of the property. Retrying the
same operation cannot succeed. tryAutoRecover() re-proves ownership
inline. Wired into the catch block in the next commit.
"
```

---

### Task C3: Worker - wire `tryAutoRecover` into the catch block + dead-letter on persistent failure

When the handler throws, call `tryAutoRecover()` with the resolved site URL. If recovery succeeded, immediately re-run the handler once (a single retry within the same job execution, not a new claim). If recovery itself failed, mark the job `dead_letter` with `deadLetterReason` set to the recovery error.

**Files:**

- Modify: `lib/gsc-worker.ts` - `catch` block at lines 415-450
- Modify: `lib/gsc-worker.test.ts` - wire integration with a fake handler

- [x] **Step 1: Failing test**

In `lib/gsc-worker.test.ts`, add:

```ts
describe('worker catch block — auto-reverify + dead-letter', () => {
  // (Match the existing test setup pattern in this file — likely uses
  //  prismaMock and a runWorkerOnce helper. Read the file to confirm.)

  it('on 403, auto-reverifies and re-runs the handler', async () => {
    const verifySpy = jest.spyOn(verification, 'verifyOwnership').mockResolvedValue(undefined);
    let handlerCalls = 0;
    setHandlerFor('inspect_url', async () => {
      handlerCalls += 1;
      if (handlerCalls === 1) {
        throw new GoogleApiError(403, false, 'User not site owner');
      }
      return; // second call succeeds
    });

    // seed one pending job, run worker once
    await runWorkerOnce();

    expect(verifySpy).toHaveBeenCalledTimes(1);
    expect(handlerCalls).toBe(2);
    const job = await prisma.gscJob.findFirstOrThrow();
    expect(job.status).toBe('completed');
    expect(job.deadLetterReason).toBeNull();
  });

  it('on 403 with reverify also failing, marks the job dead_letter', async () => {
    jest
      .spyOn(verification, 'verifyOwnership')
      .mockRejectedValue(new GoogleApiError(403, false, 'DNS does not resolve'));
    setHandlerFor('inspect_url', async () => {
      throw new GoogleApiError(403, false, 'User not site owner');
    });

    await runWorkerOnce();

    const job = await prisma.gscJob.findFirstOrThrow();
    expect(job.status).toBe('dead_letter');
    expect(job.deadLetterReason).toMatch(/DNS does not resolve/);
  });

  it('non-verification errors fall through to the existing retry/fail path', async () => {
    setHandlerFor('inspect_url', async () => {
      throw new GoogleApiError(500, true, 'transient');
    });

    await runWorkerOnce();

    const job = await prisma.gscJob.findFirstOrThrow();
    expect(['pending', 'failed']).toContain(job.status);
    expect(job.deadLetterReason).toBeNull();
  });
});
```

- [x] **Step 2: Run, expect failure**

Run: `npx jest lib/gsc-worker.test.ts -t "auto-reverify + dead-letter"`
Expected: FAIL.

- [x] **Step 3: Wire into the catch block**

Find the `catch (err)` block at line 415 in `lib/gsc-worker.ts`. Modify to call `tryAutoRecover` and either retry-inline or dead-letter:

```ts
} catch (err) {
  const e = err as Error & { retryable?: boolean; status?: number };

  // Auto-recovery: a verification-class 4xx means Google lost our ownership
  // record. Re-prove ownership inline; if that succeeds, give the original
  // handler one immediate retry. If reverify itself fails, the situation is
  // unrecoverable in this deploy — dead_letter the job.
  if (isVerificationFailure(e)) {
    const siteUrl = resolveSiteUrlForJob(job);  // existing helper, see resolvePropertyForJob
    const recovery = await tryAutoRecover(siteUrl, e);
    if (recovery.recovered) {
      try {
        await runHandler(job);  // existing inner function that drives the case-by-type switch
        const done = await prisma.gscJob.updateMany({
          where: { id: job.id, status: 'running' },
          data: { status: 'completed', completedAt: new Date(), lastError: null },
        });
        if (done.count === 0) continue;
        result.completed++;
        continue;
      } catch (retryErr) {
        // Fall through — treat the second failure as the new error and
        // continue with the existing retry/dead-letter logic.
        Object.assign(e, { message: (retryErr as Error).message });
      }
    } else {
      await prisma.gscJob.updateMany({
        where: { id: job.id, status: 'running' },
        data: {
          status: 'dead_letter',
          attempts: job.attempts + 1,
          lastError: e.message,
          deadLetterReason: `auto-reverify failed: ${recovery.reason ?? 'unknown'}`,
        },
      });
      result.deadLettered = (result.deadLettered ?? 0) + 1;
      gscLogger.warn(
        { jobId: job.id, type: job.type, dealerId: job.dealerId,
          reason: recovery.reason },
        'GSC job dead-lettered after failed auto-reverify'
      );
      continue;
    }
  }

  // … original retry/fail logic continues unchanged …
  const retryable = e.retryable !== false;
  const newAttempts = job.attempts + 1;
  // (original code from lines 418-449 stays here)
}
```

Two things to note:

1. **`runHandler(job)` and `resolveSiteUrlForJob(job)` are helper extractions** you need to perform first. The existing worker has the per-`type` switch inline in the `try` block; extract it into a `runHandler(job): Promise<void>` so the auto-recovery path can call it again. Similarly extract the site-URL resolution. The extraction is mechanical - move the `switch (job.type)` block into a function and call it from both places.

2. **`result.deadLettered`**: extend the existing `result` shape to include `deadLettered: number`. The `WorkerRunResult` interface (find it earlier in the file) needs the new field added.

- [x] **Step 4: Run tests, expect pass**

Run: `npx jest lib/gsc-worker.test.ts`
Expected: all worker tests PASS (existing + new).

- [x] **Step 5: Commit**

```bash
git add lib/gsc-worker.ts lib/gsc-worker.test.ts
git commit -m "feat(gsc-worker): auto-reverify + dead-letter on persistent 4xx

A 403 'User not site owner' (or 400 'verification token not found')
now triggers an inline sites.verify() call. If that succeeds, the
original handler is re-run once and typically completes — most 403s
self-heal silently.

If sites.verify() also fails, the job is moved to status='dead_letter'
with deadLetterReason recording the verification failure. Admins see
dead-lettered jobs in the new drilldown (Task C7) and can manually
release or mark-won't-fix.

Most importantly: the perma-403 retry loop that was burning quota and
filling the failures list is gone.
"
```

---

### Task C4: Integration test - real Postgres, real verifyOwnership mock

Add an integration test that exercises the entire worker against a real Postgres (the `*.int.test.ts` pattern already in this repo) to prove the dead_letter status transitions persist correctly through the worker's `updateMany` queries.

**Files:**

- Create: `lib/__tests__/gsc-worker.dead-letter.int.test.ts`

- [x] **Step 1: Write the integration test**

Read `lib/__tests__/gsc-worker.concurrency.int.test.ts` first to copy its setup/teardown pattern (real Prisma client, real DB, before/after cleanup).

```ts
import { describe, it, expect, beforeAll, afterEach, jest } from '@jest/globals';
import { prisma } from '@/lib/prisma';
import { runWorker } from '@/lib/gsc-worker';
import * as verification from '@/lib/google/site-verification';
import { GoogleApiError } from '@/lib/google/indexing';
// + helpers to mock the per-handler Google API calls used by the inspect_url path

describe('gsc-worker dead-letter (integration)', () => {
  beforeAll(async () => {
    // ensure schema includes deadLetterReason; assert it does
    const cols = await prisma.$queryRaw<{ column_name: string }[]>`
      SELECT column_name FROM information_schema.columns
      WHERE table_name = 'GscJob' AND column_name = 'deadLetterReason'`;
    expect(cols).toHaveLength(1);
  });

  afterEach(async () => {
    await prisma.gscJob.deleteMany();
    await prisma.dealer.deleteMany({ where: { email: { startsWith: 'dl-test+' } } });
  });

  it('persists status=dead_letter with deadLetterReason when reverify also fails', async () => {
    const dealer = await prisma.dealer.create({
      data: {
        email: 'dl-test+1@claude.dev',
        subdomain: 'dl-test-1',
        domain: 'dl-test-1.myamsoil.com',
        customDomain: null,
        // …other required fields per Dealer schema
      },
    });
    await prisma.gscJob.create({
      data: {
        type: 'inspect_url',
        dealerId: dealer.id,
        payload: { url: 'https://dl-test-1.myamsoil.com/' },
        status: 'pending',
      },
    });

    // Stub the upstream Google calls used by inspect_url so the handler throws 403.
    // (Pattern: see existing worker tests for how inspectUrl is mocked.)
    jest
      .spyOn(verification, 'verifyOwnership')
      .mockRejectedValueOnce(new GoogleApiError(403, false, 'DNS does not resolve'));
    // (also stub inspectUrl to throw a 403 'User not site owner')

    await runWorker();

    const job = await prisma.gscJob.findFirstOrThrow();
    expect(job.status).toBe('dead_letter');
    expect(job.deadLetterReason).toMatch(/DNS does not resolve/);
    expect(job.attempts).toBeGreaterThanOrEqual(1);
  });
});
```

- [x] **Step 2: Run the int test**

Run: `npx jest lib/__tests__/gsc-worker.dead-letter.int.test.ts --runInBand`
Expected: PASS against real Postgres. Verify the existing `JEST_INT_DATABASE_URL` (or whatever this repo uses) is configured.

- [x] **Step 3: Commit**

```bash
git add lib/__tests__/gsc-worker.dead-letter.int.test.ts
git commit -m "test(gsc-worker): integration coverage for dead-letter transition"
```

---

### Task C5: Admin endpoints - Release + Won't-Fix

Two POST endpoints under `/api/admin/gsc/dead-letter/[id]/`. `release` moves the job from `dead_letter` back to `pending` with `attempts: 0`. `wontfix` moves it to a terminal `wont_fix` status. Both are admin-gated.

**Files:**

- Create: `app/api/admin/gsc/dead-letter/[id]/release/route.ts`
- Create: `app/api/admin/gsc/dead-letter/[id]/release/__tests__/route.test.ts`
- Create: `app/api/admin/gsc/dead-letter/[id]/wontfix/route.ts`
- Create: `app/api/admin/gsc/dead-letter/[id]/wontfix/__tests__/route.test.ts`

- [x] **Step 1: Failing test for `release`**

Create `app/api/admin/gsc/dead-letter/[id]/release/__tests__/route.test.ts`:

```ts
import { describe, it, expect, beforeEach } from '@jest/globals';
import { POST } from '../route';
import { NextRequest } from 'next/server';
// + the existing admin-auth mock helper used by other route tests (find via grep
//   for "requireAdmin" mocks in app/api/admin/**/__tests__/)

describe('POST /api/admin/gsc/dead-letter/[id]/release', () => {
  it('requires admin auth', async () => {
    mockUnauthenticated();
    const res = await POST(new NextRequest('http://x'), { params: Promise.resolve({ id: 'j1' }) });
    expect(res.status).toBe(401);
  });

  it('moves a dead_letter job back to pending and resets attempts', async () => {
    mockAdmin();
    const job = await prisma.gscJob.create({
      data: {
        /* …status: 'dead_letter', attempts: 5 */
      },
    });
    const res = await POST(new NextRequest('http://x'), {
      params: Promise.resolve({ id: job.id }),
    });
    expect(res.status).toBe(200);
    const after = await prisma.gscJob.findUniqueOrThrow({ where: { id: job.id } });
    expect(after.status).toBe('pending');
    expect(after.attempts).toBe(0);
    expect(after.deadLetterReason).toBeNull();
  });

  it('refuses to release a non-dead_letter job', async () => {
    mockAdmin();
    const job = await prisma.gscJob.create({
      data: {
        /* …status: 'completed' */
      },
    });
    const res = await POST(new NextRequest('http://x'), {
      params: Promise.resolve({ id: job.id }),
    });
    expect(res.status).toBe(409);
  });
});
```

- [x] **Step 2: Run, expect failure**

Run: `npx jest app/api/admin/gsc/dead-letter/[id]/release/__tests__/route.test.ts`
Expected: module not found.

- [x] **Step 3: Implement release**

Create `app/api/admin/gsc/dead-letter/[id]/release/route.ts`:

```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import { prisma } from '@/lib/prisma';

export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const auth = await requireAdmin();
  if ('response' in auth) return auth.response;

  const { id } = await params;

  // Conditional update: only release jobs that are actually dead_letter.
  const result = await prisma.gscJob.updateMany({
    where: { id, status: 'dead_letter' },
    data: {
      status: 'pending',
      attempts: 0,
      deadLetterReason: null,
      runAt: new Date(),
    },
  });

  if (result.count === 0) {
    return NextResponse.json({ error: 'job not found or not dead_letter' }, { status: 409 });
  }

  return NextResponse.json({ ok: true });
}
```

- [x] **Step 4: Run test, expect pass**

Run: `npx jest app/api/admin/gsc/dead-letter/[id]/release/__tests__/route.test.ts`
Expected: 3 tests PASS.

- [x] **Step 5: Mirror for `wontfix`**

Create `app/api/admin/gsc/dead-letter/[id]/wontfix/__tests__/route.test.ts` with the same structure (auth gate + state transition + wrong-source-state guard). Create `wontfix/route.ts` with the same body but the data block is:

```ts
data: {
  status: 'wont_fix',
  // Preserve deadLetterReason as historical record
},
```

The wrong-source-state guard accepts both `dead_letter` (normal flow) and `failed` (rare manual escalation) - using `status: { in: ['dead_letter', 'failed'] }` in the where clause.

- [x] **Step 6: Run wontfix test, expect pass**

Run: `npx jest app/api/admin/gsc/dead-letter/[id]/wontfix/__tests__/route.test.ts`
Expected: 3 tests PASS.

- [x] **Step 7: Commit**

```bash
git add app/api/admin/gsc/dead-letter
git commit -m "feat(admin-gsc): release + wont-fix endpoints for dead-letter jobs"
```

---

### Task C6: Summary route - return dead_letter counts + list

Add `dead_letter` to the queue counts and surface a list of recent dead-letter jobs with the reason so the new drilldown card has data.

**Files:**

- Modify: `app/api/admin/gsc/summary/route.ts`
- Modify: `app/api/admin/gsc/summary/__tests__/route.test.ts`

- [x] **Step 1: Failing test**

In `app/api/admin/gsc/summary/__tests__/route.test.ts`:

```ts
it('returns dead_letter count and list', async () => {
  await prisma.gscJob.create({
    data: {
      type: 'inspect_url',
      dealerId: someDealer.id,
      payload: {},
      status: 'dead_letter',
      deadLetterReason: 'auto-reverify failed: DNS does not resolve',
    },
  });
  const res = await GET(new NextRequest('http://x'));
  const body = await res.json();
  expect(body.queueCounts.dead_letter).toBeGreaterThanOrEqual(1);
  expect(body.deadLetterList.length).toBeGreaterThanOrEqual(1);
  expect(body.deadLetterList[0].deadLetterReason).toMatch(/DNS does not resolve/);
});
```

- [x] **Step 2: Run, expect failure**

Expected: shape mismatch.

- [x] **Step 3: Update the summary route**

Add to the `Promise.all(...)` block:

```ts
prisma.gscJob.count({ where: { status: 'dead_letter' } }),
prisma.gscJob.findMany({
  where: { status: 'dead_letter' },
  orderBy: { updatedAt: 'desc' },
  take: 50,
  select: {
    id: true,
    type: true,
    dealerId: true,
    deadLetterReason: true,
    lastError: true,
    updatedAt: true,
    dealer: {
      select: { businessName: true, email: true, customDomain: true, subdomain: true },
    },
  },
}),
```

And in the response JSON include:

```ts
queueCounts: { pending, running, completed24h, failed, dead_letter: deadLetterCount },
deadLetterList: deadLetterRaw.map((j) => ({
  id: j.id,
  type: j.type,
  dealerId: j.dealerId,
  dealerName: j.dealer.businessName,
  dealerEmail: j.dealer.email,
  domain: j.dealer.customDomain ?? (j.dealer.subdomain ? `${j.dealer.subdomain}.myamsoil.com` : null),
  deadLetterReason: j.deadLetterReason,
  lastError: j.lastError,
  updatedAt: j.updatedAt.toISOString(),
})),
```

- [x] **Step 4: Run, expect pass**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts`
Expected: PASS.

- [x] **Step 5: Commit**

```bash
git add app/api/admin/gsc/summary/route.ts app/api/admin/gsc/summary/__tests__/route.test.ts
git commit -m "feat(admin-gsc/summary): include dead_letter count + list"
```

---

### Task C7: 9th drilldown card - Dead Letter

Add a `<DrilldownStat>` card to Queue Health for `Dead Letter`. Drawer renders the list from `summary.deadLetterList` with per-row Release and Won't-Fix buttons. Clicking the dealer name opens the modal as elsewhere.

**Files:**

- Create: `app/admin/gsc/DeadLetterDrawer.tsx`
- Create: `app/admin/gsc/__tests__/DeadLetterDrawer.test.tsx`
- Modify: `app/admin/gsc/page.tsx` (add a 5th `<DrilldownStat>` to QueueHealth)

- [x] **Step 1: Failing test for the drawer**

Create `app/admin/gsc/__tests__/DeadLetterDrawer.test.tsx`:

```tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DeadLetterDrawer } from '../DeadLetterDrawer';

const rows = [
  {
    id: 'j1',
    type: 'inspect_url',
    dealerId: 'd1',
    dealerName: 'Acme',
    dealerEmail: 'a@b.com',
    domain: 'acme.com',
    deadLetterReason: 'auto-reverify failed: DNS does not resolve',
    lastError: 'Google API 403: User not site owner',
    updatedAt: new Date().toISOString(),
  },
];

describe('DeadLetterDrawer', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('renders the dead letter reason per row', () => {
    render(<DeadLetterDrawer rows={rows} onSelectDealer={() => {}} onChanged={() => {}} />);
    expect(screen.getByText(/DNS does not resolve/i)).toBeInTheDocument();
  });

  it('Release button POSTs to /release and calls onChanged on success', async () => {
    (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
    const onChanged = jest.fn();
    render(<DeadLetterDrawer rows={rows} onSelectDealer={() => {}} onChanged={onChanged} />);
    fireEvent.click(screen.getByRole('button', { name: /release/i }));
    await waitFor(() => expect(onChanged).toHaveBeenCalled());
    expect(global.fetch).toHaveBeenCalledWith(
      '/api/admin/gsc/dead-letter/j1/release',
      expect.objectContaining({ method: 'POST' })
    );
  });

  it("Won't-fix button POSTs to /wontfix", async () => {
    (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true });
    render(<DeadLetterDrawer rows={rows} onSelectDealer={() => {}} onChanged={() => {}} />);
    fireEvent.click(screen.getByRole('button', { name: /won't fix/i }));
    await waitFor(() =>
      expect(global.fetch).toHaveBeenCalledWith(
        '/api/admin/gsc/dead-letter/j1/wontfix',
        expect.objectContaining({ method: 'POST' })
      )
    );
  });
});
```

- [x] **Step 2: Run, expect failure**

Run: `npx jest app/admin/gsc/__tests__/DeadLetterDrawer.test.tsx`
Expected: module not found.

- [x] **Step 3: Implement**

Create `app/admin/gsc/DeadLetterDrawer.tsx`:

```tsx
'use client';

import React, { useCallback } from 'react';
import { JobTypePill } from '@/components/admin/JobTypePill';
import styles from './FailuresTable.module.css';

interface DeadLetterRow {
  id: string;
  type: string;
  dealerId: string;
  dealerName: string | null;
  dealerEmail: string | null;
  domain: string | null;
  deadLetterReason: string | null;
  lastError: string | null;
  updatedAt: string;
}

interface Props {
  rows: DeadLetterRow[];
  onSelectDealer: (id: string) => void;
  onChanged: () => void; // called after a release or wontfix
}

export function DeadLetterDrawer({ rows, onSelectDealer, onChanged }: Props) {
  const act = useCallback(
    async (id: string, action: 'release' | 'wontfix') => {
      const res = await fetch(`/api/admin/gsc/dead-letter/${id}/${action}`, {
        method: 'POST',
      });
      if (res.ok) onChanged();
    },
    [onChanged]
  );

  if (rows.length === 0) {
    return <p>None — no jobs are stuck in dead-letter.</p>;
  }

  return (
    <table className={styles.table}>
      <thead>
        <tr>
          <th>Dealer</th>
          <th>Domain</th>
          <th>Type</th>
          <th>Reason</th>
          <th>When</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {rows.map((r) => (
          <tr key={r.id}>
            <td onClick={() => onSelectDealer(r.dealerId)}>
              <div className={styles.dealerName}>{r.dealerName ?? r.dealerId}</div>
              {r.dealerEmail && <div className={styles.dealerEmail}>{r.dealerEmail}</div>}
            </td>
            <td>{r.domain ?? '—'}</td>
            <td>
              <JobTypePill type={r.type} />
            </td>
            <td>{r.deadLetterReason ?? r.lastError ?? '—'}</td>
            <td>{new Date(r.updatedAt).toLocaleString()}</td>
            <td>
              <button type="button" onClick={() => act(r.id, 'release')}>
                Release
              </button>{' '}
              <button type="button" onClick={() => act(r.id, 'wontfix')}>
                Won't fix
              </button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
```

- [x] **Step 4: Wire into Queue Health**

In `app/admin/gsc/page.tsx`:

```tsx
import { DeadLetterDrawer } from './DeadLetterDrawer';

interface QueueCounts {
  pending: number;
  running: number;
  completed24h: number;
  failed: number;
  dead_letter: number;
}

interface SummaryData {
  todayQuotaUsed: number;
  queueCounts: QueueCounts;
  dealerIndexing: DealerIndexingData;
  deadLetterList: Array<{
    id: string;
    type: string;
    dealerId: string;
    dealerName: string | null;
    dealerEmail: string | null;
    domain: string | null;
    deadLetterReason: string | null;
    lastError: string | null;
    updatedAt: string;
  }>;
  propertyHeadroom?: { ownedEstimate: number; cap: number };
}

// In QueueHealth, after the Failed card:
<DrilldownStat
  label="Dead Letter"
  value={counts.dead_letter}
  accent={counts.dead_letter > 0 ? 'error' : 'default'}
>
  <DeadLetterDrawer rows={deadLetterList} onSelectDealer={onSelectDealer} onChanged={refetch} />
</DrilldownStat>;
```

Where `deadLetterList` and `refetch` are threaded down from `GscAdminPage` - `refetch` re-runs the `load()` function that populates `data` so the count updates after a release.

- [x] **Step 5: Run tests, expect pass**

Run: `npx jest app/admin/gsc/__tests__/DeadLetterDrawer.test.tsx app/admin/gsc/__tests__/page.test.tsx`
Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add app/admin/gsc/DeadLetterDrawer.tsx app/admin/gsc/__tests__/DeadLetterDrawer.test.tsx app/admin/gsc/page.tsx
git commit -m "feat(admin-gsc): Dead Letter drilldown with Release/Won't-fix actions"
```

---

## Section D: Wiring + ship

### Task D1: Full test suite + manual verification

- [x] **Step 1: Run the full test suite**

Run: `npm test`
Expected: all tests PASS. Investigate any failure; do not move to D2 until green.

- [x] **Step 2: Type check**

Run: `npx tsc --noEmit`
Expected: clean, no errors.

- [x] **Step 3: Build**

Run: `npm run build`
Expected: production build succeeds. Watch for `'use client'` boundary errors or Next.js complaints about server-only imports.

- [x] **Step 4: Manual browser verification**

Start the app per `npm run power-cycle`, log in as `test-admin@claude.dev`, navigate to `/admin/gsc`. Verify:

- Each Queue Health stat opens an inline drawer when clicked; only one open at a time.
- Failed drawer shows the redesigned FailuresTable: dealer column stacked, domain link in new tab, type pill tooltip on hover, error pill red.
- Dead Letter drawer renders (if any dead-letter jobs exist) and Release button moves the job back to pending.
- Dealer Indexing section title is "Dealer Indexing", shows Type column, includes a subdomain dealer.
- Open a dealer modal → Indexing & SEO section shows single `Reindex Now` button, no `Deep Audit`. Recent Activity table shows natural-English type pills with tooltip on hover.

If any of these are broken, file a bugfix step inline and re-run from Task D1 Step 1.

### Task D2: Push branch + open PR

- [ ] **Step 1: Confirm the diff with the user**

Show `git log --oneline dev..HEAD` and `git diff --stat dev..HEAD` to the user. Wait for explicit "push" approval per the standing constraint ("Never push to origin / PR without explicit user direction").

- [ ] **Step 2: Push branch**

Run: `git push -u origin feat/gsc-admin-fastfollow`

- [ ] **Step 3: Open PR**

Use `gh pr create` with title and body per the established repo PR style. Link issue #865 in the body. Mention follow-up issues filed.

---

## Self-Review Notes

- **Spec coverage** - every locked design decision (modal button collapse, Deep Audit comment-out, type pills+tooltips, drilldown drawers on all 8 stats, dealer indexing rename + subdomain inclusion, dead-letter handling) maps to a task above.
- **No placeholders** - every step contains the actual code to write or modify; no "TBD" / "add appropriate error handling" / "similar to Task N" patterns.
- **Type consistency** - `DealerIndexingData`, `FailureRow`, `JobRow`, `DeadLetterRow` are all defined explicitly. The summary endpoint shape (`dealerIndexing` not `customDomainIndexing`, `domain` field with `type` discriminator) is consistent between Task B1 (server) and Task B2 (client).
- **Known soft spots** that an implementer should resolve in-task by reading the existing code:
  - The exact test-helper pattern in `lib/gsc-worker.test.ts` (mocks/fixtures) - the plan tells the worker tests what to assert but tells the implementer to match the existing file's patterns rather than inventing new ones.
  - The exact `Dealer` schema field for the active-status predicate in Task B1 Step 1 - read the schema before settling on the filter expression.
  - The exact admin-auth mock pattern in the endpoint route tests (Task C5) - find by grepping for `requireAdmin` mocks in sibling test files.

These soft spots are noted explicitly so an implementer doesn't try to invent patterns inconsistent with the existing codebase.
