Files
buzz/scripts/seed-admin-dashboard.sh
Will Pfleger 8f8c4dfade feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768)
Adds a relay admin console under Settings — the single surface for
relay-level operator work — replacing the previously unreachable
moderation section. Operators authenticate with their own Nostr key over
NIP-98 — no browser extension, no bearer token — to view deployment-wide
moderation reports and product feedback, resolve/dismiss/escalate/reopen
reports, update feedback status, and manage the operator/moderator
staffing roster.

## Nav reachability

The old `moderation` section id was defined in `settingsSections` but
never wired into any group in `settingsNavGroups`, so the sidebar never
rendered it — unreachable since #1617. This PR replaces it with a
`relay-admin` section wired into the Communities nav group, pointing at
the relay admin console, and drops the separate `admin-console` section
id so a single surface owns relay-level trust & safety.

The Relay admin nav entry is always visible. Auth gates the panel
itself; the nav entry is a door, not a credential — hiding it behind
discovery state would strand an operator whose relay transiently fails
NIP-11 discovery with no path back to the connection settings.

## Discovery and connection

On mount the console reads the connected relay's NIP-11 document for the
optional `admin_api` field. When a valid origin is discovered **and its
host matches the connected relay's host** (case-insensitive, host
identity only), it is automatically saved and probed — the panel renders
immediately with no Save step required. A cross-host advertisement is
pre-fill only: it populates the manual origin field under Advanced and
is never saved or probed without an explicit Save, so a relay cannot
direct an unconsented NIP-98 signature at a third-party origin; residual
exposure on the same-host path is bounded by NIP-98's URL/method/payload
binding. Saving is only required for manual origin changes. The
advertised origin is untrusted input: revalidated through
`AdminOrigin::parse`, falling back to manual entry when absent or
invalid. A saved manual origin always takes precedence over discovery.

## Auth and transport (Rust, `src-tauri/src/commands/admin/`)

- `AdminOrigin` value object: validates scheme + host + optional port,
rejects credentials/path/query/fragment; `http://` only for loopback.
The relay side normalizes its configured admin host at config load and
compares inbound `Host`/`Origin` hosts case-insensitively.
- `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so
the NIP-98-signed URL is byte-identical to the fetched URL.
- Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx`
is an error and the NIP-98 header never crosses origins.
- `admin_probe` signs `GET /probe` and returns a typed state
(`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` /
`networkOrIntercepted`). The response body is validated against the full
relay contract — `nip98Authorized` only when the complete NIP-98
invariant holds, `disabled` only on a coherent disabled-mode body; any
incoherent or unrelated JSON classifies as `notAdminApi`, so the console
never fails open at the network boundary. Unknown fields are tolerated
for forward compatibility.
- NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with
a fresh signed event; response size bounded by a `Content-Length`
preflight and a streaming byte counter; per-pubkey origin storage with
atomic `0o600` writes.

## Relay admin console

The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from
#3777; every `/api/admin/v1/*` route returns `403` to anyone else.
Community self-administration (`community-members` / Invites) and
in-channel enforcement (the 9040–9043 signed commands) are a separate
axis and are untouched. The previously-duplicate community report queue
(`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was
unreachable dead code and is deleted in its own commit;
`useBanMemberMutation` and the rest of `features/moderation/hooks.ts`
remain in use by the members sidebar and message menu.

## Console UI (TypeScript, `src/features/admin-console/`)

- `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for
every probe state, manual origin under an `Advanced` disclosure at the
bottom. The role (operator/moderator) is rendered in the connection
status line as plain text ("Connected as operator") — no badge-shaped
elements above the tab row. The origin provenance (relay config /
database) appears inside the Advanced card as small muted text. Section
title changed to `Admin`.
- `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing
gated on `role === "operator"`); reports and feedback grouped by
community for cross-community triage.
- **Reports**: always requests `scope=all` so the existing
resolve/cancel/reopen controls can reach every status (`open`,
`processing`, `resolved`, `dismissed`, `escalated`). The relay's
omitted-scope default is `escalated`-only (the platform-safety
backstop); `scope=all` is explicit and scoped to this console. Full
action matrix per `target_kind` (event →
delete/kick/ban/timeout/dismiss/escalate; pubkey →
ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is
suppressed when the report carries no `channelId`. A `processing` row
stays navigable — enforcement state lives in the detail view. A `failed`
action (always pre-mutation) offers a single **Cancel & reopen** via
`POST /reports/{id}/cancel` with `{actionId}` fencing; there is no
client-side retry. `pending`/`enforcing` actions belong to the relay
recovery worker and offer no button; a `409` reloads detail. Lists
refetch on back-navigation after a mutation.
- **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status
prefixes and surfaces the relay's own error reason in the toast (e.g.
relay returns `"400: you cannot report this content"` → toast shows
`"you cannot report this content"`). Self-report blocking UI removed —
the relay has no self-report gate, and the failure was purely
client-side error surfacing.
- **Reason audience disclosure**: the Reason (optional) field shows
exact copy under the input based on selected action — Delete discloses
that reason is sent verbatim to the affected user and posted publicly in
the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate
disclose reporter only. These mirror the actual relay notice paths.
- **Enforcement history**: the report detail carries the governing
action even after the report leaves `processing` — a report enforced
then reopened is `open` yet still shows its `succeeded` action as
executed history alongside the resolve form; a reopen does not un-happen
a ban. The resolve form gates on `open` status alone.
- **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can
be returned to `open` for re-triage via `POST /reports/{id}/reopen`.
Reopen never reverses applied enforcement, and the copy says so.
- **Feedback**: list, detail, and status control
(`new`/`reviewed`/`archived`) with a generation-fenced attachment
viewer. A purged source community severs provenance to `null` rather
than deleting the row; severed rows bucket under a "source community
removed" heading.
- **Staffing**: Add is create-only with display names resolved via
`useUsersBatchQuery` and npub cross-fade on hover
(`HoverStaffingIdentity`). An entered key that already appears in the
authoritative roster (config, owner-fallback, or DB) is rejected locally
with an inline duplicate-key message and no request is issued; a `409
Conflict` from add, role change, or remove surfaces the relay's parsed
error body via `adminErrorMessage(e)` — distinguishing config-backed key
conflicts from last-operator conflicts without HTTP-status inference.
Roster with `config`/`owner_fallback`/`db` source labels; config-backed
entries disable remove client-side. In-place role change via `<select>`
calling `putAdminOperator`; the relay responds with the effective
`OperatorEntry` (`pubkey`, `effectiveRole`, `sources`) in the same shape
as the roster list. Read-only badge for config-managed entries.
- **Timeout expiry**: `useTimeoutState` clears the store when derived
state is `INACTIVE` but the store is still active; the 1s tick is scoped
to entries with a known `expiresAtMs` and owned by
`ComposerTimeoutBanner` (mounted only while a timeout is active), while
`ChannelPane` subscribes to the boolean `useTimeoutActive()` — the pane
re-renders when a timeout starts or ends, not every second.
`useMembersSidebarModeration` ticks `nowMs` only while the sidebar is
open, the viewer can moderate, and some member has a future
`mutedUntil`; the tick after the last expiry stops the interval.
- Staffing refreshes the principal after a self-demotion or
self-removal: `AdminConsolePanel` passes `onSelfMutation` to
`StaffingTab`, which re-probes the saved origin so the role badge and
tab visibility update without a manual re-probe.
- Staffing mutation failures, including both 409 variants (config-backed
key vs. last operator), surface the relay's parsed error via
`adminErrorMessage`, so the last-operator recovery guidance is shown.
- Mutations confirm via `sonner` toasts; failures surface the relay's
parsed error `message`, not raw JSON. The `disabled` auth mode renders
read-only.

## Mutation idempotency

Resolve and reopen carry a per-attempt `requestId`. On first submit the
whole command (`{requestId, action, reason, expirationSecs}`) is frozen;
after an ambiguous failure (409, 5xx, transport error, truncated
response) the snapshot is retained, action/reason/duration controls are
locked, and the retry sends the exact same payload byte-for-byte. A
definitive pre-commit rejection (non-409 4xx with `bodyComplete: true`)
discards the snapshot, unlocks controls, and the corrected submit uses a
fresh payload. On success the toast derives from the authoritative
`AdminReportResolution` (`activeAction.action` when present; otherwise
`status` for decision-only outcomes) — never from the mutable form
selection.

The native mutation commands — including operator delete — reject with a
typed `AdminMutationError` carrying the relay's HTTP status
(`relayStatus`, `null` when no verdict exists) and a `bodyComplete` flag
that is `true` only when the full response body was read.

## Restriction routes contract change

`GET /members/restrictions` and `DELETE /members/{pubkey}/ban|timeout`
now take `communityHost` instead of `communityId`; the relay resolves
the tenant itself and returns `400 unknown_community_host` for an
unmapped host. This is a breaking contract change with no compatibility
shim: the relay must run this build before a desktop that uses it,
scripts sending `communityId` must switch, and rolling the relay back
breaks the desktop Restrictions UI. `docs/admin/README.md` documents the
three routes in the route inventory and carries the migration note.
Restriction calls also carry the relay the list was loaded from, and the
native side rejects a mismatch before building any URL.

## Hardening

- Saved admin origins are stored per pubkey and per relay host. The host
slug parses the relay URL, keeps the port-drop for DNS names, and
encodes IPv6 hosts canonically, so two IPv6 relays never share a file
and no IPv6 slug can match a DNS slug.
- NIP-11 admin discovery has its own 10s request deadline and reads
`/info` success and error bodies through the bounded reader with a 64
KiB cap (`Content-Length` is only a pre-check).
- `PUT /operators` builds its `OperatorEntry` response from the
validated body (`effectiveRole = role`, `sources = ["db"]`) instead of
re-reading the roster, so a concurrent change can't turn a committed
write into a `403`.

Related: #3777 (relay `OPERATOR`/`MODERATOR` role model + NIP-98 auth +
`admin_api` NIP-11 advertisement — provides the runtime and the
discovery field this console consumes)
Related:
[builderbot-platform-core-infrastructure#140](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/140)
(prod admin ingress allowlist and NIP-98 rollout)


## Screenshots

Captured headless at `197f0ba0c` against the mock bridge (1280×720).

### Setup

**Origin setup — Advanced disclosure open, idle state**
![Relay admin panel with Advanced: admin origin disclosure open before
probe](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/02-origin-setup.png)

**Authorized console — all three tabs**
![Authorized relay console showing Connected as operator status and
Reports/Feedback/Staffing
tabs](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/03-origin-authorized.png)

### Reports

**Reports queue — open, processing, resolved across two relay groups**
![Reports tab showing community-grouped list with spam/open,
harassment/processing, illegal/resolved
rows](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/04-reports-queue.png)

**Open report detail — reporter, target, message snapshot, resolve
form**
![Open spam report detail with reported message content and
Delete/Kick/Ban/Timeout/Dismiss/Escalate action
buttons](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/05-report-detail-open.png)

**Processing report — PROCESSING/HARASSMENT, ban enforcing state**
![Processing harassment report detail showing enforcement state with ban
action in enforcing
status](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/06-report-enforcement.png)

**Resolve form — Delete selected, audience disclosure copy**
![Resolve form with Delete action selected showing "Sent verbatim to the
affected user and posted publicly in the room" audience
copy](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/07-resolve-form.png)

**Resolved report — enforcement history block**
![Resolved/illegal report detail showing Resolved by, Resolved at, and
Act ID fields from completed
enforcement](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/08-resolved-history.png)

### Feedback

**Feedback list — community grouping with status badges**
![Feedback tab showing relay.example.com group with new/reviewed items
and source-community-removed
group](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/09-feedback-list.png)

**Feedback detail — mutable status control (operator mode)**
![Feedback detail with new/reviewed/archived status tab control visible
for
mutation](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/10-feedback-mutable.png)

**Feedback detail — passive status badge (read-only/disabled mode)**
![Feedback detail in disabled mode showing passive "Status NEW" badge
with no mutation
control](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/11-feedback-readonly.png)

### Staffing

**Staffing roster — config/owner\_fallback/db source labels**
![Staffing tab showing three operators with config, owner_fallback, and
db source
labels](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/12-staffing-roster.png)

**Add form — new pubkey filled, ready to submit**
![Staffing add form with a new 64-hex pubkey entered and moderator role
selected](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/13-staffing-add.png)

**Duplicate rejection — inline error for existing principal**
![Staffing add form showing "Already an operator: operator. Use Remove
to revoke before re-adding with a different role." error for an existing
config-backed
operator](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/14-staffing-dup.png)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-09-24 17:34:25 -04:00

211 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Seed deterministic moderation reports and product feedback for local dashboard review.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
if [[ -f ".env" ]]; then
set -o allexport
# shellcheck disable=SC1091
source .env
set +o allexport
fi
export PGHOST="${PGHOST:-localhost}"
export PGPORT="${PGPORT:-5432}"
export PGUSER="${PGUSER:-buzz}"
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
export PGDATABASE="${PGDATABASE:-buzz}"
if command -v psql >/dev/null 2>&1; then
run_psql() {
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" \
-U "${PGUSER}" -d "${PGDATABASE}" "$@"
}
elif docker exec buzz-postgres psql --version >/dev/null 2>&1; then
run_psql() {
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
psql -U "${PGUSER}" -d "${PGDATABASE}" "$@"
}
else
echo "error: neither psql nor buzz-postgres docker psql is available" >&2
exit 1
fi
community_id="$(run_psql -At -v ON_ERROR_STOP=1 -c "
SELECT id
FROM communities
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
ORDER BY CASE lower(host)
WHEN 'localhost:3000' THEN 1
WHEN 'localhost' THEN 2
WHEN '127.0.0.1:3000' THEN 3
ELSE 4
END
LIMIT 1
")"
if [[ -z "${community_id}" ]]; then
echo "error: local community is missing; run just setup first" >&2
exit 1
fi
fixture_hash() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
fixture_size() {
wc -c < "$1" | awk '{print $1}'
}
upload_fixture() {
local path="$1" hash="$2" extension="$3" mime="$4" dimensions="$5"
local size sidecar
size="$(fixture_size "${path}")"
sidecar="$(printf '{"dim":"%s","blurhash":"","thumb_url":"","ext":"%s","mime_type":"%s","size":%s,"uploaded_at":0}' \
"${dimensions}" "${extension}" "${mime}" "${size}")"
docker exec -i buzz-minio mc pipe --quiet --attr "Content-Type=${mime}" \
"local/${BUZZ_S3_BUCKET:-buzz-media}/${hash}.${extension}" < "${path}"
printf '%s' "${sidecar}" | docker exec -i buzz-minio mc pipe --quiet \
--attr "Content-Type=application/json" \
"local/${BUZZ_S3_BUCKET:-buzz-media}/_meta/${community_id}/${hash}.json"
}
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-admin-feedback.XXXXXX")"
search_image="${REPO_ROOT}/docs/assets/screenshots/media-comments.png"
workspace_image="${REPO_ROOT}/docs/assets/screenshots/channel-thread.png"
quality_image="${REPO_ROOT}/docs/assets/screenshots/channel-agents.png"
composer_diagnostics="${fixture_dir}/composer-diagnostics.txt"
workspace_diagnostics="${fixture_dir}/workspace-diagnostics.txt"
trap 'rm -f "${composer_diagnostics}" "${workspace_diagnostics}"; rmdir "${fixture_dir}"' EXIT
printf '%s\n' "buzz feedback diagnostics" "area: composer" \
"event: resumed_from_sleep" "result: composer_unresponsive" > "${composer_diagnostics}"
printf '%s\n' "buzz feedback diagnostics" "area: workspace-switching" \
"from: design" "to: engineering" \
"result: previous_sidebar_visible_for_one_frame" > "${workspace_diagnostics}"
search_image_hash="$(fixture_hash "${search_image}")"
workspace_image_hash="$(fixture_hash "${workspace_image}")"
quality_image_hash="$(fixture_hash "${quality_image}")"
composer_diagnostics_hash="$(fixture_hash "${composer_diagnostics}")"
workspace_diagnostics_hash="$(fixture_hash "${workspace_diagnostics}")"
if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
"${BUZZ_S3_ACCESS_KEY:-buzz_dev}" "${BUZZ_S3_SECRET_KEY:-buzz_dev_secret}" >/dev/null; then
echo "error: local MinIO is unavailable; run just setup first" >&2
exit 1
fi
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
read -r -d '' sql <<'SQL' || true
DO $$
DECLARE
local_community_id UUID;
BEGIN
SELECT id INTO local_community_id
FROM communities
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
ORDER BY CASE lower(host)
WHEN 'localhost:3000' THEN 1
WHEN 'localhost' THEN 2
WHEN '127.0.0.1:3000' THEN 3
ELSE 4
END
LIMIT 1;
IF local_community_id IS NULL THEN
RAISE EXCEPTION 'local community is missing; run just setup first';
END IF;
-- A real channel for the failed-enforcement report below. Kick is only valid
-- on `event` reports, and the relay rejects it pre-mutation unless the report
-- carries a channel_id (FK into channels). Seeding this channel makes the
-- Kick action reachable in the UI and lets the enforcement genuinely run and
-- fail, so the Cancel & reopen recovery path is exercisable locally.
INSERT INTO channels (community_id, id, name, created_by)
VALUES (
local_community_id,
'c4a11e10-0000-4000-8000-000000000001',
'seed-enforcement-channel',
decode(repeat('3b', 32), 'hex')
)
ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO moderation_reports (
community_id, id, report_event_id, reporter_pubkey, target_kind,
target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note,
status, resolved_by, resolved_at, created_at
) VALUES
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), NULL, 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), NULL, 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), NULL, 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000011', decode(repeat('0b', 32), 'hex'), decode(repeat('1b', 32), 'hex'), 'event', decode(repeat('2b', 32), 'hex'), NULL, NULL, 'c4a11e10-0000-4000-8000-000000000001', 'spam', 'Event report in a real channel — Kick is offered and the enforcement genuinely runs and fails, exercising the Cancel & reopen recovery path.', 'open', NULL, NULL, now() - interval '3 minutes')
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
reporter_pubkey = EXCLUDED.reporter_pubkey,
target_kind = EXCLUDED.target_kind,
target_event_id = EXCLUDED.target_event_id,
target_pubkey = EXCLUDED.target_pubkey,
target_blob_sha256 = EXCLUDED.target_blob_sha256,
channel_id = EXCLUDED.channel_id,
report_type = EXCLUDED.report_type,
note = EXCLUDED.note,
status = EXCLUDED.status,
resolved_by = EXCLUDED.resolved_by,
resolved_at = EXCLUDED.resolved_at,
created_at = EXCLUDED.created_at;
INSERT INTO product_feedback (
id, community_id, event_id, submitter_pubkey, category, body, tags,
event_created_at, received_at
) VALUES
('feed0000-0000-4000-8000-000000000001', local_community_id, decode(repeat('41', 32), 'hex'), decode(repeat('51', 32), 'hex'), 'bug', 'Unread counts return after reopening the desktop app.', '[["category", "bug"]]', now() - interval '20 minutes', now() - interval '19 minutes'),
('feed0000-0000-4000-8000-000000000002', local_community_id, decode(repeat('42', 32), 'hex'), decode(repeat('52', 32), 'hex'), 'needs-work', E'Search needs clearer empty-state guidance.\n![image](http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png", "m image/png", "x __SEARCH_IMAGE_HASH__", "size __SEARCH_IMAGE_SIZE__", "dim 2000x1172", "filename search-empty-state.png"]]', now() - interval '5 hours', now() - interval '5 hours'),
('feed0000-0000-4000-8000-000000000003', local_community_id, decode(repeat('43', 32), 'hex'), decode(repeat('53', 32), 'hex'), 'praise', 'The new channel switcher feels immediate.', '[["category", "praise"]]', now() - interval '1 day', now() - interval '1 day'),
('feed0000-0000-4000-8000-000000000004', local_community_id, decode(repeat('44', 32), 'hex'), decode(repeat('54', 32), 'hex'), 'bug', E'The composer froze after waking my laptop. Diagnostics attached.\n[feedback-diagnostics.txt](http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt)', '[["category", "bug"], ["imeta", "url http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __COMPOSER_DIAGNOSTICS_HASH__", "size __COMPOSER_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '2 days', now() - interval '2 days'),
('feed0000-0000-4000-8000-000000000005', local_community_id, decode(repeat('45', 32), 'hex'), decode(repeat('55', 32), 'hex'), NULL, 'General feedback without a selected category or any attachments.', '[]', now() - interval '3 days', now() - interval '3 days'),
('feed0000-0000-4000-8000-000000000006', local_community_id, decode(repeat('46', 32), 'hex'), decode(repeat('56', 32), 'hex'), 'needs-work', E'The sidebar briefly renders the previous workspace after switching. Screenshot and diagnostics attached.\n![image](http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png)\n[feedback-diagnostics.txt](http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png", "m image/png", "x __WORKSPACE_IMAGE_HASH__", "size __WORKSPACE_IMAGE_SIZE__", "dim 2000x1172", "filename workspace-flash.png"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __WORKSPACE_DIAGNOSTICS_HASH__", "size __WORKSPACE_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '5 days', now() - interval '5 days'),
('feed0000-0000-4000-8000-000000000007', local_community_id, decode(repeat('47', 32), 'hex'), decode(repeat('57', 32), 'hex'), 'praise', E'Calls have been much more reliable this week. Attaching the quality graph that made the improvement obvious.\n![image](http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png)', '[["category", "praise"], ["imeta", "url http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png", "m image/png", "x __QUALITY_IMAGE_HASH__", "size __QUALITY_IMAGE_SIZE__", "dim 2000x1172", "filename huddle-quality.png"]]', now() - interval '8 days', now() - interval '8 days')
ON CONFLICT (event_id) DO UPDATE SET
community_id = EXCLUDED.community_id,
submitter_pubkey = EXCLUDED.submitter_pubkey,
category = EXCLUDED.category,
body = EXCLUDED.body,
tags = EXCLUDED.tags,
event_created_at = EXCLUDED.event_created_at,
received_at = EXCLUDED.received_at;
END $$;
SQL
sql="${sql//__SEARCH_IMAGE_HASH__/${search_image_hash}}"
sql="${sql//__WORKSPACE_IMAGE_HASH__/${workspace_image_hash}}"
sql="${sql//__QUALITY_IMAGE_HASH__/${quality_image_hash}}"
sql="${sql//__COMPOSER_DIAGNOSTICS_HASH__/${composer_diagnostics_hash}}"
sql="${sql//__WORKSPACE_DIAGNOSTICS_HASH__/${workspace_diagnostics_hash}}"
sql="${sql//__SEARCH_IMAGE_SIZE__/$(fixture_size "${search_image}")}"
sql="${sql//__WORKSPACE_IMAGE_SIZE__/$(fixture_size "${workspace_image}")}"
sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
run_psql -v ON_ERROR_STOP=1 -c "${sql}"
echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."