mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add read-only deployment moderation dashboard (#1999)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
0e03836018
commit
68e670e001
@@ -1,6 +1,7 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
/dist/
|
||||
/admin-web/dist/
|
||||
|
||||
# lefthook-generated hook scripts (machine-specific)
|
||||
.hooks/
|
||||
|
||||
+9
-4
@@ -105,9 +105,11 @@ RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY patches/ patches/
|
||||
COPY web/package.json web/
|
||||
RUN pnpm install --frozen-lockfile --filter buzz-web
|
||||
COPY admin-web/package.json admin-web/
|
||||
RUN pnpm install --frozen-lockfile --filter buzz-web --filter buzz-admin-web
|
||||
COPY web/ web/
|
||||
RUN pnpm -C web build
|
||||
COPY admin-web/ admin-web/
|
||||
RUN pnpm -C web build && pnpm -C admin-web build
|
||||
|
||||
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
@@ -137,10 +139,13 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-rela
|
||||
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
|
||||
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
||||
COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web
|
||||
|
||||
# The invite landing page is always served from the bundled web UI. Repository
|
||||
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
|
||||
ENV BUZZ_WEB_DIR=/srv/buzz/web
|
||||
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. The
|
||||
# admin bundle is inert until BUZZ_ADMIN_HOST is configured.
|
||||
ENV BUZZ_WEB_DIR=/srv/buzz/web \
|
||||
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||||
|
||||
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
|
||||
EXPOSE 3000 8080 9102
|
||||
|
||||
@@ -363,6 +363,30 @@ relay-web: bootstrap _ensure-migrations
|
||||
pnpm -C web build
|
||||
BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay
|
||||
|
||||
# Build and run the private read-only admin dashboard
|
||||
admin: bootstrap _ensure-migrations
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
pnpm -C admin-web build
|
||||
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
|
||||
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
|
||||
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
|
||||
cargo run -p buzz-relay
|
||||
|
||||
# Seed deterministic reports and product feedback for local admin dashboard review
|
||||
admin-seed: _ensure-migrations
|
||||
./scripts/seed-admin-dashboard.sh
|
||||
|
||||
# Run focused relay and browser checks for the read-only admin dashboard
|
||||
admin-check: fmt-check
|
||||
cargo check -p buzz-relay --all-targets
|
||||
cargo test -p buzz-relay api::admin
|
||||
cargo test -p buzz-relay router::tests
|
||||
pnpm -C admin-web check
|
||||
pnpm -C admin-web exec playwright test
|
||||
|
||||
# Start the relay server in release mode
|
||||
relay-release: _ensure-migrations
|
||||
cargo run -p buzz-relay --release
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Buzz admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "buzz-admin-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm typecheck && pnpm test",
|
||||
"format": "biome format --write .",
|
||||
"test": "vitest run src --passWithNoTests",
|
||||
"test:e2e": "pnpm build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"jsdom": "^27.3.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "pnpm exec vite preview --host 127.0.0.1 --port 4174",
|
||||
url: "http://127.0.0.1:4174",
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
use: { baseURL: "http://127.0.0.1:4174" },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466 309">
|
||||
<style>
|
||||
.mark { fill: #231e1e; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mark { fill: #d7d72e; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<mask id="bee-mask">
|
||||
<circle cx="91.7" cy="154.5" r="91.7" fill="white"/>
|
||||
<circle cx="374.3" cy="154.5" r="91.7" fill="white"/>
|
||||
<rect x="128" y="0" width="210" height="309" rx="34" fill="white"/>
|
||||
<circle cx="193.3" cy="84.4" r="27" fill="black"/>
|
||||
<circle cx="276" cy="84.4" r="27" fill="black"/>
|
||||
<rect x="166.3" y="157.2" width="136.9" height="38.3" rx="5" fill="black"/>
|
||||
<rect x="166.9" y="235.1" width="136.2" height="37.6" rx="5" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect class="mark" width="466" height="309" mask="url(#bee-mask)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,798 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ApiFailure, request } from "./api";
|
||||
import type { FeedbackDetail, FeedbackSummary, Report } from "./types";
|
||||
import { useResource } from "./useResource";
|
||||
|
||||
function usePath() {
|
||||
const [path, setPath] = useState(location.pathname);
|
||||
useEffect(() => {
|
||||
const update = () => setPath(location.pathname);
|
||||
addEventListener("popstate", update);
|
||||
return () => removeEventListener("popstate", update);
|
||||
}, []);
|
||||
const navigate = useCallback((url: string) => {
|
||||
history.pushState(null, "", url);
|
||||
dispatchEvent(new PopStateEvent("popstate"));
|
||||
}, []);
|
||||
return { path, navigate };
|
||||
}
|
||||
|
||||
function Link({
|
||||
href,
|
||||
className,
|
||||
activeWhenNested = false,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className?: string;
|
||||
activeWhenNested?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { path, navigate } = usePath();
|
||||
const active =
|
||||
path === href || (activeWhenNested && path.startsWith(`${href}/`));
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
if (!event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StateView<T>({
|
||||
resource,
|
||||
children,
|
||||
}: {
|
||||
resource: ReturnType<typeof useResource<T>>;
|
||||
children: (data: T) => ReactNode;
|
||||
}) {
|
||||
if (resource.loading && !resource.data)
|
||||
return <div className="state">Loading…</div>;
|
||||
if (resource.error && !resource.data) {
|
||||
const forbidden =
|
||||
resource.error instanceof ApiFailure && resource.error.status === 403;
|
||||
return (
|
||||
<div className="state error" role="alert">
|
||||
<h2>{forbidden ? "Access denied" : "Could not load data"}</h2>
|
||||
<p>{resource.error.message}</p>
|
||||
<button type="button" onClick={resource.refetch}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return resource.data ? children(resource.data) : null;
|
||||
}
|
||||
|
||||
function Reports() {
|
||||
const resource = useResource(
|
||||
() => request<Report[]>("/reports?status=open&limit=100"),
|
||||
"reports",
|
||||
);
|
||||
return (
|
||||
<Page
|
||||
eyebrow="Moderation"
|
||||
title="Open reports"
|
||||
description="Review reports across every Buzz community."
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(reports) =>
|
||||
reports.length ? (
|
||||
<div className="cards">
|
||||
{reports.map((report) => (
|
||||
<Link
|
||||
key={report.id}
|
||||
href={`/reports/${report.id}`}
|
||||
className="card-link"
|
||||
>
|
||||
<article className="record-card">
|
||||
<span className="record-icon report-icon">
|
||||
<ReportIcon />
|
||||
</span>
|
||||
<div className="record-primary">
|
||||
<span className="tag">{report.reportType}</span>
|
||||
<strong>{report.communityHost}</strong>
|
||||
<code>
|
||||
{report.targetKind}: {short(report.target)}
|
||||
</code>
|
||||
</div>
|
||||
<div className="record-date">
|
||||
<span>Submitted</span>
|
||||
<time>{date(report.createdAt)}</time>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty />
|
||||
)
|
||||
}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetail({ id }: { id: string }) {
|
||||
const resource = useResource(() => request<Report>(`/reports/${id}`), id);
|
||||
return (
|
||||
<Page
|
||||
eyebrow="Moderation"
|
||||
title="Report detail"
|
||||
description="The full report as submitted to the relay."
|
||||
back="/reports"
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(report) => (
|
||||
<article className="detail">
|
||||
<div className="detail-heading">
|
||||
<span className="record-icon report-icon">
|
||||
<ReportIcon />
|
||||
</span>
|
||||
<div>
|
||||
<span className="tag">{report.reportType}</span>
|
||||
<h2>{report.communityHost}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<span className="status">{report.status}</span>
|
||||
</dd>
|
||||
<dt>Reporter</dt>
|
||||
<dd>
|
||||
<code>{report.reporterPubkey}</code>
|
||||
</dd>
|
||||
<dt>Target</dt>
|
||||
<dd>
|
||||
<code>{report.target}</code>
|
||||
</dd>
|
||||
<dt>Note</dt>
|
||||
<dd className="sensitive">
|
||||
{report.note ?? "No note provided."}
|
||||
</dd>
|
||||
</dl>
|
||||
</article>
|
||||
)}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackList() {
|
||||
const resource = useResource(
|
||||
() => request<FeedbackSummary[]>("/feedback"),
|
||||
"feedback",
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [community, setCommunity] = useState("all");
|
||||
const [timeRange, setTimeRange] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [statuses, setStatuses] = useState(loadFeedbackStatuses);
|
||||
|
||||
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked;
|
||||
setStatuses((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
[id]: checked,
|
||||
};
|
||||
saveFeedbackStatuses(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
eyebrow="Product"
|
||||
title="Feedback"
|
||||
description="Recent product feedback from across Buzz."
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(items) => {
|
||||
if (!items.length) return <Empty />;
|
||||
return (
|
||||
<FeedbackResults
|
||||
items={items}
|
||||
query={query}
|
||||
community={community}
|
||||
timeRange={timeRange}
|
||||
statusFilter={statusFilter}
|
||||
statuses={statuses}
|
||||
>
|
||||
{({ communities, filtered }) => (
|
||||
<>
|
||||
<div className="feedback-filters">
|
||||
<label className="search-field">
|
||||
<span>Search feedback</span>
|
||||
<div>
|
||||
<SearchIcon />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search feedback"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Community</span>
|
||||
<select
|
||||
value={community}
|
||||
onChange={(event) => setCommunity(event.target.value)}
|
||||
>
|
||||
<option value="all">All communities</option>
|
||||
{communities.map((host) => (
|
||||
<option key={host} value={host}>
|
||||
{host}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Received</span>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(event) => setTimeRange(event.target.value)}
|
||||
>
|
||||
<option value="all">Any time</option>
|
||||
<option value="day">Last 24 hours</option>
|
||||
<option value="week">Last 7 days</option>
|
||||
<option value="month">Last 30 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) =>
|
||||
setStatusFilter(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="all">Any status</option>
|
||||
<option value="pending">Needs action</option>
|
||||
<option value="acted-on">Acted on</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="result-count" aria-live="polite">
|
||||
{filtered.length} of {items.length} submissions
|
||||
</p>
|
||||
{filtered.length ? (
|
||||
<div className="cards">
|
||||
{filtered.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="record-card feedback-card feedback-record"
|
||||
>
|
||||
<Link
|
||||
href={`/feedback/${item.id}`}
|
||||
className="feedback-main-link"
|
||||
>
|
||||
<span className="record-icon feedback-icon">
|
||||
<CategoryIcon category={item.category} />
|
||||
</span>
|
||||
<div className="record-primary">
|
||||
<CategoryTag category={item.category} />
|
||||
<strong>{item.bodySummary}</strong>
|
||||
<span className="record-provenance">
|
||||
{item.communityHost}
|
||||
<code>{short(item.submitterPubkey)}</code>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<label className="feedback-status">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statuses[item.id] ?? false}
|
||||
onChange={(event) => updateStatus(item.id, event)}
|
||||
/>
|
||||
Acted on
|
||||
<span className="visually-hidden">
|
||||
feedback from {item.communityHost}
|
||||
</span>
|
||||
</label>
|
||||
<div className="record-date">
|
||||
<span>Received</span>
|
||||
<time>{date(item.receivedAt)}</time>
|
||||
</div>
|
||||
<Link
|
||||
href={`/feedback/${item.id}`}
|
||||
className="record-open-link"
|
||||
>
|
||||
<span className="visually-hidden">
|
||||
Open feedback from {item.communityHost}
|
||||
</span>
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="state">No matching feedback.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FeedbackResults>
|
||||
);
|
||||
}}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackResults({
|
||||
items,
|
||||
query,
|
||||
community,
|
||||
timeRange,
|
||||
statusFilter,
|
||||
statuses,
|
||||
children,
|
||||
}: {
|
||||
items: FeedbackSummary[];
|
||||
query: string;
|
||||
community: string;
|
||||
timeRange: string;
|
||||
statusFilter: string;
|
||||
statuses: FeedbackStatuses;
|
||||
children: (results: {
|
||||
communities: string[];
|
||||
filtered: FeedbackSummary[];
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const results = useMemo(() => {
|
||||
const communities = [...new Set(items.map((item) => item.communityHost))]
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const after = timeRangeStart(timeRange);
|
||||
const filtered = items.filter((item) => {
|
||||
if (community !== "all" && item.communityHost !== community) return false;
|
||||
if (statusFilter === "pending" && statuses[item.id]) return false;
|
||||
if (statusFilter === "acted-on" && !statuses[item.id]) return false;
|
||||
if (after !== undefined) {
|
||||
const receivedAt = new Date(item.receivedAt).valueOf();
|
||||
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
|
||||
}
|
||||
if (!normalizedQuery) return true;
|
||||
return [
|
||||
item.bodySummary,
|
||||
item.communityHost,
|
||||
item.category ?? "uncategorized",
|
||||
item.submitterPubkey,
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
|
||||
});
|
||||
return { communities, filtered };
|
||||
}, [items, query, community, timeRange, statusFilter, statuses]);
|
||||
return children(results);
|
||||
}
|
||||
|
||||
function FeedbackDetailView({ id }: { id: string }) {
|
||||
const resource = useResource(
|
||||
() => request<FeedbackDetail>(`/feedback/${id}`),
|
||||
id,
|
||||
);
|
||||
return (
|
||||
<Page
|
||||
eyebrow="Product"
|
||||
title="Feedback detail"
|
||||
description="The complete feedback submission and its source."
|
||||
back="/feedback"
|
||||
backLabel="Back to feedback"
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(feedback) => {
|
||||
const attachments = feedbackAttachments(
|
||||
feedback.tags,
|
||||
feedback.communityHost,
|
||||
);
|
||||
const body = stripAttachmentMarkdown(feedback.body, attachments);
|
||||
return (
|
||||
<article className="detail">
|
||||
<div className="detail-heading">
|
||||
<span className="record-icon feedback-icon">
|
||||
<CategoryIcon category={feedback.category} />
|
||||
</span>
|
||||
<div>
|
||||
<CategoryTag category={feedback.category} />
|
||||
<h2>{feedback.communityHost}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>Feedback</dt>
|
||||
<dd className="sensitive feedback-body">{body}</dd>
|
||||
{attachments.length ? (
|
||||
<>
|
||||
<dt>Attachments</dt>
|
||||
<dd className="attachments">
|
||||
{attachments.map((attachment) => (
|
||||
<Attachment
|
||||
key={`${attachment.hash}-${attachment.url}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>Submitted by</dt>
|
||||
<dd>
|
||||
<code>{feedback.submitterPubkey}</code>
|
||||
</dd>
|
||||
<dt>Event</dt>
|
||||
<dd>
|
||||
<code>{feedback.eventId}</code>
|
||||
</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{date(feedback.eventCreatedAt)}</dd>
|
||||
<dt>Received</dt>
|
||||
<dd>{date(feedback.receivedAt)}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
type FeedbackStatuses = Record<string, boolean>;
|
||||
|
||||
interface FeedbackAttachment {
|
||||
url: string;
|
||||
mimeType: string;
|
||||
hash: string;
|
||||
size?: number;
|
||||
dimensions?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status";
|
||||
|
||||
function loadFeedbackStatuses(): FeedbackStatuses {
|
||||
try {
|
||||
const stored = localStorage.getItem(FEEDBACK_STATUS_KEY);
|
||||
return stored ? (JSON.parse(stored) as FeedbackStatuses) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveFeedbackStatuses(statuses: FeedbackStatuses) {
|
||||
try {
|
||||
localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses));
|
||||
} catch {
|
||||
// The controls remain useful for the current session if storage is blocked.
|
||||
}
|
||||
}
|
||||
|
||||
function timeRangeStart(range: string) {
|
||||
const durations: Record<string, number> = {
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const duration = durations[range];
|
||||
return duration ? Date.now() - duration : undefined;
|
||||
}
|
||||
|
||||
function feedbackAttachments(
|
||||
tags: string[][],
|
||||
communityHost: string,
|
||||
): FeedbackAttachment[] {
|
||||
return tags.flatMap((tag) => {
|
||||
if (tag[0] !== "imeta") return [];
|
||||
const values = new Map<string, string>();
|
||||
for (const entry of tag.slice(1)) {
|
||||
const separator = entry.indexOf(" ");
|
||||
if (separator > 0) {
|
||||
values.set(entry.slice(0, separator), entry.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
const url = values.get("url");
|
||||
const mimeType = values.get("m");
|
||||
const hash = values.get("x");
|
||||
const safeUrl = url && safeAttachmentUrl(url, communityHost);
|
||||
if (!safeUrl || !mimeType || !hash) return [];
|
||||
const parsedSize = Number(values.get("size"));
|
||||
return [
|
||||
{
|
||||
url: safeUrl,
|
||||
mimeType,
|
||||
hash,
|
||||
size:
|
||||
Number.isFinite(parsedSize) && parsedSize > 0
|
||||
? parsedSize
|
||||
: undefined,
|
||||
dimensions: values.get("dim"),
|
||||
filename: values.get("filename"),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function safeAttachmentUrl(value: string, communityHost: string) {
|
||||
try {
|
||||
const url = new URL(value, `${location.protocol}//${communityHost}`);
|
||||
return ["http:", "https:"].includes(url.protocol) &&
|
||||
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
|
||||
url.pathname.startsWith("/media/")
|
||||
? url.href
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stripAttachmentMarkdown(
|
||||
body: string,
|
||||
attachments?: FeedbackAttachment[],
|
||||
) {
|
||||
const knownUrls = attachments
|
||||
? new Set(attachments.map((attachment) => attachment.url))
|
||||
: undefined;
|
||||
return body
|
||||
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
|
||||
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
|
||||
return isMedia ? "" : match;
|
||||
})
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
|
||||
const url = attachment.url;
|
||||
const name =
|
||||
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
|
||||
const metadata = [
|
||||
attachment.mimeType,
|
||||
attachment.dimensions,
|
||||
attachment.size ? formatBytes(attachment.size) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
if (attachment.mimeType.startsWith("image/")) {
|
||||
return (
|
||||
<figure className="image-attachment">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt={name} loading="lazy" />
|
||||
</a>
|
||||
<figcaption>
|
||||
<span>{name}</span>
|
||||
<small>{metadata}</small>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className="file-attachment"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={name}
|
||||
>
|
||||
<FileIcon />
|
||||
<span>
|
||||
<strong>{name}</strong>
|
||||
<small>{metadata}</small>
|
||||
</span>
|
||||
<ArrowIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function Page({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
back,
|
||||
backLabel,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
back?: string;
|
||||
backLabel?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<header className="page-title">
|
||||
{back ? (
|
||||
<Link href={back} className="back-link">
|
||||
<ArrowIcon /> {backLabel ?? "Back to reports"}
|
||||
</Link>
|
||||
) : null}
|
||||
<p>{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<span>{description}</span>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return <div className="state">No records.</div>;
|
||||
}
|
||||
|
||||
function short(value: string) {
|
||||
return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
function date(value: string) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf())
|
||||
? "Unknown date"
|
||||
: parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function BuzzMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 466 309" aria-hidden="true">
|
||||
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
|
||||
<path d="M12 7.5v5M12 16.5h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
|
||||
<path d="M8.5 9h7M8.5 12h4.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTag({ category }: { category?: string }) {
|
||||
const labels: Record<string, string> = {
|
||||
bug: "Bug",
|
||||
praise: "Praise",
|
||||
"needs-work": "Needs work",
|
||||
};
|
||||
return (
|
||||
<span className="tag">
|
||||
<CategoryIcon category={category} />
|
||||
{category ? (labels[category] ?? category) : "Uncategorized"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryIcon({ category }: { category?: string }) {
|
||||
if (category === "bug") return <BugIcon />;
|
||||
if (category === "praise") return <ThumbsUpIcon />;
|
||||
if (category === "needs-work") return <WrenchIcon />;
|
||||
return <FeedbackIcon />;
|
||||
}
|
||||
|
||||
function BugIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbsUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
<path d="M7 10v12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WrenchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 3h8l4 4v14H6V3Z" />
|
||||
<path d="M14 3v5h4M9 13h6M9 17h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { path } = usePath();
|
||||
const report = path.match(/^\/reports\/([^/]+)$/);
|
||||
const feedback = path.match(/^\/feedback\/([^/]+)$/);
|
||||
const content = report ? (
|
||||
<ReportDetail id={report[1]} />
|
||||
) : feedback ? (
|
||||
<FeedbackDetailView id={feedback[1]} />
|
||||
) : path === "/feedback" ? (
|
||||
<FeedbackList />
|
||||
) : (
|
||||
<Reports />
|
||||
);
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<Link href="/reports" className="brand">
|
||||
<span className="brand-mark">
|
||||
<BuzzMark />
|
||||
</span>
|
||||
<span>
|
||||
Buzz <b>Admin</b>
|
||||
</span>
|
||||
</Link>
|
||||
<nav>
|
||||
<Link href="/reports" className="nav-link" activeWhenNested>
|
||||
<ReportIcon /> Reports
|
||||
</Link>
|
||||
<Link href="/feedback" className="nav-link" activeWhenNested>
|
||||
<FeedbackIcon /> Feedback
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main>{content}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const PREFIX = "/api/admin/v1";
|
||||
|
||||
export class ApiFailure extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${PREFIX}${path}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const envelope = await response.json().catch(() => null);
|
||||
throw new ApiFailure(
|
||||
response.status,
|
||||
envelope?.error?.message ?? `Request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("root element missing");
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,702 @@
|
||||
:root {
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
|
||||
color: #231e1e;
|
||||
background: #d7e7f6;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
background: #231e1e;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
width: min(1120px, calc(100% - 3rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.brand b {
|
||||
font-weight: 400;
|
||||
color: rgb(35 30 30 / 60%);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.7rem;
|
||||
color: #d7d72e;
|
||||
background: #231e1e;
|
||||
}
|
||||
|
||||
.brand-mark svg {
|
||||
width: 1.7rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 42%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
color: rgb(35 30 30 / 60%);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: -0.035em;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.nav-link svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #231e1e;
|
||||
}
|
||||
|
||||
.nav-link[aria-current="page"] {
|
||||
color: #231e1e;
|
||||
background: white;
|
||||
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1120px, calc(100% - 3rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 7rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
max-width: 52rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.page-title > p {
|
||||
margin: 0 0 1rem;
|
||||
color: rgb(35 30 30 / 55%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(3rem, 7vw, 5.5rem);
|
||||
font-weight: 400;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.page-title > span {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 2.25rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.feedback-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(255 255 255 / 45%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.feedback-filters label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.feedback-filters label > span {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.feedback-filters input,
|
||||
.feedback-filters select {
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid rgb(35 30 30 / 12%);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
color: #231e1e;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.feedback-filters input:focus,
|
||||
.feedback-filters select:focus {
|
||||
border-color: #231e1e;
|
||||
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
|
||||
}
|
||||
|
||||
.feedback-filters select {
|
||||
padding: 0 2.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.search-field > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
width: 100%;
|
||||
padding: 0 1rem 0 2.6rem;
|
||||
}
|
||||
|
||||
.search-field svg {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transform: translateY(-50%);
|
||||
fill: none;
|
||||
stroke: rgb(35 30 30 / 45%);
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
margin: 0 0 0.75rem;
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
border-radius: 1.6rem;
|
||||
}
|
||||
|
||||
.record-card,
|
||||
.detail,
|
||||
.state {
|
||||
background: white;
|
||||
border-radius: 1.6rem;
|
||||
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
min-height: 7.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
padding: 1.2rem 1.5rem;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.feedback-record {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.feedback-main-link {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.record-open-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
min-width: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feedback-status input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
accent-color: #231e1e;
|
||||
}
|
||||
|
||||
.record-open-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 999px;
|
||||
color: rgb(35 30 30 / 30%);
|
||||
}
|
||||
|
||||
.card-link:hover .record-card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #231e1e;
|
||||
}
|
||||
|
||||
.report-icon {
|
||||
background: #d7d72e;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
background: #d7e7f6;
|
||||
}
|
||||
|
||||
.record-icon svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.record-primary {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag,
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: #f2f2ed;
|
||||
padding: 0.25rem 0.55rem;
|
||||
color: rgb(35 30 30 / 65%);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tag svg {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.record-primary strong {
|
||||
max-width: 42rem;
|
||||
overflow: hidden;
|
||||
color: #231e1e;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.04em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-card .record-primary strong {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.record-provenance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: rgb(35 30 30 / 55%);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
code {
|
||||
color: rgb(35 30 30 / 50%);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-provenance code {
|
||||
padding-left: 0.65rem;
|
||||
border-left: 1px solid rgb(35 30 30 / 14%);
|
||||
}
|
||||
|
||||
.record-date {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.record-date span {
|
||||
color: rgb(35 30 30 / 32%);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.back-link .arrow-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.record-card > .arrow-icon {
|
||||
color: rgb(35 30 30 / 30%);
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgb(35 30 30 / 9%);
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 9rem minmax(0, 1fr);
|
||||
gap: 1.4rem;
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
border-left: 3px solid #d7d72e;
|
||||
border-radius: 0 0.8rem 0.8rem 0;
|
||||
background: #f6f6f1;
|
||||
padding: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.image-attachment {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1rem;
|
||||
background: #f6f6f1;
|
||||
}
|
||||
|
||||
.image-attachment a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-attachment img {
|
||||
width: 100%;
|
||||
max-height: 32rem;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: rgb(35 30 30 / 4%);
|
||||
}
|
||||
|
||||
.image-attachment figcaption,
|
||||
.file-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.image-attachment figcaption {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.image-attachment figcaption span,
|
||||
.file-attachment strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-main-link:hover strong {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
.image-attachment small,
|
||||
.file-attachment small {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.file-attachment {
|
||||
align-self: start;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1rem;
|
||||
background: #f6f6f1;
|
||||
}
|
||||
|
||||
.file-attachment:hover strong {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
|
||||
.file-attachment > svg:first-child {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.file-attachment > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
min-height: 14rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
padding: 2rem;
|
||||
color: rgb(35 30 30 / 56%);
|
||||
text-align: center;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.state h2 {
|
||||
margin: 0;
|
||||
color: #231e1e;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.state p {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: #9f2424;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-header {
|
||||
width: min(100% - 2rem, 1120px);
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100% - 2rem, 1120px);
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3rem, 16vw, 4.5rem);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feedback-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feedback-record {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.feedback-main-link {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
grid-column: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feedback-record .record-date {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.record-open-link {
|
||||
grid-column: 2;
|
||||
grid-row: 2 / span 2;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
grid-column: 2;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.record-card > .arrow-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
dl {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
dd + dt {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Report {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
reporterPubkey: string;
|
||||
targetKind: "event" | "pubkey" | "blob";
|
||||
target: string;
|
||||
channelId?: string;
|
||||
reportType: string;
|
||||
note?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackSummary {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
submitterPubkey: string;
|
||||
category?: string;
|
||||
bodySummary: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackDetail {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
eventId: string;
|
||||
submitterPubkey: string;
|
||||
category?: string;
|
||||
body: string;
|
||||
tags: string[][];
|
||||
eventCreatedAt: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface Resource<T> {
|
||||
data?: T;
|
||||
error?: Error;
|
||||
loading: boolean;
|
||||
stale: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useResource<T>(
|
||||
load: () => Promise<T>,
|
||||
key: string,
|
||||
): Resource<T> {
|
||||
const [data, setData] = useState<T>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const loadRef = useRef(load);
|
||||
const activeRequest = useRef("");
|
||||
loadRef.current = load;
|
||||
const refetch = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = `${key}\0${revision}`;
|
||||
activeRequest.current = requestId;
|
||||
const isCurrent = () => activeRequest.current === requestId;
|
||||
const loadCurrent = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const value = await loadRef.current();
|
||||
if (isCurrent()) setData(value);
|
||||
} catch (reason) {
|
||||
if (isCurrent()) {
|
||||
setError(
|
||||
reason instanceof Error ? reason : new Error("Request failed"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
};
|
||||
void loadCurrent();
|
||||
return () => {
|
||||
activeRequest.current = "";
|
||||
};
|
||||
}, [key, revision]);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
stale: loading && data !== undefined,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,246 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/**", async (route) => {
|
||||
await route.fulfill({ contentType: "application/json", body: "[]" });
|
||||
});
|
||||
});
|
||||
|
||||
for (const [path, heading] of [
|
||||
["/reports", "Open reports"],
|
||||
["/feedback", "Feedback"],
|
||||
]) {
|
||||
test(`${path} supports a deep link and empty state`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
|
||||
await expect(page.getByText("No records.")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("forbidden reads have an explicit state", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||
route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: { code: "forbidden", message: "request is not authorized" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.goto("/reports");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Access denied" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("report rows render the relay response contract", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "0e6caad8-1e18-4cd7-84fa-7264103f0a08",
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
reporterPubkey: "21".repeat(32),
|
||||
targetKind: "event",
|
||||
target: "12".repeat(32),
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2026-07-17T17:30:00Z",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await page.goto("/reports");
|
||||
await expect(page.getByText("design.buzz.xyz")).toBeVisible();
|
||||
await expect(page.getByText("spam")).toBeVisible();
|
||||
await expect(page.getByText("Unknown date")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("feedback cards open the complete submission", async ({ page }) => {
|
||||
const id = "feed0000-0000-4000-8000-000000000001";
|
||||
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
|
||||
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
eventId: "31".repeat(32),
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "needs-work",
|
||||
body: fullBody,
|
||||
tags: [],
|
||||
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "needs-work",
|
||||
bodySummary: `${fullBody.slice(0, 240)}…`,
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
const card = page.locator(".feedback-record");
|
||||
await expect(card.locator(".record-provenance")).toContainText(
|
||||
"design.buzz.xyz",
|
||||
);
|
||||
await card.locator(".feedback-main-link").click();
|
||||
await expect(page).toHaveURL(`/feedback/${id}`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Feedback detail" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("end of feedback", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("feedback can be searched and filtered by community and time", async ({
|
||||
page,
|
||||
}) => {
|
||||
const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "recent",
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
bodySummary: "Composer freezes after sleep",
|
||||
receivedAt: recent,
|
||||
},
|
||||
{
|
||||
id: "old",
|
||||
communityId: "two",
|
||||
communityHost: "engineering.buzz.xyz",
|
||||
submitterPubkey: "22".repeat(32),
|
||||
category: "praise",
|
||||
bodySummary: "Calls are much more reliable",
|
||||
receivedAt: old,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
await expect(page.getByText("2 of 2 submissions")).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: "Search feedback" }).fill("calls");
|
||||
await expect(page.getByText("Calls are much more reliable")).toBeVisible();
|
||||
await expect(page.getByText("Composer freezes after sleep")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("searchbox", { name: "Search feedback" }).fill("");
|
||||
await page.getByLabel("Community").selectOption("design.buzz.xyz");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Community").selectOption("all");
|
||||
await page.getByLabel("Received").selectOption("day");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("feedback status is stored locally by feedback id", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "feedback-one",
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
bodySummary: "Composer freezes after sleep",
|
||||
receivedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
await page.getByRole("checkbox", { name: "Acted on" }).check();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("checkbox", { name: "Acted on" })).toBeChecked();
|
||||
await page.getByLabel("Status").selectOption("acted-on");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await page.getByLabel("Status").selectOption("pending");
|
||||
await expect(page.getByText("No matching feedback.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("feedback attachments render from imeta without raw markdown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const id = "feedback-with-attachments";
|
||||
const imageUrl = `https://design.buzz.xyz/media/${"a".repeat(64)}.png`;
|
||||
const fileUrl = `https://design.buzz.xyz/media/${"b".repeat(64)}.txt`;
|
||||
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
eventId: "31".repeat(32),
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
body: `Composer froze.\n\n[diagnostics.txt](${fileUrl})`,
|
||||
tags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${imageUrl}`,
|
||||
"m image/png",
|
||||
`x ${"a".repeat(64)}`,
|
||||
"size 48213",
|
||||
"dim 1280x720",
|
||||
"filename screenshot.png",
|
||||
],
|
||||
[
|
||||
"imeta",
|
||||
`url ${fileUrl}`,
|
||||
"m text/plain",
|
||||
`x ${"b".repeat(64)}`,
|
||||
"size 391",
|
||||
"filename diagnostics.txt",
|
||||
],
|
||||
],
|
||||
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/feedback/${id}`);
|
||||
await expect(
|
||||
page.getByText("Composer froze.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("img", { name: "screenshot.png" }),
|
||||
).toHaveAttribute("src", imageUrl);
|
||||
await expect(
|
||||
page.getByRole("link", { name: /diagnostics.txt/ }),
|
||||
).toHaveAttribute("href", fileUrl);
|
||||
const fileHeight = await page
|
||||
.locator(".file-attachment")
|
||||
.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(fileHeight).toBeLessThan(100);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 4174 },
|
||||
build: { sourcemap: false },
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Explicit deployment-global reads for the private deployment-admin plane.
|
||||
//!
|
||||
//! This module is the only moderation repository allowed to omit a
|
||||
//! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in
|
||||
//! [`crate::moderation`] tenant-fenced.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgPool, Row as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Maximum rows accepted by one admin query.
|
||||
pub const MAX_PAGE_SIZE: i64 = 200;
|
||||
|
||||
fn bounded_limit(limit: i64) -> i64 {
|
||||
limit.clamp(1, MAX_PAGE_SIZE)
|
||||
}
|
||||
|
||||
/// Deployment-global moderation report.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminReport {
|
||||
/// Report row identifier.
|
||||
pub id: Uuid,
|
||||
/// Community identifier.
|
||||
pub community_id: Uuid,
|
||||
/// Community host.
|
||||
pub community_host: String,
|
||||
/// Signed report event identifier.
|
||||
pub report_event_id: String,
|
||||
/// Reporter public key.
|
||||
pub reporter_pubkey: String,
|
||||
/// Target class.
|
||||
pub target_kind: String,
|
||||
/// Hex target identifier.
|
||||
pub target: String,
|
||||
/// Optional channel.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// NIP-56 report category.
|
||||
pub report_type: String,
|
||||
/// Private reporter note.
|
||||
pub note: Option<String>,
|
||||
/// Lifecycle status.
|
||||
pub status: String,
|
||||
/// Resolving principal pubkey.
|
||||
pub resolved_by: Option<String>,
|
||||
/// Resolution time.
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
/// Linked action.
|
||||
pub action_id: Option<Uuid>,
|
||||
/// Creation time.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Deployment-global product feedback with source-community provenance.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminFeedback {
|
||||
/// Feedback row identifier.
|
||||
pub id: Uuid,
|
||||
/// Source community identifier.
|
||||
pub community_id: Uuid,
|
||||
/// Source community host.
|
||||
pub community_host: String,
|
||||
/// Signed feedback event identifier.
|
||||
pub event_id: String,
|
||||
/// Submitter public key.
|
||||
pub submitter_pubkey: String,
|
||||
/// Optional feedback category.
|
||||
pub category: Option<String>,
|
||||
/// Full feedback body.
|
||||
pub body: String,
|
||||
/// Full source tags, including attachment metadata.
|
||||
pub tags: serde_json::Value,
|
||||
/// Timestamp signed into the feedback event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// Time accepted by this deployment.
|
||||
pub received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List reports across all communities by stable descending keyset.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_reports(
|
||||
pool: &PgPool,
|
||||
community_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
report_type: Option<&str>,
|
||||
target_kind: Option<&str>,
|
||||
after: Option<DateTime<Utc>>,
|
||||
before: Option<DateTime<Utc>>,
|
||||
cursor: Option<(DateTime<Utc>, Uuid)>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<AdminReport>> {
|
||||
let (cursor_time, cursor_id) = cursor.unzip();
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT r.id, r.community_id, c.host AS community_host,
|
||||
r.report_event_id, r.reporter_pubkey, r.target_kind,
|
||||
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
|
||||
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
|
||||
r.resolved_at, r.action_id, r.created_at
|
||||
FROM moderation_reports r
|
||||
JOIN communities c ON c.id = r.community_id
|
||||
WHERE ($1::uuid IS NULL OR r.community_id = $1)
|
||||
AND ($2::text IS NULL OR r.status = $2)
|
||||
AND ($3::text IS NULL OR r.report_type = $3)
|
||||
AND ($4::text IS NULL OR r.target_kind = $4)
|
||||
AND ($5::timestamptz IS NULL OR r.created_at >= $5)
|
||||
AND ($6::timestamptz IS NULL OR r.created_at < $6)
|
||||
AND ($7::timestamptz IS NULL OR (r.created_at, r.id) < ($7, $8))
|
||||
ORDER BY r.created_at DESC, r.id DESC
|
||||
LIMIT $9
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(status)
|
||||
.bind(report_type)
|
||||
.bind(target_kind)
|
||||
.bind(after)
|
||||
.bind(before)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(bounded_limit(limit))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_report).collect()
|
||||
}
|
||||
|
||||
/// Fetch one report globally by its row id.
|
||||
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReport>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT r.id, r.community_id, c.host AS community_host,
|
||||
r.report_event_id, r.reporter_pubkey, r.target_kind,
|
||||
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
|
||||
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
|
||||
r.resolved_at, r.action_id, r.created_at
|
||||
FROM moderation_reports r
|
||||
JOIN communities c ON c.id = r.community_id
|
||||
WHERE r.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(report_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(row_to_report).transpose()
|
||||
}
|
||||
|
||||
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<AdminReport> {
|
||||
let target_kind: String = row.try_get("target_kind")?;
|
||||
let target = match target_kind.as_str() {
|
||||
"event" => row.try_get::<Vec<u8>, _>("target_event_id")?,
|
||||
"pubkey" => row.try_get::<Vec<u8>, _>("target_pubkey")?,
|
||||
"blob" => row.try_get::<Vec<u8>, _>("target_blob_sha256")?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(AdminReport {
|
||||
id: row.try_get("id")?,
|
||||
community_id: row.try_get("community_id")?,
|
||||
community_host: row.try_get("community_host")?,
|
||||
report_event_id: hex::encode(row.try_get::<Vec<u8>, _>("report_event_id")?),
|
||||
reporter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("reporter_pubkey")?),
|
||||
target_kind,
|
||||
target: hex::encode(target),
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
report_type: row.try_get("report_type")?,
|
||||
note: row.try_get("note")?,
|
||||
status: row.try_get("status")?,
|
||||
resolved_by: row
|
||||
.try_get::<Option<Vec<u8>>, _>("resolved_by")?
|
||||
.map(hex::encode),
|
||||
resolved_at: row.try_get("resolved_at")?,
|
||||
action_id: row.try_get("action_id")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// List product feedback across all communities, newest first.
|
||||
pub async fn list_feedback(pool: &PgPool, limit: i64) -> Result<Vec<AdminFeedback>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
ORDER BY f.received_at DESC, f.id DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(bounded_limit(limit))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_feedback).collect()
|
||||
}
|
||||
|
||||
/// Fetch one feedback submission globally by its row id.
|
||||
pub async fn get_feedback(pool: &PgPool, id: Uuid) -> Result<Option<AdminFeedback>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
WHERE f.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(row_to_feedback).transpose()
|
||||
}
|
||||
|
||||
fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
|
||||
Ok(AdminFeedback {
|
||||
id: row.try_get("id")?,
|
||||
community_id: row.try_get("community_id")?,
|
||||
community_host: row.try_get("community_host")?,
|
||||
event_id: hex::encode(row.try_get::<Vec<u8>, _>("event_id")?),
|
||||
submitter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("submitter_pubkey")?),
|
||||
category: row.try_get("category")?,
|
||||
body: row.try_get("body")?,
|
||||
tags: row.try_get("tags")?,
|
||||
event_created_at: row.try_get("event_created_at")?,
|
||||
received_at: row.try_get("received_at")?,
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
//! - No FK references to partitioned tables.
|
||||
//! - Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time).
|
||||
|
||||
/// Explicit deployment-global admin report reads.
|
||||
pub mod admin_moderation;
|
||||
/// API token storage and lookup.
|
||||
pub mod api_token;
|
||||
/// Relay-scoped archived identity persistence (NIP-IA).
|
||||
@@ -397,6 +399,57 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// List reports for the deployment-global read-only admin plane.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn admin_list_reports(
|
||||
&self,
|
||||
community_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
report_type: Option<&str>,
|
||||
target_kind: Option<&str>,
|
||||
after: Option<DateTime<Utc>>,
|
||||
before: Option<DateTime<Utc>>,
|
||||
cursor: Option<(DateTime<Utc>, Uuid)>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<admin_moderation::AdminReport>> {
|
||||
admin_moderation::list_reports(
|
||||
&self.pool,
|
||||
community_id,
|
||||
status,
|
||||
report_type,
|
||||
target_kind,
|
||||
after,
|
||||
before,
|
||||
cursor,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch one report for the deployment-global read-only admin plane.
|
||||
pub async fn admin_get_report(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<admin_moderation::AdminReport>> {
|
||||
admin_moderation::get_report(&self.pool, id).await
|
||||
}
|
||||
|
||||
/// List feedback for the deployment-global read-only admin plane.
|
||||
pub async fn admin_list_feedback(
|
||||
&self,
|
||||
limit: i64,
|
||||
) -> Result<Vec<admin_moderation::AdminFeedback>> {
|
||||
admin_moderation::list_feedback(&self.pool, limit).await
|
||||
}
|
||||
|
||||
/// Fetch one feedback submission for the deployment-global admin plane.
|
||||
pub async fn admin_get_feedback(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<admin_moderation::AdminFeedback>> {
|
||||
admin_moderation::get_feedback(&self.pool, id).await
|
||||
}
|
||||
|
||||
/// Return total number of communities on this relay.
|
||||
pub async fn usage_community_count(&self) -> Result<i64> {
|
||||
usage::community_count(&self.pool).await
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::http::{header, HeaderMap};
|
||||
|
||||
use super::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
|
||||
let Some(config) = state.config.admin.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
headers
|
||||
.get(header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|host| host == config.host)
|
||||
}
|
||||
|
||||
pub fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
let config = state
|
||||
.config
|
||||
.admin
|
||||
.as_ref()
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
if !is_admin_host(state, headers) {
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
if headers.get(header::ORIGIN).is_some_and(|origin| {
|
||||
origin
|
||||
.to_str()
|
||||
.map_or(true, |origin| !origin_matches_host(origin, &config.host))
|
||||
}) {
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn origin_matches_host(origin: &str, host: &str) -> bool {
|
||||
origin
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| origin.strip_prefix("http://"))
|
||||
== Some(host)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::origin_matches_host;
|
||||
|
||||
#[test]
|
||||
fn browser_origin_must_match_admin_host() {
|
||||
assert!(origin_matches_host(
|
||||
"https://admin.example.com",
|
||||
"admin.example.com"
|
||||
));
|
||||
assert!(origin_matches_host(
|
||||
"http://admin.localhost:3000",
|
||||
"admin.localhost:3000"
|
||||
));
|
||||
assert!(!origin_matches_host(
|
||||
"https://attacker.example",
|
||||
"admin.example.com"
|
||||
));
|
||||
assert!(!origin_matches_host("null", "admin.example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: StatusCode,
|
||||
pub code: &'static str,
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorEnvelope {
|
||||
error: ErrorBody,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ErrorBody {
|
||||
code: &'static str,
|
||||
message: &'static str,
|
||||
request_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(code: &'static str, message: &'static str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden() -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
code: "forbidden",
|
||||
message: "request is not authorized",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "not_found",
|
||||
message: "record was not found",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal() -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
code: "internal_error",
|
||||
message: "request failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
self.status,
|
||||
Json(ErrorEnvelope {
|
||||
error: ErrorBody {
|
||||
code: self.code,
|
||||
message: self.message,
|
||||
request_id: uuid::Uuid::new_v4(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<buzz_db::DbError> for ApiError {
|
||||
fn from(_: buzz_db::DbError) -> Self {
|
||||
Self::internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Private, read-only deployment moderation API.
|
||||
|
||||
mod auth;
|
||||
mod error;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use auth::authorize;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, HeaderValue},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use error::ApiError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap) -> bool {
|
||||
auth::is_admin_host(state, headers)
|
||||
}
|
||||
|
||||
/// Build the read-only deployment-admin routes.
|
||||
pub fn router(state: Arc<crate::state::AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/reports", get(reports))
|
||||
.route("/reports/{id}", get(report_detail))
|
||||
.route("/feedback", get(feedback))
|
||||
.route("/feedback/{id}", get(feedback_detail))
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(RequestBodyLimitLayer::new(1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn security_headers(request: axum::extract::Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert(
|
||||
header::REFERRER_POLICY,
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ReportQuery {
|
||||
community_id: Option<Uuid>,
|
||||
status: Option<String>,
|
||||
report_type: Option<String>,
|
||||
target_kind: Option<String>,
|
||||
before: Option<DateTime<Utc>>,
|
||||
after: Option<DateTime<Utc>>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
fn limit(value: Option<i64>) -> Result<i64, ApiError> {
|
||||
match value.unwrap_or(50) {
|
||||
value @ 1..=200 => Ok(value),
|
||||
_ => Err(ApiError::bad_request(
|
||||
"invalid_limit",
|
||||
"limit must be between 1 and 200",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(value: Option<&str>, allowed: &[&str], code: &'static str) -> Result<(), ApiError> {
|
||||
if value.is_some_and(|value| !allowed.contains(&value)) {
|
||||
Err(ApiError::bad_request(code, "filter is invalid"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn reports(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ReportQuery>,
|
||||
) -> Result<Json<Vec<buzz_db::admin_moderation::AdminReport>>, ApiError> {
|
||||
authorize(&state, &headers)?;
|
||||
validate(
|
||||
query.status.as_deref(),
|
||||
&["open", "resolved", "dismissed", "escalated"],
|
||||
"invalid_status",
|
||||
)?;
|
||||
validate(
|
||||
query.target_kind.as_deref(),
|
||||
&["event", "pubkey", "blob"],
|
||||
"invalid_target_kind",
|
||||
)?;
|
||||
let items = state
|
||||
.db
|
||||
.admin_list_reports(
|
||||
query.community_id,
|
||||
query.status.as_deref(),
|
||||
query.report_type.as_deref(),
|
||||
query.target_kind.as_deref(),
|
||||
query.after,
|
||||
query.before,
|
||||
None,
|
||||
limit(query.limit)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
async fn report_detail(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<buzz_db::admin_moderation::AdminReport>, ApiError> {
|
||||
authorize(&state, &headers)?;
|
||||
state
|
||||
.db
|
||||
.admin_get_report(id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(ApiError::not_found)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FeedbackSummary {
|
||||
id: Uuid,
|
||||
community_id: Uuid,
|
||||
community_host: String,
|
||||
submitter_pubkey: String,
|
||||
category: Option<String>,
|
||||
body_summary: String,
|
||||
received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn feedback(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<FeedbackSummary>>, ApiError> {
|
||||
authorize(&state, &headers)?;
|
||||
let items = state
|
||||
.db
|
||||
.admin_list_feedback(100)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let body_summary = summarize_body(&item.body, &item.tags);
|
||||
FeedbackSummary {
|
||||
id: item.id,
|
||||
community_id: item.community_id,
|
||||
community_host: item.community_host,
|
||||
submitter_pubkey: item.submitter_pubkey,
|
||||
category: item.category,
|
||||
body_summary,
|
||||
received_at: item.received_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
async fn feedback_detail(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<buzz_db::admin_moderation::AdminFeedback>, ApiError> {
|
||||
authorize(&state, &headers)?;
|
||||
state
|
||||
.db
|
||||
.admin_get_feedback(id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(ApiError::not_found)
|
||||
}
|
||||
|
||||
fn summarize_body(body: &str, tags: &serde_json::Value) -> String {
|
||||
const MAX_CHARS: usize = 240;
|
||||
let attachment_urls = tags
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tag| tag.as_array())
|
||||
.filter(|tag| tag.first().and_then(|value| value.as_str()) == Some("imeta"))
|
||||
.flat_map(|tag| tag.iter().skip(1))
|
||||
.filter_map(|value| value.as_str()?.strip_prefix("url "))
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let body = body
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let line = line.trim();
|
||||
let url = line
|
||||
.strip_suffix(')')
|
||||
.and_then(|line| line.rsplit_once("]("))
|
||||
.and_then(|(label, url)| {
|
||||
(label.starts_with('[') || label.starts_with("![")).then_some(url)
|
||||
});
|
||||
url.is_none_or(|url| !attachment_urls.contains(url))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let mut chars = body.trim().chars();
|
||||
let mut summary = chars.by_ref().take(MAX_CHARS).collect::<String>();
|
||||
if chars.next().is_some() {
|
||||
summary.push('…');
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_filters_reject_unknown_values() {
|
||||
assert!(validate(Some("open"), &["open"], "invalid_status").is_ok());
|
||||
assert!(validate(Some("unknown"), &["open"], "invalid_status").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_summary_is_unicode_safe_and_marks_truncation() {
|
||||
let body = "🐝".repeat(241);
|
||||
let summary = summarize_body(&body, &serde_json::Value::Null);
|
||||
assert_eq!(summary.chars().count(), 241);
|
||||
assert!(summary.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_summary_omits_imeta_attachment_lines() {
|
||||
let url = "http://localhost:3000/media/abc.png";
|
||||
let tags = serde_json::json!([["imeta", format!("url {url}"), "m image/png"]]);
|
||||
assert_eq!(
|
||||
summarize_body(&format!("Useful context.\n"), &tags),
|
||||
"Useful context."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.
|
||||
|
||||
pub mod admin;
|
||||
pub mod bridge;
|
||||
pub mod events;
|
||||
pub mod git;
|
||||
|
||||
@@ -24,6 +24,15 @@ pub enum ConfigError {
|
||||
InvalidValue(String),
|
||||
}
|
||||
|
||||
/// Deny-by-default read-only deployment-admin configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminConfig {
|
||||
/// Exact admin HTTP authority.
|
||||
pub host: String,
|
||||
/// Optional admin SPA bundle directory.
|
||||
pub web_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Relay-hosted policy content presented on join surfaces.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JoinPolicyConfig {
|
||||
@@ -219,6 +228,9 @@ pub struct Config {
|
||||
/// documents or age attestation are configured.
|
||||
pub join_policy: Option<JoinPolicyConfig>,
|
||||
|
||||
/// Deployment-admin API and SPA configuration. Absent means the surface is disabled.
|
||||
pub admin: Option<AdminConfig>,
|
||||
|
||||
/// Optional path to the web UI `dist/` directory.
|
||||
/// When set, the relay serves the invite landing page and its static assets.
|
||||
/// When unset, no static file serving happens (relay behaves as before).
|
||||
@@ -737,6 +749,35 @@ impl Config {
|
||||
})
|
||||
};
|
||||
|
||||
// Read-only deployment-admin surface. The route is absent when the host is unset.
|
||||
let admin = match std::env::var("BUZZ_ADMIN_HOST")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
None => None,
|
||||
Some(host) => {
|
||||
if host.contains(['/', '\\', '@']) {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"BUZZ_ADMIN_HOST must be an exact authority".to_string(),
|
||||
));
|
||||
}
|
||||
let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR")
|
||||
.ok()
|
||||
.map(|value| std::path::PathBuf::from(value.trim()))
|
||||
.filter(|value| !value.as_os_str().is_empty());
|
||||
if let Some(ref dir) = web_dir {
|
||||
if !dir.join("index.html").is_file() {
|
||||
return Err(ConfigError::InvalidValue(format!(
|
||||
"BUZZ_ADMIN_WEB_DIR={} does not contain index.html",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Some(AdminConfig { host, web_dir })
|
||||
}
|
||||
};
|
||||
|
||||
// Web UI static file serving
|
||||
let web_dir = std::env::var("BUZZ_WEB_DIR")
|
||||
.ok()
|
||||
@@ -810,6 +851,7 @@ impl Config {
|
||||
push_gateway_delivery_url,
|
||||
push_gateway_timeout,
|
||||
join_policy,
|
||||
admin,
|
||||
web_dir,
|
||||
serve_git_web_gui,
|
||||
})
|
||||
|
||||
@@ -20,6 +20,18 @@ pub async fn handle(
|
||||
) -> Result<(), String> {
|
||||
let category = parse_category(event)?;
|
||||
validate_body(&event.content)?;
|
||||
let imeta_tags = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind().to_string() == "imeta")
|
||||
.map(|tag| tag.as_slice().iter().map(ToString::to_string).collect())
|
||||
.collect::<Vec<Vec<String>>>();
|
||||
if !imeta_tags.is_empty() {
|
||||
let media_base =
|
||||
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
|
||||
crate::api::validate_imeta_tags(&imeta_tags, &media_base)?;
|
||||
crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage).await?;
|
||||
}
|
||||
|
||||
let tags = serialize_tags(event)?;
|
||||
let event_created_at =
|
||||
|
||||
@@ -49,6 +49,15 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
|
||||
let git_policy_router = api::git::git_policy_router(state.clone());
|
||||
|
||||
let admin_enabled = state.config.admin.is_some();
|
||||
let admin_web_dir = state
|
||||
.config
|
||||
.admin
|
||||
.as_ref()
|
||||
.and_then(|config| config.web_dir.clone());
|
||||
let admin_router = admin_enabled
|
||||
.then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone())));
|
||||
|
||||
let api_router = Router::new()
|
||||
// WebSocket + NIP-11
|
||||
.route("/", get(nip11_or_ws_handler))
|
||||
@@ -127,28 +136,48 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.merge(media_router)
|
||||
.merge(git_router)
|
||||
.merge(git_policy_router);
|
||||
if let Some(admin_router) = admin_router {
|
||||
merged = merged.merge(admin_router);
|
||||
}
|
||||
|
||||
// When BUZZ_WEB_DIR is set, serve either the full SPA or its invite-only
|
||||
// surface. Invite-only mode deliberately exposes only /invite/{code} and
|
||||
// hashed build assets; root and repository browser routes remain absent.
|
||||
if let Some(ref web_dir) = state.config.web_dir {
|
||||
let index_path = web_dir.join("index.html");
|
||||
let static_files = ServeDir::new(web_dir);
|
||||
// Serve both bundles from one fallback. The admin host is checked first so
|
||||
// it can never fall through to the public web bundle.
|
||||
let web_dir = state.config.web_dir.clone();
|
||||
if admin_web_dir.is_some() || web_dir.is_some() {
|
||||
let admin_index = admin_web_dir.as_ref().map(|dir| dir.join("index.html"));
|
||||
let admin_files = admin_web_dir.map(ServeDir::new);
|
||||
let web_index = web_dir.as_ref().map(|dir| dir.join("index.html"));
|
||||
let web_files = web_dir.map(ServeDir::new);
|
||||
let serve_git_web_gui = state.config.serve_git_web_gui;
|
||||
let fallback_state = state.clone();
|
||||
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
|
||||
let index = index_path.clone();
|
||||
let static_files = static_files.clone();
|
||||
let admin_index = admin_index.clone();
|
||||
let admin_files = admin_files.clone();
|
||||
let web_index = web_index.clone();
|
||||
let web_files = web_files.clone();
|
||||
let state = fallback_state.clone();
|
||||
async move {
|
||||
let path = req.uri().path();
|
||||
if path.starts_with("/assets/") {
|
||||
return static_files
|
||||
.oneshot(req)
|
||||
.await
|
||||
.map(IntoResponse::into_response);
|
||||
let admin_host = api::admin::is_admin_host(&state, req.headers());
|
||||
if admin_host {
|
||||
if let (Some(index), Some(files)) = (admin_index, admin_files) {
|
||||
if path.starts_with("/assets/") {
|
||||
return files.oneshot(req).await.map(IntoResponse::into_response);
|
||||
}
|
||||
if is_admin_spa_path(path) {
|
||||
return Ok(read_spa_index(&index).await);
|
||||
}
|
||||
}
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
if should_serve_spa(path, serve_git_web_gui) {
|
||||
return Ok(read_spa_index(&index).await);
|
||||
if let (Some(index), Some(files)) = (web_index, web_files) {
|
||||
if path.starts_with("/assets/") {
|
||||
return files.oneshot(req).await.map(IntoResponse::into_response);
|
||||
}
|
||||
if should_serve_spa(path, serve_git_web_gui) {
|
||||
return Ok(read_spa_index(&index).await);
|
||||
}
|
||||
}
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
@@ -162,6 +191,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.layer(build_cors_layer(&state.config.cors_origins))
|
||||
}
|
||||
|
||||
fn is_admin_spa_path(path: &str) -> bool {
|
||||
path == "/"
|
||||
|| path == "/reports"
|
||||
|| path.starts_with("/reports/")
|
||||
|| path == "/feedback"
|
||||
|| path.starts_with("/feedback/")
|
||||
}
|
||||
|
||||
fn is_invite_landing_path(path: &str) -> bool {
|
||||
path.strip_prefix("/invite/")
|
||||
.is_some_and(|code| !code.is_empty() && !code.contains('/'))
|
||||
@@ -216,6 +253,25 @@ async fn nip11_or_ws_handler(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
// `/` is an explicit relay route, so it never reaches the SPA fallback.
|
||||
// Short-circuit the exact admin authority here and never let it serve the
|
||||
// public web bundle, NIP-11 document, or WebSocket endpoint.
|
||||
if api::admin::is_admin_host(&state, &headers) {
|
||||
if !accept.contains("text/html") {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let Some(index) = state
|
||||
.config
|
||||
.admin
|
||||
.as_ref()
|
||||
.and_then(|config| config.web_dir.as_ref())
|
||||
.map(|dir| dir.join("index.html"))
|
||||
else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
return read_spa_index(&index).await;
|
||||
}
|
||||
|
||||
if accept.contains("application/nostr+json") {
|
||||
return Json(nip11_document(&state, raw_host).await).into_response();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Read-only deployment moderation dashboard
|
||||
|
||||
Buzz can expose a private, deployment-wide read-only dashboard from the existing
|
||||
relay process. It shows open moderation reports and recent product feedback.
|
||||
|
||||
Configure `BUZZ_ADMIN_HOST` to activate the dashboard. A private ingress limits
|
||||
access to the operator VPN or approved source IPs.
|
||||
|
||||
Required configuration:
|
||||
|
||||
```text
|
||||
BUZZ_ADMIN_HOST=admin.example.com
|
||||
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||||
```
|
||||
|
||||
The relay requires the configured admin host and matching browser origin.
|
||||
Requests and responses are bounded and uncached. The deployment routes admin
|
||||
traffic through the private ingress.
|
||||
|
||||
When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while
|
||||
preserving the admin `Host` header. A `NetworkPolicy` grants the admin pod access
|
||||
to that relay path.
|
||||
|
||||
Read routes:
|
||||
|
||||
- `GET /api/admin/v1/reports`
|
||||
- `GET /api/admin/v1/reports/:id`
|
||||
- `GET /api/admin/v1/feedback`
|
||||
- `GET /api/admin/v1/feedback/:id`
|
||||
|
||||
Report reads accept optional `communityId`, `status`, `reportType`, `targetKind`,
|
||||
`after`, `before`, and `limit` parameters. Limits are capped at 200. Feedback is
|
||||
a bounded newest-first summary from the existing product-feedback repository.
|
||||
|
||||
For local review, run `just admin-seed` before `just admin`. The seed command
|
||||
also uploads real image and diagnostic fixtures to local MinIO. Feedback search
|
||||
and filters run over the bounded browser result set; the **Acted on** checkbox is
|
||||
stored in that browser's local storage.
|
||||
Generated
+810
-2
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- "desktop"
|
||||
- "web"
|
||||
- "admin-web"
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
overrides:
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/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;
|
||||
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_event_id, target_pubkey, target_blob_sha256, 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, '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, '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'), '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, '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'), '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, '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, '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, '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'), '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, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days')
|
||||
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,
|
||||
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', '[["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\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', '[["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 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
|
||||
Reference in New Issue
Block a user