Add user feedback status notifications
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user