alter table public.redemption_codes add column if not exists revoked_at timestamptz, add column if not exists revoked_by uuid; alter table public.redemption_codes drop constraint if exists redemption_codes_redeemed_or_revoked_check; alter table public.redemption_codes add constraint redemption_codes_redeemed_or_revoked_check check ( not (redeemed_at is not null and revoked_at is not null) and ((revoked_by is null and revoked_at is null) or (revoked_by is not null and revoked_at is not null)) ); create index if not exists redemption_codes_revoked_at_idx on public.redemption_codes (revoked_at) where revoked_at is not null; create schema if not exists audit; revoke all on schema audit from public, anon, authenticated; grant usage on schema audit to service_role; create table if not exists audit.admin_audit_logs ( id uuid primary key default gen_random_uuid(), actor_user_id uuid not null, actor_email text not null check (actor_email = lower(btrim(actor_email)) and char_length(actor_email) between 3 and 320), actor_role text not null check (actor_role in ('admin', 'viewer')), action text not null check (action in ('redemption_code.create', 'redemption_code.update', 'redemption_code.revoke')), target_type text not null check (target_type = 'redemption_code'), target_id uuid not null, before_value jsonb, after_value jsonb, request_id text not null check (char_length(request_id) between 1 and 200), created_at timestamptz not null default clock_timestamp(), check (before_value is null or not (before_value ?| array['code', 'code_hash', 'token', 'secret', 'key'])), check (after_value is null or not (after_value ?| array['code', 'code_hash', 'token', 'secret', 'key'])), unique (actor_user_id, request_id, action, target_id) ); create index if not exists admin_audit_logs_created_at_idx on audit.admin_audit_logs (created_at desc); create index if not exists admin_audit_logs_actor_idx on audit.admin_audit_logs (actor_user_id, created_at desc); alter table audit.admin_audit_logs enable row level security; revoke all on table audit.admin_audit_logs from public, anon, authenticated, service_role; grant select on table audit.admin_audit_logs to service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'admin_runtime') then grant usage on schema audit to admin_runtime; grant select on table audit.admin_audit_logs to admin_runtime; grant select (id, email, credits, name, birth_date, birth_time_status, birth_place_label) on table public.profiles to admin_runtime; grant select (id, code_mask, credits, expires_at, note, created_at, redeemed_by, redeemed_email, redeemed_at, revoked_by, revoked_at) on table public.redemption_codes to admin_runtime; grant select (id, user_id, transaction_type, amount, balance_after, request_id, model, input_tokens, output_tokens, created_at) on table public.credit_transactions to admin_runtime; grant select (user_id, request_id, status, created_at, updated_at) on table public.consultation_requests to admin_runtime; drop policy if exists profiles_admin_read on public.profiles; create policy profiles_admin_read on public.profiles for select to admin_runtime using (true); drop policy if exists redemption_codes_admin_read on public.redemption_codes; create policy redemption_codes_admin_read on public.redemption_codes for select to admin_runtime using (true); drop policy if exists credit_transactions_admin_read on public.credit_transactions; create policy credit_transactions_admin_read on public.credit_transactions for select to admin_runtime using (true); drop policy if exists consultation_requests_admin_read on public.consultation_requests; create policy consultation_requests_admin_read on public.consultation_requests for select to admin_runtime using (true); drop policy if exists admin_audit_logs_admin_read on audit.admin_audit_logs; create policy admin_audit_logs_admin_read on audit.admin_audit_logs for select to admin_runtime using (true); end if; end; $$; create or replace function public.reject_admin_audit_mutation() returns trigger language plpgsql set search_path = '' as $$ begin raise exception 'admin audit logs are append-only' using errcode = '55000'; end; $$; revoke all on function public.reject_admin_audit_mutation() from public, anon, authenticated, service_role; drop trigger if exists admin_audit_logs_append_only on audit.admin_audit_logs; create trigger admin_audit_logs_append_only before update or delete on audit.admin_audit_logs for each row execute function public.reject_admin_audit_mutation(); create or replace function public.admin_redemption_code_snapshot(p_code public.redemption_codes) returns jsonb language sql stable set search_path = '' as $$ select jsonb_build_object( 'id', p_code.id, 'mask', p_code.code_mask, 'credits', p_code.credits, 'expiresAt', p_code.expires_at, 'note', p_code.note, 'status', case when p_code.redeemed_at is not null then 'redeemed' when p_code.revoked_at is not null then 'revoked' when p_code.expires_at is not null and p_code.expires_at <= now() then 'expired' else 'available' end, 'redeemedAt', p_code.redeemed_at, 'revokedAt', p_code.revoked_at ) $$; revoke all on function public.admin_redemption_code_snapshot(public.redemption_codes) from public, anon, authenticated, service_role; create or replace function public.admin_verified_actor_email( p_actor_user_id uuid, p_actor_email text, p_actor_role text ) returns text language plpgsql security definer set search_path = '' as $$ declare v_email text; v_role text; v_banned boolean; v_ban_expires timestamptz; begin select lower(btrim(u.email)), u.role, u.banned, u.ban_expires into v_email, v_role, v_banned, v_ban_expires from identity.users u where u.id = p_actor_user_id; if not found or p_actor_role <> 'admin' or v_role is null or not ('admin' = any(string_to_array(replace(v_role, ' ', ''), ','))) or v_email is distinct from lower(btrim(p_actor_email)) or (v_banned and (v_ban_expires is null or v_ban_expires > now())) then raise exception 'administrator access required' using errcode = '42501'; end if; return v_email; end; $$; revoke all on function public.admin_verified_actor_email(uuid, text, text) from public, anon, authenticated, service_role; create or replace function public.admin_create_redemption_codes( p_actor_user_id uuid, p_actor_email text, p_actor_role text, p_request_id text, p_codes jsonb ) returns table ( id uuid, code_mask text, credits integer, expires_at timestamptz, note text, created_at timestamptz, redeemed_by uuid, redeemed_email text, redeemed_at timestamptz, revoked_by uuid, revoked_at timestamptz ) language plpgsql security definer set search_path = '' as $$ declare v_item jsonb; v_code public.redemption_codes%rowtype; v_email text := lower(btrim(p_actor_email)); v_request_id text := btrim(p_request_id); begin v_email := public.admin_verified_actor_email( p_actor_user_id, p_actor_email, p_actor_role ); if v_request_id is null or char_length(v_request_id) not between 1 and 200 then raise exception 'invalid request id' using errcode = '22023'; end if; if p_codes is null or jsonb_typeof(p_codes) is distinct from 'array' or jsonb_array_length(p_codes) not between 1 and 100 then raise exception 'codes must contain between 1 and 100 items' using errcode = '22023'; end if; for v_item in select value from jsonb_array_elements(p_codes) loop insert into public.redemption_codes ( code_hash, code_mask, credits, expires_at, note, created_by ) values ( v_item ->> 'codeHash', v_item ->> 'codeMask', (v_item ->> 'credits')::integer, nullif(v_item ->> 'expiresAt', '')::timestamptz, nullif(btrim(v_item ->> 'note'), ''), p_actor_user_id ) returning * into v_code; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id ) values ( p_actor_user_id, v_email, p_actor_role, 'redemption_code.create', 'redemption_code', v_code.id, null, public.admin_redemption_code_snapshot(v_code), v_request_id ); id := v_code.id; code_mask := v_code.code_mask; credits := v_code.credits; expires_at := v_code.expires_at; note := v_code.note; created_at := v_code.created_at; redeemed_by := v_code.redeemed_by; redeemed_email := v_code.redeemed_email; redeemed_at := v_code.redeemed_at; revoked_by := v_code.revoked_by; revoked_at := v_code.revoked_at; return next; end loop; end; $$; create or replace function public.admin_update_redemption_code( p_actor_user_id uuid, p_actor_email text, p_actor_role text, p_request_id text, p_code_id uuid, p_set_note boolean, p_note text, p_set_expires_at boolean, p_expires_at timestamptz ) returns table ( id uuid, code_mask text, credits integer, expires_at timestamptz, note text, created_at timestamptz, redeemed_by uuid, redeemed_email text, redeemed_at timestamptz, revoked_by uuid, revoked_at timestamptz ) language plpgsql security definer set search_path = '' as $$ declare v_before public.redemption_codes%rowtype; v_after public.redemption_codes%rowtype; v_email text := lower(btrim(p_actor_email)); begin v_email := public.admin_verified_actor_email( p_actor_user_id, p_actor_email, p_actor_role ); if p_request_id is null or char_length(btrim(p_request_id)) not between 1 and 200 then raise exception 'invalid request id' using errcode = '22023'; end if; if not coalesce(p_set_note, false) and not coalesce(p_set_expires_at, false) then raise exception 'no editable field supplied' using errcode = '22023'; end if; if p_set_note and p_note is not null and char_length(p_note) > 500 then raise exception 'note is too long' using errcode = '22023'; end if; select rc.* into v_before from public.redemption_codes rc where rc.id = p_code_id for update; if not found then raise exception 'redemption code not found' using errcode = 'P0002'; end if; if v_before.redeemed_at is not null then raise exception 'redeemed codes are immutable' using errcode = '55000'; end if; if v_before.revoked_at is not null then raise exception 'revoked codes are immutable' using errcode = '55000'; end if; update public.redemption_codes rc set note = case when p_set_note then nullif(btrim(p_note), '') else rc.note end, expires_at = case when p_set_expires_at then p_expires_at else rc.expires_at end where rc.id = p_code_id returning * into v_after; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id ) values ( p_actor_user_id, v_email, p_actor_role, 'redemption_code.update', 'redemption_code', v_after.id, public.admin_redemption_code_snapshot(v_before), public.admin_redemption_code_snapshot(v_after), btrim(p_request_id) ); return query select v_after.id, v_after.code_mask, v_after.credits, v_after.expires_at, v_after.note, v_after.created_at, v_after.redeemed_by, v_after.redeemed_email, v_after.redeemed_at, v_after.revoked_by, v_after.revoked_at; end; $$; create or replace function public.admin_revoke_redemption_code( p_actor_user_id uuid, p_actor_email text, p_actor_role text, p_request_id text, p_code_id uuid ) returns table ( id uuid, code_mask text, credits integer, expires_at timestamptz, note text, created_at timestamptz, redeemed_by uuid, redeemed_email text, redeemed_at timestamptz, revoked_by uuid, revoked_at timestamptz ) language plpgsql security definer set search_path = '' as $$ declare v_before public.redemption_codes%rowtype; v_after public.redemption_codes%rowtype; v_email text := lower(btrim(p_actor_email)); begin v_email := public.admin_verified_actor_email( p_actor_user_id, p_actor_email, p_actor_role ); if p_request_id is null or char_length(btrim(p_request_id)) not between 1 and 200 then raise exception 'invalid request id' using errcode = '22023'; end if; select rc.* into v_before from public.redemption_codes rc where rc.id = p_code_id for update; if not found then raise exception 'redemption code not found' using errcode = 'P0002'; end if; if v_before.redeemed_at is not null then raise exception 'redeemed codes cannot be revoked' using errcode = '55000'; end if; if v_before.revoked_at is not null then raise exception 'redemption code is already revoked' using errcode = '55000'; end if; update public.redemption_codes rc set revoked_at = clock_timestamp(), revoked_by = p_actor_user_id where rc.id = p_code_id returning * into v_after; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id ) values ( p_actor_user_id, v_email, p_actor_role, 'redemption_code.revoke', 'redemption_code', v_after.id, public.admin_redemption_code_snapshot(v_before), public.admin_redemption_code_snapshot(v_after), btrim(p_request_id) ); return query select v_after.id, v_after.code_mask, v_after.credits, v_after.expires_at, v_after.note, v_after.created_at, v_after.redeemed_by, v_after.redeemed_email, v_after.redeemed_at, v_after.revoked_by, v_after.revoked_at; end; $$; create or replace function public.redeem_code(p_code_hash text) returns table (success boolean, credits integer, error_code text) language plpgsql security definer set search_path = public, pg_temp as $$ declare v_user_id uuid := auth.uid(); v_email text := auth.jwt() ->> 'email'; v_code public.redemption_codes%rowtype; v_balance integer; begin if v_user_id is null then return query select false, null::integer, 'unauthorized'::text; return; end if; if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then return query select false, null::integer, 'invalid_code'::text; return; end if; select rc.* into v_code from public.redemption_codes rc where rc.code_hash = p_code_hash for update; if not found then return query select false, null::integer, 'invalid_code'::text; return; end if; if v_code.redeemed_by is not null then return query select false, null::integer, 'already_redeemed'::text; return; end if; if v_code.revoked_at is not null then return query select false, null::integer, 'revoked_code'::text; return; end if; if v_code.expires_at is not null and v_code.expires_at <= now() then return query select false, null::integer, 'expired_code'::text; return; end if; select p.credits into v_balance from public.profiles p where p.id = v_user_id for update; if not found then return query select false, null::integer, 'profile_missing'::text; return; end if; update public.redemption_codes rc set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now() where rc.id = v_code.id; update public.profiles p set credits = p.credits + v_code.credits, updated_at = now() where p.id = v_user_id returning p.credits into v_balance; insert into public.credit_transactions ( user_id, transaction_type, amount, balance_after, request_id, redemption_code_id ) values ( v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id ); return query select true, v_balance, null::text; end; $$; revoke all on function public.admin_create_redemption_codes(uuid, text, text, text, jsonb) from public, anon, authenticated; revoke all on function public.admin_update_redemption_code(uuid, text, text, text, uuid, boolean, text, boolean, timestamptz) from public, anon, authenticated; revoke all on function public.admin_revoke_redemption_code(uuid, text, text, text, uuid) from public, anon, authenticated; grant execute on function public.admin_create_redemption_codes(uuid, text, text, text, jsonb) to service_role; grant execute on function public.admin_update_redemption_code(uuid, text, text, text, uuid, boolean, text, boolean, timestamptz) to service_role; grant execute on function public.admin_revoke_redemption_code(uuid, text, text, text, uuid) to service_role; revoke all on function public.redeem_code(text) from public, anon; grant execute on function public.redeem_code(text) to authenticated;