~ knotless Search/
↑↓ move↵ open⇧↵ all results

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
540 lines · 17.6 KB Raw History
# User Reports Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Let authenticated users report accounts/records by selecting a label, and give admins a queue to review and act on those reports.

**Architecture:** New `_reports` SQLite table + db helper functions, a `dev.hatk.createReport` XRPC endpoint registered as a core handler, three admin REST endpoints in server.ts, and a new "Reports" tab in admin.html.

**Tech Stack:** TypeScript, SQLite (better-sqlite3), vanilla HTML/CSS/JS admin UI

---

### Task 1: Create the `_reports` table in database init

**Files:**

- Modify: `packages/hatk/src/database/db.ts:67-127` (inside `initDatabase`, after the `_preferences` table creation)

**Step 1: Add the table creation SQL**

After the `_preferences` table block (line ~123) and before the OAuth DDL line, add:

```typescript
// Reports table (user-submitted moderation reports)
await run(`CREATE TABLE IF NOT EXISTS _reports (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    subject_uri TEXT NOT NULL,
    subject_did TEXT NOT NULL,
    label TEXT NOT NULL,
    reason TEXT,
    reported_by TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'open',
    resolved_by TEXT,
    resolved_at TEXT,
    created_at TEXT NOT NULL
  )`)
await run(`CREATE INDEX IF NOT EXISTS idx_reports_status ON _reports(status)`)
await run(`CREATE INDEX IF NOT EXISTS idx_reports_subject_uri ON _reports(subject_uri)`)
```

**Step 2: Verify the app boots**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds

---

### Task 2: Add report database helper functions

**Files:**

- Modify: `packages/hatk/src/database/db.ts` (add new exported functions at the end, before the final closing brace or after the last export)

**Step 1: Add the helper functions**

Add these exports to `db.ts`:

```typescript
export async function insertReport(report: {
  subjectUri: string
  subjectDid: string
  label: string
  reason?: string
  reportedBy: string
}): Promise<{ id: number }> {
  const createdAt = new Date().toISOString()
  await run(
    `INSERT INTO _reports (subject_uri, subject_did, label, reason, reported_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)`,
    [report.subjectUri, report.subjectDid, report.label, report.reason || null, report.reportedBy, createdAt],
  )
  const rows = await all<{ id: number }>(`SELECT last_insert_rowid() as id`)
  return { id: rows[0].id }
}

export async function queryReports(opts: {
  status?: string
  label?: string
  limit?: number
  offset?: number
}): Promise<{ reports: any[]; total: number }> {
  const conditions: string[] = []
  const params: unknown[] = []
  let idx = 1

  if (opts.status) {
    conditions.push(`r.status = $${idx++}`)
    params.push(opts.status)
  }
  if (opts.label) {
    conditions.push(`r.label = $${idx++}`)
    params.push(opts.label)
  }

  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
  const limit = opts.limit || 50
  const offset = opts.offset || 0

  const countRows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM _reports r ${where}`, params)
  const total = Number(countRows[0]?.count || 0)

  const rows = await all(
    `SELECT r.*, rp.handle as reported_by_handle FROM _reports r LEFT JOIN _repos rp ON r.reported_by = rp.did ${where} ORDER BY r.created_at DESC LIMIT $${idx++} OFFSET $${idx++}`,
    [...params, limit, offset],
  )

  return { reports: rows, total }
}

export async function resolveReport(
  id: number,
  action: 'resolved' | 'dismissed',
  resolvedBy: string,
): Promise<{ subjectUri: string; label: string } | null> {
  const rows = await all<{ subject_uri: string; label: string; status: string }>(
    `SELECT subject_uri, label, status FROM _reports WHERE id = $1`,
    [id],
  )
  if (!rows.length) return null
  if (rows[0].status !== 'open') return null

  await run(`UPDATE _reports SET status = $1, resolved_by = $2, resolved_at = $3 WHERE id = $4`, [
    action,
    resolvedBy,
    new Date().toISOString(),
    id,
  ])
  return { subjectUri: rows[0].subject_uri, label: rows[0].label }
}

export async function getOpenReportCount(): Promise<number> {
  const rows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM _reports WHERE status = 'open'`)
  return Number(rows[0]?.count || 0)
}
```

**Step 2: Verify build**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds

---

### Task 3: Register `dev.hatk.createReport` XRPC handler

**Files:**

- Modify: `packages/hatk/src/server.ts:27` (add imports)
- Modify: `packages/hatk/src/server.ts:167-203` (inside the `if (oauth)` block, add the new handler)

**Step 1: Add imports**

Add `insertReport` to the existing import from `'./database/db.ts'` (line 6-27):

```typescript
import {
  // ... existing imports ...
  insertReport,
} from './database/db.ts'
```

**Step 2: Add the handler**

Inside the `if (oauth) {` block (after the existing `dev.hatk.uploadBlob` handler, around line 202), add:

```typescript
registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
  if (!viewer) throw new InvalidRequestError('Authentication required')
  const body = input as { subject?: any; label?: string; reason?: string }
  if (!body.subject) throw new InvalidRequestError('Missing subject')
  if (!body.label || typeof body.label !== 'string') throw new InvalidRequestError('Missing or invalid label')

  // Validate label exists in definitions
  const defs = getLabelDefinitions()
  if (!defs.some((d) => d.identifier === body.label)) {
    throw new InvalidRequestError(`Unknown label: ${body.label}`)
  }

  // Validate reason length
  if (body.reason && body.reason.length > 2000) {
    throw new InvalidRequestError('Reason must be 2000 characters or less')
  }

  // Determine subject URI and DID
  let subjectUri: string
  let subjectDid: string
  if (body.subject.uri) {
    // Record report: { uri, cid }
    subjectUri = body.subject.uri
    // Extract DID from at:// URI
    const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/)
    if (!match) throw new InvalidRequestError('Invalid subject URI')
    subjectDid = match[1]
  } else if (body.subject.did) {
    // Account report: { did }
    subjectUri = `at://${body.subject.did}`
    subjectDid = body.subject.did
  } else {
    throw new InvalidRequestError('Subject must have uri or did')
  }

  const result = await insertReport({
    subjectUri,
    subjectDid,
    label: body.label,
    reason: body.reason,
    reportedBy: viewer.did,
  })

  return {
    id: result.id,
    subject: body.subject,
    label: body.label,
    reason: body.reason || null,
    reportedBy: viewer.did,
    createdAt: new Date().toISOString(),
  }
})
```

**Step 3: Verify build**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds

---

### Task 4: Add admin report endpoints

**Files:**

- Modify: `packages/hatk/src/server.ts` (add imports + admin routes)

**Step 1: Add imports**

Add `queryReports`, `resolveReport`, `getOpenReportCount` to the import from `'./database/db.ts'`:

```typescript
import {
  // ... existing imports ...
  queryReports,
  resolveReport,
  getOpenReportCount,
} from './database/db.ts'
```

**Step 2: Add the GET /admin/reports endpoint**

After the existing `GET /admin/repos` handler (around line 670), add:

```typescript
// GET /admin/reports — list reports
if (url.pathname === '/admin/reports' && request.method === 'GET') {
  const denied = requireAdmin(viewer, acceptEncoding)
  if (denied) return denied
  const status = url.searchParams.get('status') || 'open'
  const label = url.searchParams.get('label') || undefined
  const limit = parseInt(url.searchParams.get('limit') || '50')
  const offset = parseInt(url.searchParams.get('offset') || '0')
  const result = await queryReports({ status, label, limit, offset })
  return withCors(json(result, 200, acceptEncoding))
}
```

**Step 3: Add the POST /admin/reports/resolve endpoint**

Right after the GET handler above:

```typescript
// POST /admin/reports/resolve — resolve or dismiss a report
if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
  const denied = requireAdmin(viewer, acceptEncoding)
  if (denied) return denied
  const { id, action } = JSON.parse(await request.text())
  if (!id || !action) return withCors(jsonError(400, 'Missing id or action', acceptEncoding))
  if (action !== 'resolve' && action !== 'dismiss')
    return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding))

  const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer!.did)
  if (!report) return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding))

  // If resolving, apply the label
  if (action === 'resolve') {
    await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }])
  }
  return withCors(json({ ok: true }, 200, acceptEncoding))
}
```

**Step 4: Add openReports to the GET /admin/info response**

In the existing `/admin/info` handler (around line 619-636), modify the return to include the open report count. Change:

```typescript
return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding))
```

to:

```typescript
const openReports = await getOpenReportCount()
return withCors(
  json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts, openReports }, 200, acceptEncoding),
)
```

**Step 5: Verify build**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds

---

### Task 5: Add Reports tab to admin UI

**Files:**

- Modify: `packages/hatk/public/admin.html`

**Step 1: Add the Reports tab button to desktop nav**

Find the `<nav class="tabs">` element (around line 1202) and add the Reports button:

```html
<nav class="tabs">
  <button class="tab active" data-tab="overview">Overview</button>
  <button class="tab" data-tab="repos">Repos</button>
  <button class="tab" data-tab="content">Content</button>
  <button class="tab" data-tab="reports">Reports</button>
</nav>
```

**Step 2: Add the Reports button to mobile bottom nav**

Find the `<div class="bottom-nav-track">` element (around line 1284) and add:

```html
<button class="bnav-btn" data-tab="reports">Reports</button>
```

**Step 3: Add the Reports tab panel**

After the content tab panel closing `</div>` (before `<!-- Bottom nav (mobile) -->`), add:

```html
<!-- Reports -->
<div class="tab-panel" id="panel-reports">
  <div class="search-bar">
    <select class="search-input" id="reports-status" style="max-width: 200px">
      <option value="open">Open</option>
      <option value="resolved">Resolved</option>
      <option value="dismissed">Dismissed</option>
    </select>
    <select class="search-input" id="reports-label-filter" style="max-width: 200px">
      <option value="">All labels</option>
    </select>
  </div>
  <div id="reports-results"></div>
</div>
```

**Step 4: Add the tab activation case**

In the `activateTab` function (around line 1462), add after `if (tab === 'content') loadContent()`:

```javascript
if (tab === 'reports') loadReports()
```

**Step 5: Add the loadReports function and rendering**

Before the closing `</script>` tag, add:

```javascript
// ── Reports ──

const reportsPage = { limit: 50, offset: 0 }

function populateReportsLabelFilter() {
  const select = document.getElementById('reports-label-filter')
  select.innerHTML =
    '<option value="">All labels</option>' +
    labelDefinitions.map((d) => `<option value="${d.identifier}">${d.identifier}</option>`).join('')
}

document.getElementById('reports-status').addEventListener('change', () => {
  reportsPage.offset = 0
  loadReports()
})
document.getElementById('reports-label-filter').addEventListener('change', () => {
  reportsPage.offset = 0
  loadReports()
})

async function loadReports() {
  populateReportsLabelFilter()
  const status = document.getElementById('reports-status').value
  const label = document.getElementById('reports-label-filter').value
  const container = document.getElementById('reports-results')
  container.innerHTML = '<div class="loading">Loading</div>'
  try {
    let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}`
    if (label) url += `&label=${encodeURIComponent(label)}`
    const result = await api(url)
    renderReports(result.reports || [], result.total)
  } catch (e) {
    container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
  }
}

function renderReports(reports, total) {
  const container = document.getElementById('reports-results')
  if (!reports.length) {
    container.innerHTML = '<div class="empty-state">No reports found</div>'
    return
  }

  const showPagination = total != null && total > reportsPage.limit
  const paginationHtml = showPagination
    ? `
          <div class="pagination">
            <span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span>
            <div class="pagination-buttons">
              <button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button>
              <button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button>
            </div>
          </div>
        `
    : ''

  const countLabel =
    total != null
      ? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}`
      : `${reports.length} result${reports.length !== 1 ? 's' : ''}`

  const isOpen = document.getElementById('reports-status').value === 'open'

  container.innerHTML = `
          <div class="card">
            <div class="result-count">${countLabel}</div>
            ${reports
              .map((r) => {
                const reporterDisplay = r.reported_by_handle
                  ? `@${escapeHtml(r.reported_by_handle)}`
                  : escapeHtml(r.reported_by)
                const date = new Date(r.created_at).toLocaleString()
                return `<div class="record-card">
                <div class="record-header">
                  <div class="record-meta">
                    <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
                    <div class="record-summary">
                      <span class="label-tag">${escapeHtml(r.label)}</span>
                      reported by ${reporterDisplay} &middot; ${date}
                    </div>
                    ${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''}
                    ${!isOpen ? `<div class="record-summary" style="margin-top:0.25rem;opacity:0.6">${escapeHtml(r.status)} ${r.resolved_by ? `by ${escapeHtml(r.resolved_by)}` : ''}</div>` : ''}
                  </div>
                  ${
                    isOpen
                      ? `<div class="record-actions">
                    <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="resolve" style="background:var(--accent);color:white">Apply Label</button>
                    <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button>
                  </div>`
                      : ''
                  }
                </div>
              </div>`
              })
              .join('')}
            ${paginationHtml}
          </div>
        `

  container.querySelectorAll('[data-reports-page="prev"]').forEach((b) => {
    b.addEventListener('click', () => {
      reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit)
      loadReports()
    })
  })
  container.querySelectorAll('[data-reports-page="next"]').forEach((b) => {
    b.addEventListener('click', () => {
      reportsPage.offset += reportsPage.limit
      loadReports()
    })
  })

  container.querySelectorAll('[data-action="resolve-report"]').forEach((btn) => {
    btn.addEventListener('click', async () => {
      const action = btn.dataset.resolve
      try {
        await api('/admin/reports/resolve', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: parseInt(btn.dataset.id), action }),
        })
        toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success')
        loadReports()
      } catch (e) {
        toast(e.message, 'error')
      }
    })
  })
}
```

**Step 6: Add report badge to overview**

In the `loadOverview` function, after the existing status cards are rendered, add the open reports count. Find where `info` is used in `loadOverview` and add after the status cards rendering:

In the overview section, the open reports count will already be available from the `/admin/info` response as `info.openReports`. Add a card showing the count. Find the status cards rendering and append:

```javascript
if (info.openReports > 0) {
  statusCards.innerHTML += `<div class="stat-card" style="cursor:pointer" onclick="activateTab('reports')"><div class="stat-value">${info.openReports}</div><div class="stat-label">Open Reports</div></div>`
}
```

**Step 7: Verify build**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds

---

### Task 6: Final verification

**Step 1: Verify the full build**

Run: `cd packages/hatk && npx vite build`
Expected: Build succeeds with no errors

**Step 2: Commit**

```bash
git add packages/hatk/src/database/db.ts packages/hatk/src/server.ts packages/hatk/public/admin.html
git commit -m "feat: add user report system with admin review queue

Users can submit reports via dev.hatk.createReport XRPC endpoint,
selecting from the app's defined labels. Admins see a Reports tab
in the admin UI to review, apply labels, or dismiss reports."
```