Files
Jyotisha/frontend/supabase/migrations/20260930030000_user_feedback.sql
T

299 lines
12 KiB
PL/PgSQL

-- 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;