-- In-app feedback / complaints and persisted reply ratings -- (docs/tasks/PROGRESS-feedback-complaints-20260930.md, compliance round 2026-09-30). -- -- user_feedback: one row per submission from the account menu's 「反馈与投诉」. -- Holds the user's own words, an optional contact, and at most a session id -- when the user ticks 「附上当前对话」 — never message text. Admins handle rows -- through permission-checked functions; there is no direct table privilege. -- reply_ratings: the 👍 / 👎 on one assistant reply (「回复评价」 in CONTEXT.md), -- keyed by session + message position, with a short hash of the reply text -- so a rating is not carried over onto a regenerated answer. -- -- Access: -- * both tables enable RLS and grant no table privilege to any runtime role; -- * the web server writes/reads through SECURITY DEFINER functions executable -- by service_role only; each checks that the session belongs to the user; -- * the admin console reads and updates feedback through SECURITY DEFINER -- functions executable by admin_runtime, gated by new permissions -- support.feedback.read / support.feedback.write, and every update writes -- audit.admin_audit_logs. -- Rows cascade with the account (auth.users) and with the session. -- -- Backward compatibility: additive only (two tables, functions, permission -- rows and role grants). Nothing existing changes. begin; create table if not exists public.user_feedback ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, feedback_type text not null check (feedback_type in ('problem', 'content_report', 'refund', 'other')), body text not null check (char_length(btrim(body)) between 10 and 2000), contact text check (contact is null or char_length(contact) between 1 and 200), session_id uuid references public.chat_sessions(id) on delete set null, status text not null default 'new' check (status in ('new', 'in_progress', 'resolved', 'rejected')), admin_note text check (admin_note is null or char_length(admin_note) <= 2000), handled_by uuid, created_at timestamptz not null default clock_timestamp(), updated_at timestamptz not null default clock_timestamp() ); create index if not exists user_feedback_user_created_idx on public.user_feedback (user_id, created_at desc); create index if not exists user_feedback_status_created_idx on public.user_feedback (status, created_at desc); create table if not exists public.reply_ratings ( user_id uuid not null references auth.users(id) on delete cascade, session_id uuid not null references public.chat_sessions(id) on delete cascade, message_index integer not null check (message_index between 0 and 100000), rating text not null check (rating in ('up', 'down')), answer_sha256 text not null check (answer_sha256 ~ '^[a-f0-9]{16,64}$'), created_at timestamptz not null default clock_timestamp(), updated_at timestamptz not null default clock_timestamp(), primary key (user_id, session_id, message_index) ); alter table public.user_feedback enable row level security; alter table public.reply_ratings enable row level security; revoke all on table public.user_feedback from public, anon, authenticated, service_role; revoke all on table public.reply_ratings from public, anon, authenticated, service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'app_runtime') then revoke all on table public.user_feedback from app_runtime; revoke all on table public.reply_ratings from app_runtime; end if; if exists (select 1 from pg_roles where rolname = 'admin_runtime') then revoke all on table public.user_feedback from admin_runtime; revoke all on table public.reply_ratings from admin_runtime; end if; end; $$; -- --------------------------------------------------------------------------- -- User side (service_role only) -- --------------------------------------------------------------------------- create or replace function public.submit_user_feedback( p_user_id uuid, p_type text, p_body text, p_contact text, p_session_id uuid ) returns uuid language plpgsql security definer set search_path = '' as $$ declare v_id uuid; v_recent integer; begin if p_user_id is null then raise exception 'feedback_user_required' using errcode = '22023'; end if; -- Five submissions per rolling hour per account. select count(*) into v_recent from public.user_feedback where user_id = p_user_id and created_at > clock_timestamp() - interval '1 hour'; if v_recent >= 5 then raise exception 'feedback_rate_limited' using errcode = 'P0001'; end if; if p_session_id is not null and not exists ( select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id ) then raise exception 'feedback_session_not_owned' using errcode = '42501'; end if; insert into public.user_feedback (user_id, feedback_type, body, contact, session_id) values (p_user_id, p_type, btrim(p_body), nullif(btrim(coalesce(p_contact, '')), ''), p_session_id) returning id into v_id; return v_id; end; $$; create or replace function public.set_reply_rating( p_user_id uuid, p_session_id uuid, p_message_index integer, p_rating text, p_answer_sha256 text ) returns boolean language plpgsql security definer set search_path = '' as $$ begin if not exists ( select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id ) then raise exception 'rating_session_not_owned' using errcode = '42501'; end if; if p_rating is null then delete from public.reply_ratings where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; return true; end if; insert into public.reply_ratings (user_id, session_id, message_index, rating, answer_sha256) values (p_user_id, p_session_id, p_message_index, p_rating, p_answer_sha256) on conflict (user_id, session_id, message_index) do update set rating = excluded.rating, answer_sha256 = excluded.answer_sha256, updated_at = clock_timestamp(); return true; end; $$; create or replace function public.list_reply_ratings(p_user_id uuid, p_session_id uuid) returns table (message_index integer, rating text, answer_sha256 text) language sql stable security definer set search_path = '' as $$ select r.message_index, r.rating, r.answer_sha256 from public.reply_ratings r where r.user_id = p_user_id and r.session_id = p_session_id order by r.message_index; $$; -- --------------------------------------------------------------------------- -- Admin side (admin_runtime only, permission-checked, audited) -- --------------------------------------------------------------------------- insert into public.admin_permissions (permission_key, description) values ('support.feedback.read', '查看用户反馈与投诉'), ('support.feedback.write', '处理用户反馈与投诉') on conflict (permission_key) do update set description = excluded.description; with role_grants(role_code, permission_key) as (values ('owner', 'support.feedback.read'), ('owner', 'support.feedback.write'), ('operations', 'support.feedback.read'), ('operations', 'support.feedback.write'), ('support', 'support.feedback.read'), ('support', 'support.feedback.write'), ('auditor', 'support.feedback.read') ) insert into public.admin_role_permissions (role_id, permission_id) select r.id, p.id from role_grants g join public.admin_roles r on r.code = g.role_code join public.admin_permissions p on p.permission_key = g.permission_key on conflict do nothing; create or replace function public.admin_list_user_feedback( p_actor_user_id uuid, p_type text, p_status text, p_query text, p_limit integer, p_offset integer ) returns table ( id uuid, user_id uuid, email text, feedback_type text, body text, contact text, session_id uuid, status text, admin_note text, handled_by uuid, created_at timestamptz, updated_at timestamptz, total_count bigint ) language plpgsql stable security definer set search_path = '' as $$ begin if not public.admin_has_permission(p_actor_user_id, 'support.feedback.read') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; return query select f.id, f.user_id, u.email, f.feedback_type, f.body, f.contact, f.session_id, f.status, f.admin_note, f.handled_by, f.created_at, f.updated_at, count(*) over() as total_count from public.user_feedback f left join identity.users u on u.id = f.user_id where (p_type is null or f.feedback_type = p_type) and (p_status is null or f.status = p_status) and (p_query is null or f.body ilike p_query or u.email ilike p_query or f.user_id::text ilike p_query) order by (f.feedback_type = 'content_report' and f.status = 'new') desc, f.created_at desc limit greatest(1, least(coalesce(p_limit, 20), 100)) offset greatest(0, coalesce(p_offset, 0)); end; $$; create or replace function public.admin_update_user_feedback( p_actor_user_id uuid, p_feedback_id uuid, p_status text, p_admin_note text, p_request_id text ) returns table (id uuid, status text, admin_note text, handled_by uuid, updated_at timestamptz) language plpgsql security definer set search_path = '' as $$ declare v_actor_email text; v_before jsonb; begin if not public.admin_has_permission(p_actor_user_id, 'support.feedback.write') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; if p_status not in ('new', 'in_progress', 'resolved', 'rejected') then raise exception 'feedback_status_invalid' using errcode = '22023'; end if; if p_admin_note is not null and char_length(p_admin_note) > 2000 then raise exception 'feedback_note_too_long' using errcode = '22023'; end if; select jsonb_build_object('status', f.status, 'admin_note', f.admin_note) into v_before from public.user_feedback f where f.id = p_feedback_id for update; if v_before is null then raise exception 'feedback_not_found' using errcode = '22023'; end if; select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; update public.user_feedback f set status = p_status, admin_note = nullif(btrim(coalesce(p_admin_note, '')), ''), handled_by = p_actor_user_id, updated_at = clock_timestamp() where f.id = p_feedback_id; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id, permission_used, reason ) values ( p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', 'user_feedback.update', 'user_feedback', p_feedback_id, v_before, jsonb_build_object('status', p_status), p_request_id, 'support.feedback.write', 'feedback handling' ) on conflict do nothing; return query select f.id, f.status, f.admin_note, f.handled_by, f.updated_at from public.user_feedback f where f.id = p_feedback_id; end; $$; revoke all on function public.submit_user_feedback(uuid, text, text, text, uuid) from public, anon, authenticated; revoke all on function public.set_reply_rating(uuid, uuid, integer, text, text) from public, anon, authenticated; revoke all on function public.list_reply_ratings(uuid, uuid) from public, anon, authenticated; revoke all on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) from public, anon, authenticated; revoke all on function public.admin_update_user_feedback(uuid, uuid, text, text, text) from public, anon, authenticated; grant execute on function public.submit_user_feedback(uuid, text, text, text, uuid) to service_role; grant execute on function public.set_reply_rating(uuid, uuid, integer, text, text) to service_role; grant execute on function public.list_reply_ratings(uuid, uuid) to service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'admin_runtime') then grant execute on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) to admin_runtime; grant execute on function public.admin_update_user_feedback(uuid, uuid, text, text, text) to admin_runtime; end if; end; $$; commit;