~ 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:

// 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:

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):

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:

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':

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:

// 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:

// 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:

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

to:

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:

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

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

<!-- 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():

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

Step 5: Add the loadReports function and rendering

Before the closing </script> tag, add:

// ── 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:

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

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."