diff --git a/frontend/app/api/feedback/route.ts b/frontend/app/api/feedback/route.ts index e36f65db..bcdbd330 100644 --- a/frontend/app/api/feedback/route.ts +++ b/frontend/app/api/feedback/route.ts @@ -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> | 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( diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index f5770296..89babeca 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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("thresholds"); const [onlineCount, setOnlineCount] = useState(null); const [feedbackDraft, setFeedbackDraft] = useState(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"} + setFeedbackDraft(null)} + onSubmitted={handleFeedbackSubmitted} /> ); diff --git a/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx b/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx index 948bbb12..642d90dc 100644 --- a/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx +++ b/frontend/components/dashboard/scan-terminal/UserFeedbackModal.tsx @@ -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("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({

{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." + : "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}

+ + {open && ( +
+
+
+
+ {isEn ? "Feedback updates" : "反馈处理动态"} +
+
+ {isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"} +
+
+ +
+ + {error && ( +
+ {isEn ? "Status refresh failed: " : "状态刷新失败:"}{error} +
+ )} + + {entries.length === 0 ? ( +
+ {emptyStateText} +
+ ) : ( +
+ {entries.map((entry) => ( +
+
+
+
+ {entry.message || (isEn ? "Feedback" : "反馈")} +
+
+ {categoryLabel(entry.category, isEn)} + {entry.created_at && {compactDate(entry.created_at)}} +
+
+ + {feedbackStatusLabel(entry.status, isEn)} + +
+ {entry.updated_at && entry.updated_at !== entry.created_at && ( +
+ {isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)} +
+ )} +
+ ))} +
+ )} +
+ )} + + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/feedbackStatusNotification.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/feedbackStatusNotification.test.ts new file mode 100644 index 00000000..fd2ebefb --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/feedbackStatusNotification.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/feedbackWorkflow.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/feedbackWorkflow.test.ts index 9ca95d0f..4a73f3d6 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/feedbackWorkflow.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/feedbackWorkflow.test.ts @@ -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(", +) { + return [ + Number(entry.id || 0), + String(entry.status || "open").toLowerCase(), + String(entry.updated_at || entry.created_at || ""), + ].join(":"); +} + +export function countUnseenFeedbackUpdates( + entries: Array< + Pick + >, + seenKeys: Set, +) { + return entries.reduce((count, entry) => { + return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1); + }, 0); +} diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 97bd4a9f..7bba6bfb 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -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: diff --git a/tests/test_user_feedback.py b/tests/test_user_feedback.py index e0093c7c..0ed99da4 100644 --- a/tests/test_user_feedback.py +++ b/tests/test_user_feedback.py @@ -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 diff --git a/web/routers/feedback.py b/web/routers/feedback.py index a189f44b..e2ec8f3b 100644 --- a/web/routers/feedback.py +++ b/web/routers/feedback.py @@ -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) diff --git a/web/services/feedback_api.py b/web/services/feedback_api.py index 92af363b..90e1b058 100644 --- a/web/services/feedback_api.py +++ b/web/services/feedback_api.py @@ -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, + }