Add user feedback status notifications

This commit is contained in:
2569718930@qq.com
2026-06-06 22:55:15 +08:00
parent a5de604d85
commit f7625218ae
11 changed files with 530 additions and 4 deletions
+50
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -10,6 +11,55 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/feedback`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) upstream.searchParams.set("limit", limit);
const res = await fetch(upstream, {
method: "GET",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback status request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch feedback status",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
@@ -60,6 +60,7 @@ import {
UserFeedbackModal,
type FeedbackDraft,
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
@@ -663,6 +664,7 @@ function PolyWeatherTerminal({
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
const [onlineCount, setOnlineCount] = useState<number | null>(null);
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
const handleSelectNav = useCallback((key: string) => {
setActiveNavKey(key);
}, []);
@@ -754,6 +756,10 @@ function PolyWeatherTerminal({
});
}, [activeNavKey, gridCols, gridRows, locale]);
const handleFeedbackSubmitted = useCallback(() => {
setFeedbackRefreshKey((value) => value + 1);
}, []);
const handleSetGridSize = (cols: number, rows: number) => {
const safeCols = clampGridSide(cols);
const safeRows = clampGridSide(rows);
@@ -970,6 +976,7 @@ function PolyWeatherTerminal({
>
{isEn ? "中文" : "EN"}
</button>
<UserFeedbackStatusButton isEn={isEn} refreshKey={feedbackRefreshKey} />
<Link
href="/account"
className="grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
@@ -1189,6 +1196,7 @@ function PolyWeatherTerminal({
draft={feedbackDraft}
isEn={isEn}
onClose={() => setFeedbackDraft(null)}
onSubmitted={handleFeedbackSubmitted}
/>
</div>
);
@@ -7,6 +7,7 @@ import {
getAnalyticsClientId,
getAnalyticsSessionId,
} from "@/lib/app-analytics";
import type { UserFeedbackEntry } from "@/types/ops";
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
@@ -48,10 +49,12 @@ export function UserFeedbackModal({
draft,
isEn,
onClose,
onSubmitted,
}: {
draft: FeedbackDraft | null;
isEn: boolean;
onClose: () => void;
onSubmitted?: (entry?: UserFeedbackEntry) => void;
}) {
const open = Boolean(draft);
const [category, setCategory] = useState<FeedbackCategory>("bug");
@@ -103,7 +106,11 @@ export function UserFeedbackModal({
const detail = (await res.text().catch(() => "")).slice(0, 200);
throw new Error(detail || `HTTP ${res.status}`);
}
const payload = (await res.json().catch(() => null)) as {
feedback?: UserFeedbackEntry;
} | null;
setSubmitted(true);
onSubmitted?.(payload?.feedback);
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
@@ -139,8 +146,8 @@ export function UserFeedbackModal({
</div>
<p className="mx-auto mt-2 max-w-sm text-xs leading-5 text-slate-500">
{isEn
? "We saved the report with the current terminal context."
: "已附带当前终端上下文保存,后续会在后台统一处理。"}
? "We saved the report with the current terminal context. The bell icon will show handling updates."
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
</p>
<button
type="button"
@@ -0,0 +1,228 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Bell, RefreshCcw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
feedbackStatusLabel,
feedbackStatusTone,
} from "./feedback-status";
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
const FEEDBACK_STATUS_POLL_MS = 60_000;
function loadSeenKeys() {
if (typeof window === "undefined") return new Set<string>();
try {
const raw = window.localStorage.getItem(FEEDBACK_STATUS_SEEN_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
} catch {
return new Set<string>();
}
}
function saveSeenKeys(keys: Set<string>) {
if (typeof window === "undefined") return;
try {
const trimmed = Array.from(keys).slice(-200);
window.localStorage.setItem(FEEDBACK_STATUS_SEEN_KEY, JSON.stringify(trimmed));
} catch {
// Ignore storage failures; the badge can reappear without breaking feedback status.
}
}
function compactDate(value?: string) {
if (!value) return "";
return value.slice(0, 16).replace("T", " ");
}
function categoryLabel(value?: string, isEn = false) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return isEn ? "Data" : "数据";
if (key === "idea") return isEn ? "Suggestion" : "建议";
if (key === "payment") return isEn ? "Payment" : "支付";
if (key === "account") return isEn ? "Account" : "账号";
return isEn ? "Other" : "其他";
}
export function UserFeedbackStatusButton({
isEn,
refreshKey = 0,
}: {
isEn: boolean;
refreshKey?: number;
}) {
const [available, setAvailable] = useState(true);
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
const unseenCount = useMemo(
() => countUnseenFeedbackUpdates(entries, seenKeys),
[entries, seenKeys],
);
const emptyStateText = loading
? isEn
? "Loading..."
: "加载中..."
: isEn
? "No submitted feedback yet."
: "暂无已提交反馈。";
const load = useCallback(async (signal?: AbortSignal) => {
if (typeof fetch !== "function") return;
setLoading(true);
try {
const res = await fetch("/api/feedback?limit=12", {
cache: "no-store",
headers: { Accept: "application/json" },
signal,
});
if (res.status === 401 || res.status === 403) {
setAvailable(false);
setEntries([]);
return;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = (await res.json()) as UserFeedbackPayload;
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
setAvailable(true);
setError("");
} catch (err) {
if (signal?.aborted) return;
setError(String(err).slice(0, 120));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
const markVisibleAsSeen = useCallback((visibleEntries: UserFeedbackEntry[]) => {
setSeenKeys((current) => {
const next = new Set(current);
visibleEntries.forEach((entry) => next.add(buildFeedbackNotificationKey(entry)));
saveSeenKeys(next);
return next;
});
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void load();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const id = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, FEEDBACK_STATUS_POLL_MS);
return () => {
controller.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.clearInterval(id);
};
}, [load, refreshKey]);
useEffect(() => {
if (open) markVisibleAsSeen(entries);
}, [entries, markVisibleAsSeen, open]);
if (!available) return null;
return (
<div className="relative">
<button
type="button"
onClick={() => {
setOpen((value) => {
const next = !value;
if (next) markVisibleAsSeen(entries);
return next;
});
}}
className="relative grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
title={isEn ? "Feedback status" : "反馈处理状态"}
aria-label={isEn ? "Feedback status" : "反馈处理状态"}
>
<Bell size={13} />
{unseenCount > 0 && (
<span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-blue-600 px-1 text-[9px] font-black leading-none text-white ring-2 ring-white">
{unseenCount > 9 ? "9+" : unseenCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-9 z-[70] w-[340px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-800 shadow-xl">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<div>
<div className="text-xs font-black text-slate-950">
{isEn ? "Feedback updates" : "反馈处理动态"}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
</div>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
title={isEn ? "Refresh" : "刷新"}
aria-label={isEn ? "Refresh feedback status" : "刷新反馈状态"}
>
<RefreshCcw size={13} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
</div>
)}
{entries.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-slate-500">
{emptyStateText}
</div>
) : (
<div className="max-h-[360px] overflow-y-auto">
{entries.map((entry) => (
<div key={entry.id} className="border-b border-slate-100 px-3 py-2 last:border-b-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-bold text-slate-900">
{entry.message || (isEn ? "Feedback" : "反馈")}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
<span>{categoryLabel(entry.category, isEn)}</span>
{entry.created_at && <span>{compactDate(entry.created_at)}</span>}
</div>
</div>
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
{feedbackStatusLabel(entry.status, isEn)}
</span>
</div>
{entry.updated_at && entry.updated_at !== entry.created_at && (
<div className="mt-1 text-[11px] text-slate-500">
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
feedbackStatusLabel,
} from "@/components/dashboard/scan-terminal/feedback-status";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
assert(feedbackStatusLabel("resolved", false) === "已解决", "resolved feedback should read as handled");
assert(feedbackStatusLabel("closed", true) === "Closed", "closed feedback should have English copy");
const firstVersion = {
id: 7,
status: "open",
updated_at: "2026-06-06T10:00:00",
};
const handledVersion = {
...firstVersion,
status: "resolved",
updated_at: "2026-06-06T10:30:00",
};
const firstKey = buildFeedbackNotificationKey(firstVersion);
const handledKey = buildFeedbackNotificationKey(handledVersion);
assert(firstKey !== handledKey, "status and updated_at changes should create a new notification key");
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey])) === 1,
"already seen feedback versions should not count, but later handled versions should",
);
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey, handledKey])) === 0,
"opening the status panel should be able to clear all visible feedback update badges",
);
}
@@ -16,14 +16,22 @@ export function runTests() {
"utf8",
);
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
const statusButtonPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx");
const opsSidebarSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const feedbackRouteSource = fs.readFileSync(path.join(projectRoot, "app", "api", "feedback", "route.ts"), "utf8");
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
assert(fs.existsSync(statusButtonPath), "terminal must ship a user-facing feedback status notification component");
const modalSource = fs.readFileSync(modalPath, "utf8");
const statusButtonSource = fs.readFileSync(statusButtonPath, "utf8");
const statusHelperSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "feedback-status.ts"),
"utf8",
);
assert(
dashboardSource.includes("onFeedbackClick") &&
@@ -40,9 +48,30 @@ export function runTests() {
assert(
modalSource.includes("/api/feedback") &&
modalSource.includes("getAnalyticsClientId") &&
modalSource.includes("onSubmitted") &&
modalSource.includes("navigator.userAgent") &&
modalSource.includes("type=\"textarea\"") === false,
"feedback modal must submit to the feedback API and attach client/session diagnostics without using invalid textarea input types",
"feedback modal must submit to the feedback API, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
);
assert(
feedbackRouteSource.includes("export async function GET") &&
feedbackRouteSource.includes("method: \"GET\"") &&
feedbackRouteSource.includes("limit"),
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
);
assert(
dashboardSource.includes("<UserFeedbackStatusButton") &&
dashboardSource.includes("feedbackRefreshKey") &&
dashboardSource.includes("setFeedbackRefreshKey"),
"terminal header must expose a small feedback status notification icon and refresh it after submissions",
);
assert(
statusButtonSource.includes("/api/feedback") &&
statusButtonSource.includes("localStorage") &&
statusButtonSource.includes("Bell") &&
statusHelperSource.includes("triaged") &&
statusHelperSource.includes("resolved"),
"feedback status notification must poll the user feedback API, badge unseen status changes, and translate handled states",
);
assert(
opsSidebarSource.includes("/ops/feedback") &&
@@ -0,0 +1,40 @@
import type { UserFeedbackEntry } from "@/types/ops";
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
const key = String(status || "open").toLowerCase();
if (key === "triaged") return isEn ? "Confirmed" : "已确认";
if (key === "investigating") return isEn ? "In progress" : "处理中";
if (key === "resolved") return isEn ? "Resolved" : "已解决";
if (key === "closed") return isEn ? "Closed" : "已关闭";
return isEn ? "Received" : "已收到";
}
export function feedbackStatusTone(status: string | undefined) {
const key = String(status || "open").toLowerCase();
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (key === "closed") return "border-slate-200 bg-slate-50 text-slate-500";
return "border-red-200 bg-red-50 text-red-700";
}
export function buildFeedbackNotificationKey(
entry: Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">,
) {
return [
Number(entry.id || 0),
String(entry.status || "open").toLowerCase(),
String(entry.updated_at || entry.created_at || ""),
].join(":");
}
export function countUnseenFeedbackUpdates(
entries: Array<
Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">
>,
seenKeys: Set<string>,
) {
return entries.reduce((count, entry) => {
return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1);
}, 0);
}
+19
View File
@@ -568,6 +568,12 @@ class DBManager:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_feedback_created_at ON user_feedback(created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_feedback_user_created_at ON user_feedback(user_id, created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_feedback_email_created_at ON user_feedback(user_email, created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS supabase_bindings (
supabase_user_id TEXT PRIMARY KEY,
@@ -1155,14 +1161,27 @@ class DBManager:
*,
limit: int = 100,
status: Optional[str] = None,
user_id: Optional[str] = None,
user_email: Optional[str] = None,
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 100), 500))
normalized_status = str(status or "").strip().lower()
normalized_user_id = str(user_id or "").strip().lower()
normalized_user_email = str(user_email or "").strip().lower()
clauses: List[str] = []
params: List[Any] = []
if normalized_status:
clauses.append("status = ?")
params.append(normalized_status)
identity_clauses: List[str] = []
if normalized_user_id:
identity_clauses.append("user_id = ?")
params.append(normalized_user_id)
if normalized_user_email:
identity_clauses.append("user_email = ?")
params.append(normalized_user_email)
if identity_clauses:
clauses.append(f"({' OR '.join(identity_clauses)})")
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(safe_limit)
with self._get_connection() as conn:
+76
View File
@@ -1,4 +1,10 @@
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from src.database.db_manager import DBManager
from web.services import feedback_api
def test_user_feedback_round_trip_includes_context_and_status(tmp_path):
@@ -45,3 +51,73 @@ def test_user_feedback_status_filter_excludes_other_statuses(tmp_path):
assert [row["status"] for row in open_rows] == ["open"]
assert open_rows[0]["message"] == "Add a dark chart grid."
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
mine_by_user_id = db.append_user_feedback(
category="bug",
message="My chart needs attention.",
user_id="user-a",
user_email="a@example.com",
)
mine_by_email = db.append_user_feedback(
category="data",
message="My older email-only report.",
user_email="a@example.com",
)
db.append_user_feedback(
category="bug",
message="Someone else's report.",
user_id="user-b",
user_email="b@example.com",
)
db.append_user_feedback(
category="idea",
message="Anonymous report should not leak.",
contact="a@example.com",
)
rows = db.list_user_feedback(
limit=10,
user_id="user-a",
user_email="a@example.com",
)
assert [row["id"] for row in rows] == [mine_by_email["id"], mine_by_user_id["id"]]
assert {row["message"] for row in rows} == {
"My chart needs attention.",
"My older email-only report.",
}
def test_list_current_user_feedback_requires_identity(tmp_path, monkeypatch):
db = DBManager(str(tmp_path / "polyweather-feedback-current-user.db"))
db.append_user_feedback(
category="bug",
message="Mine.",
user_id="user-a",
user_email="a@example.com",
)
db.append_user_feedback(
category="bug",
message="Not mine.",
user_id="user-b",
user_email="b@example.com",
)
monkeypatch.setattr(feedback_api, "DBManager", lambda: db)
monkeypatch.setattr(feedback_api.legacy_routes, "_bind_optional_supabase_identity", lambda request: None)
request = SimpleNamespace(
state=SimpleNamespace(auth_user_id="user-a", auth_email="a@example.com")
)
payload = feedback_api.list_current_user_feedback(request, limit=20)
assert payload["total"] == 1
assert payload["feedback"][0]["message"] == "Mine."
anonymous_request = SimpleNamespace(state=SimpleNamespace())
with pytest.raises(HTTPException) as exc_info:
feedback_api.list_current_user_feedback(anonymous_request, limit=20)
assert exc_info.value.status_code == 401
+6 -1
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter, Request
from web.core import UserFeedbackRequest
from web.services.feedback_api import submit_user_feedback
from web.services.feedback_api import list_current_user_feedback, submit_user_feedback
router = APIRouter(tags=["feedback"])
@@ -11,3 +11,8 @@ router = APIRouter(tags=["feedback"])
@router.post("/api/feedback")
async def feedback_submit(request: Request, body: UserFeedbackRequest):
return submit_user_feedback(request, body)
@router.get("/api/feedback")
async def feedback_list_current_user(request: Request, limit: int = 20):
return list_current_user_feedback(request, limit=limit)
+24
View File
@@ -63,3 +63,27 @@ def submit_user_feedback(request: Request, body: UserFeedbackRequest) -> Dict[st
context=_bounded_context(body.context),
)
return {"ok": True, "feedback": feedback}
def list_current_user_feedback(request: Request, *, limit: int = 20) -> Dict[str, Any]:
legacy_routes._bind_optional_supabase_identity(request)
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
user_email = str(getattr(request.state, "auth_email", "") or "").strip()
if not user_id and not user_email:
raise HTTPException(status_code=401, detail="Supabase user required")
rows = DBManager().list_user_feedback(
limit=limit,
user_id=user_id,
user_email=user_email,
)
status_counts: Dict[str, int] = {}
for row in rows:
key = str(row.get("status") or "unknown")
status_counts[key] = status_counts.get(key, 0) + 1
return {
"feedback": rows,
"total": len(rows),
"status_counts": status_counts,
}