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:
co-authored by
Claude Opus 5.5
parent
8f58eccecf
commit
3c8123d912
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user