feat(support): in-app feedback & complaints with admin handling; persist reply ratings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 09:42:11 +08:00
co-authored by Claude Opus 5.5
parent 8f58eccecf
commit 3c8123d912
24 changed files with 1244 additions and 1 deletions
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
import { FEEDBACK_STATUSES, parseFeedbackFilter } from "@/lib/user-feedback";
export const runtime = "nodejs";
/**
* 反馈与投诉 in the admin console (compliance round 2026-09-30). Reads and
* updates go only through admin_list_user_feedback / admin_update_user_feedback,
* which re-check support.feedback.* and audit every change. The list shows the
* user's own words and, if they attached one, a session id — never message text.
* `status` filter accepts a status or `type:<feedback type>`.
*/
const updateSchema = z.object({
id: z.string().uuid(),
status: z.enum(FEEDBACK_STATUSES),
adminNote: z.string().trim().max(2000).optional(),
});
type Row = {
id: string;
user_id: string;
email: string | null;
feedback_type: string;
body: string;
contact: string | null;
session_id: string | null;
status: string;
admin_note: string | null;
handled_by: string | null;
created_at: Date;
updated_at: Date;
total_count: string;
};
export async function GET(request: Request) {
try {
const session = await requirePermission("support.feedback.read");
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const filter = parseFeedbackFilter(parsed.data.status);
const rows = await queryAdminRows<Row>(
"select * from public.admin_list_user_feedback($1::uuid,$2::text,$3::text,$4::text,$5::integer,$6::integer)",
[
session.user.id,
filter.type,
filter.status,
parsed.data.q ? `%${parsed.data.q}%` : null,
parsed.data.pageSize,
pageOffset(parsed.data.page, parsed.data.pageSize),
],
);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
userId: row.user_id,
email: row.email,
type: row.feedback_type,
body: row.body,
contact: row.contact,
sessionId: row.session_id,
status: row.status,
adminNote: row.admin_note,
handledBy: row.handled_by,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const session = await requireAdminMutation(request, "support.feedback.write");
const parsed = updateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const rows = await queryAdminRows<{ id: string; status: string; admin_note: string | null; updated_at: Date }>(
"select * from public.admin_update_user_feedback($1::uuid,$2::uuid,$3::text,$4::text,$5::text)",
[session.user.id, parsed.data.id, parsed.data.status, parsed.data.adminNote ?? null, requestId(request)],
);
const row = rows[0];
return NextResponse.json({
data: row ? { id: row.id, status: row.status, adminNote: row.admin_note, updatedAt: row.updated_at.toISOString() } : null,
});
} catch (error) {
return adminErrorResponse(error);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { feedbackRpcErrorStatus, feedbackSubmissionSchema } from "@/lib/user-feedback";
export const runtime = "nodejs";
/**
* POST /api/feedback — 反馈与投诉 from the account menu (compliance round
* 2026-09-30). The write goes through submit_user_feedback(), which enforces
* the hourly limit and that an attached session belongs to the caller.
*/
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let service: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
service = createAdminSupabaseClient();
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/feedback");
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const parsed = feedbackSubmissionSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请选择类型,并写至少 10 个字的描述。" }, { status: 400 });
}
const { data, error } = await service.rpc("submit_user_feedback", {
p_user_id: user.id,
p_type: parsed.data.type,
p_body: parsed.data.body,
p_contact: parsed.data.contact ?? null,
p_session_id: parsed.data.sessionId ?? null,
});
if (error) {
const mapped = feedbackRpcErrorStatus(error.message);
if (mapped.status === 503) console.error("[feedback] submit failed", error.code ?? "unknown");
return NextResponse.json({ error: mapped.error }, { status: mapped.status });
}
return NextResponse.json({ id: typeof data === "string" ? data : null }, { status: 201 });
}
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
/**
* 回复评价 (CONTEXT.md): the 👍 / 👎 on one assistant reply, persisted per
* session + message position (compliance round 2026-09-30). Reads and writes
* go through list_reply_ratings() / set_reply_rating(), which check that the
* session belongs to the caller.
*/
const putSchema = z.object({
sessionId: z.string().uuid(),
messageIndex: z.number().int().min(0).max(100_000),
rating: z.enum(["up", "down"]).nullable(),
answerSha256: z.string().regex(/^[a-f0-9]{16,64}$/),
});
async function clients() {
const supabase = await createServerSupabaseClient();
const service = createAdminSupabaseClient();
return { supabase, service };
}
export async function GET(request: Request) {
let context: Awaited<ReturnType<typeof clients>>;
try {
context = await clients();
} catch (error) {
return jsonForSupabaseSetupFailure(error, "GET /api/reply-ratings");
}
const { data: { user } } = await context.supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
if (!z.string().uuid().safeParse(sessionId).success) {
return NextResponse.json({ error: "会话编号不正确" }, { status: 400 });
}
const { data, error } = await context.service.rpc("list_reply_ratings", { p_user_id: user.id, p_session_id: sessionId });
if (error) return NextResponse.json({ error: "暂时读不到评价" }, { status: 503 });
const rows = Array.isArray(data) ? data : [];
return NextResponse.json({
ratings: rows.flatMap((row: Record<string, unknown>) => (
typeof row.message_index === "number" && (row.rating === "up" || row.rating === "down") && typeof row.answer_sha256 === "string"
? [{ messageIndex: row.message_index, rating: row.rating, answerSha256: row.answer_sha256 }]
: []
)),
});
}
export async function PUT(request: Request) {
let context: Awaited<ReturnType<typeof clients>>;
try {
context = await clients();
} catch (error) {
return jsonForSupabaseSetupFailure(error, "PUT /api/reply-ratings");
}
const { data: { user } } = await context.supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const parsed = putSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "评价格式不正确" }, { status: 400 });
const { error } = await context.service.rpc("set_reply_rating", {
p_user_id: user.id,
p_session_id: parsed.data.sessionId,
p_message_index: parsed.data.messageIndex,
p_rating: parsed.data.rating,
p_answer_sha256: parsed.data.answerSha256,
});
if (error) {
const notOwned = error.message?.includes("rating_session_not_owned");
return NextResponse.json({ error: notOwned ? "会话不属于当前账号" : "暂时没能保存评价" }, { status: notOwned ? 403 : 503 });
}
return NextResponse.json({ saved: true });
}