564 lines
21 KiB
PL/PgSQL
564 lines
21 KiB
PL/PgSQL
-- Authenticated content reporting for ephemeral AI output.
|
|
--
|
|
-- Generation receipts deliberately contain no prompt or generated text. A report
|
|
-- stores only the snapshot the reporter explicitly submits for moderation. All
|
|
-- tables remain service-only; user and moderator access crosses narrow RPCs.
|
|
|
|
BEGIN;
|
|
|
|
CREATE TABLE public.content_generation_receipts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
purpose text NOT NULL CHECK (purpose ~ '^[a-z][a-z0-9_]{2,39}$'),
|
|
model text NOT NULL CHECK (char_length(model) BETWEEN 1 AND 120),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
|
|
CHECK (expires_at > created_at)
|
|
);
|
|
|
|
COMMENT ON TABLE public.content_generation_receipts IS
|
|
'Service-issued proof that a user received an AI generation; never stores prompts or generated text.';
|
|
|
|
CREATE INDEX content_generation_receipts_expiry_idx
|
|
ON public.content_generation_receipts(expires_at);
|
|
CREATE INDEX content_generation_receipts_user_created_idx
|
|
ON public.content_generation_receipts(user_id, created_at DESC);
|
|
|
|
CREATE TABLE public.content_reports (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
reporter_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
idempotency_key uuid NOT NULL,
|
|
request_hash text NOT NULL CHECK (request_hash ~ '^[0-9a-f]{64}$'),
|
|
kind text NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_]{2,39}$'),
|
|
source_type text NOT NULL CHECK (source_type ~ '^[a-z][a-z0-9_]{2,39}$'),
|
|
source_id uuid NOT NULL,
|
|
generation_receipt_id uuid REFERENCES public.content_generation_receipts(id) ON DELETE SET NULL,
|
|
reason text NOT NULL CHECK (reason IN (
|
|
'harmful', 'sexual', 'hateful', 'violent', 'self_harm',
|
|
'misinformation', 'privacy', 'spam', 'other'
|
|
)),
|
|
reporter_comment text CHECK (
|
|
reporter_comment IS NULL
|
|
OR char_length(reporter_comment) BETWEEN 1 AND 500
|
|
),
|
|
reported_snapshot text NOT NULL CHECK (char_length(reported_snapshot) BETWEEN 1 AND 4000),
|
|
snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
|
|
status text NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending', 'reviewing', 'actioned', 'dismissed')),
|
|
resolution_code text CHECK (
|
|
resolution_code IS NULL
|
|
OR resolution_code IN ('dismiss', 'confirm_violation')
|
|
),
|
|
reviewed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
reviewed_at timestamptz,
|
|
evidence_expires_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CHECK (
|
|
(status IN ('pending', 'reviewing') AND resolution_code IS NULL)
|
|
OR (status = 'dismissed' AND resolution_code = 'dismiss')
|
|
OR (status = 'actioned' AND resolution_code = 'confirm_violation')
|
|
),
|
|
CHECK (
|
|
(status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL)
|
|
OR (status <> 'pending' AND reviewed_at IS NOT NULL)
|
|
),
|
|
CHECK (
|
|
(status IN ('pending', 'reviewing') AND evidence_expires_at IS NULL)
|
|
OR (status IN ('actioned', 'dismissed') AND evidence_expires_at > reviewed_at)
|
|
)
|
|
);
|
|
|
|
COMMENT ON COLUMN public.content_reports.reported_snapshot IS
|
|
'Reporter-selected evidence retained for moderation; handlers must never copy it into application logs.';
|
|
|
|
CREATE UNIQUE INDEX content_reports_reporter_idempotency_idx
|
|
ON public.content_reports(reporter_id, idempotency_key)
|
|
WHERE reporter_id IS NOT NULL;
|
|
CREATE UNIQUE INDEX content_reports_reporter_source_idx
|
|
ON public.content_reports(reporter_id, source_type, source_id)
|
|
WHERE reporter_id IS NOT NULL;
|
|
CREATE INDEX content_reports_moderation_queue_idx
|
|
ON public.content_reports(status, created_at, id);
|
|
CREATE INDEX content_reports_reporter_rate_idx
|
|
ON public.content_reports(reporter_id, created_at DESC)
|
|
WHERE reporter_id IS NOT NULL;
|
|
|
|
CREATE TABLE public.content_report_review_actions (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
report_id uuid NOT NULL REFERENCES public.content_reports(id) ON DELETE CASCADE,
|
|
actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
idempotency_key uuid NOT NULL,
|
|
request_hash text NOT NULL CHECK (request_hash ~ '^[0-9a-f]{64}$'),
|
|
action text NOT NULL CHECK (action IN ('begin_review', 'dismiss', 'confirm_violation')),
|
|
note text NOT NULL CHECK (char_length(note) BETWEEN 3 AND 1000),
|
|
before_status text NOT NULL,
|
|
after_status text NOT NULL,
|
|
response_data jsonb NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX content_report_review_action_idempotency_idx
|
|
ON public.content_report_review_actions(actor_id, idempotency_key)
|
|
WHERE actor_id IS NOT NULL;
|
|
CREATE INDEX content_report_review_actions_report_idx
|
|
ON public.content_report_review_actions(report_id, created_at DESC);
|
|
|
|
ALTER TABLE public.content_generation_receipts ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.content_reports ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.content_report_review_actions ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- No RLS policies are created. Even the reporter cannot read or mutate the
|
|
-- moderation ledger through PostgREST; the Edge Function uses service RPCs.
|
|
REVOKE ALL ON TABLE public.content_generation_receipts FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON TABLE public.content_reports FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON TABLE public.content_report_review_actions FROM PUBLIC, anon, authenticated;
|
|
GRANT ALL ON TABLE public.content_generation_receipts TO service_role;
|
|
GRANT ALL ON TABLE public.content_reports TO service_role;
|
|
GRANT ALL ON TABLE public.content_report_review_actions TO service_role;
|
|
|
|
CREATE OR REPLACE FUNCTION public.issue_content_generation_receipt_v1(
|
|
p_actor_id uuid,
|
|
p_purpose text,
|
|
p_model text
|
|
) RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
receipt_id uuid;
|
|
receipt_expiry timestamptz;
|
|
BEGIN
|
|
IF auth.role() <> 'service_role' THEN
|
|
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_actor_id IS NULL OR p_purpose IS NULL OR p_model IS NULL THEN
|
|
RAISE EXCEPTION 'generation_receipt_fields_required' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_purpose NOT IN ('talk_response', 'command_response', 'action_response') THEN
|
|
RAISE EXCEPTION 'invalid_generation_purpose' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF char_length(trim(p_model)) NOT BETWEEN 1 AND 120 THEN
|
|
RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
-- Protect the user foreign key from a concurrent account deletion until the
|
|
-- receipt has either been inserted or failed closed.
|
|
PERFORM 1 FROM auth.users WHERE id = p_actor_id FOR KEY SHARE;
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'generation_user_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
|
|
INSERT INTO public.content_generation_receipts(user_id, purpose, model)
|
|
VALUES (p_actor_id, p_purpose, trim(p_model))
|
|
RETURNING id, expires_at INTO receipt_id, receipt_expiry;
|
|
|
|
RETURN jsonb_build_object(
|
|
'generationId', receipt_id,
|
|
'expiresAt', receipt_expiry
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.issue_content_generation_receipt_v1(uuid, text, text)
|
|
FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.issue_content_generation_receipt_v1(uuid, text, text)
|
|
TO service_role;
|
|
|
|
CREATE OR REPLACE FUNCTION public.submit_content_report_v1(
|
|
p_actor_id uuid,
|
|
p_idempotency_key uuid,
|
|
p_kind text,
|
|
p_source_type text,
|
|
p_source_id uuid,
|
|
p_reason text,
|
|
p_comment text,
|
|
p_snapshot text
|
|
) RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth, extensions
|
|
AS $$
|
|
DECLARE
|
|
request_payload jsonb;
|
|
request_digest text;
|
|
existing_report public.content_reports%ROWTYPE;
|
|
receipt public.content_generation_receipts%ROWTYPE;
|
|
created_report public.content_reports%ROWTYPE;
|
|
normalized_comment text;
|
|
normalized_snapshot text;
|
|
BEGIN
|
|
IF auth.role() <> 'service_role' THEN
|
|
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_source_id IS NULL THEN
|
|
RAISE EXCEPTION 'content_report_identifiers_required' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_kind <> 'ai_output'
|
|
OR p_source_type NOT IN ('talk_response', 'command_response', 'action_response') THEN
|
|
RAISE EXCEPTION 'unsupported_content_report_source' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_reason NOT IN (
|
|
'harmful', 'sexual', 'hateful', 'violent', 'self_harm',
|
|
'misinformation', 'privacy', 'spam', 'other'
|
|
) THEN
|
|
RAISE EXCEPTION 'invalid_content_report_reason' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
normalized_comment := CASE WHEN p_comment IS NULL THEN NULL ELSE trim(p_comment) END;
|
|
normalized_snapshot := trim(coalesce(p_snapshot, ''));
|
|
IF normalized_comment IS NOT NULL
|
|
AND char_length(normalized_comment) NOT BETWEEN 1 AND 500 THEN
|
|
RAISE EXCEPTION 'invalid_content_report_comment' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF char_length(normalized_snapshot) NOT BETWEEN 1 AND 4000 THEN
|
|
RAISE EXCEPTION 'invalid_content_report_snapshot' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
request_payload := jsonb_build_object(
|
|
'kind', p_kind,
|
|
'source', jsonb_build_object('type', p_source_type, 'generationId', p_source_id),
|
|
'reason', p_reason,
|
|
'comment', normalized_comment,
|
|
'snapshot', normalized_snapshot
|
|
);
|
|
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
|
|
|
|
-- One actor lock covers idempotency, duplicate-source detection and both
|
|
-- rate windows. Concurrent calls cannot observe the same remaining slot.
|
|
PERFORM pg_advisory_xact_lock(
|
|
pg_catalog.hashtextextended('d3ro:content-report:' || p_actor_id::text, 0)
|
|
);
|
|
|
|
SELECT * INTO existing_report
|
|
FROM public.content_reports
|
|
WHERE reporter_id = p_actor_id AND idempotency_key = p_idempotency_key;
|
|
|
|
IF existing_report.id IS NOT NULL THEN
|
|
IF existing_report.request_hash <> request_digest THEN
|
|
RAISE EXCEPTION 'content_report_idempotency_conflict' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
RETURN jsonb_build_object(
|
|
'reportId', existing_report.id,
|
|
'status', 'submitted',
|
|
'idempotent', true,
|
|
'createdAt', existing_report.created_at
|
|
);
|
|
END IF;
|
|
|
|
-- Keep account deletion from invalidating the reporter foreign key between
|
|
-- source verification and the report insert. A deletion that wins first is
|
|
-- intentionally indistinguishable from any other missing source.
|
|
PERFORM 1 FROM auth.users WHERE id = p_actor_id FOR KEY SHARE;
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
|
|
SELECT * INTO receipt
|
|
FROM public.content_generation_receipts
|
|
WHERE id = p_source_id
|
|
AND user_id = p_actor_id
|
|
AND purpose = p_source_type
|
|
AND expires_at > now()
|
|
FOR KEY SHARE;
|
|
IF receipt.id IS NULL THEN
|
|
-- Missing, expired and cross-user receipts intentionally look identical.
|
|
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1 FROM public.content_reports
|
|
WHERE reporter_id = p_actor_id
|
|
AND source_type = p_source_type
|
|
AND source_id = p_source_id
|
|
) THEN
|
|
RAISE EXCEPTION 'content_report_source_already_reported' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
|
|
IF (
|
|
SELECT count(*) FROM public.content_reports
|
|
WHERE reporter_id = p_actor_id
|
|
AND created_at > now() - interval '1 hour'
|
|
) >= 10 OR (
|
|
SELECT count(*) FROM public.content_reports
|
|
WHERE reporter_id = p_actor_id
|
|
AND created_at > now() - interval '24 hours'
|
|
) >= 30 THEN
|
|
RAISE EXCEPTION 'content_report_rate_limited' USING ERRCODE = 'PT429';
|
|
END IF;
|
|
|
|
INSERT INTO public.content_reports(
|
|
reporter_id, idempotency_key, request_hash, kind, source_type, source_id,
|
|
generation_receipt_id, reason, reporter_comment, reported_snapshot,
|
|
snapshot_sha256
|
|
) VALUES (
|
|
p_actor_id, p_idempotency_key, request_digest, p_kind, p_source_type, p_source_id,
|
|
receipt.id, p_reason, normalized_comment, normalized_snapshot,
|
|
encode(extensions.digest(normalized_snapshot, 'sha256'), 'hex')
|
|
)
|
|
RETURNING * INTO created_report;
|
|
|
|
RETURN jsonb_build_object(
|
|
'reportId', created_report.id,
|
|
'status', 'submitted',
|
|
'idempotent', false,
|
|
'createdAt', created_report.created_at
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.submit_content_report_v1(
|
|
uuid, uuid, text, text, uuid, text, text, text
|
|
) FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.submit_content_report_v1(
|
|
uuid, uuid, text, text, uuid, text, text, text
|
|
) TO service_role;
|
|
|
|
CREATE OR REPLACE FUNCTION public.admin_list_content_reports_v1(
|
|
p_status text DEFAULT NULL,
|
|
p_limit integer DEFAULT 50,
|
|
p_before timestamptz DEFAULT NULL
|
|
) RETURNS TABLE (
|
|
report_id uuid,
|
|
reporter_id uuid,
|
|
kind text,
|
|
source_type text,
|
|
source_id uuid,
|
|
reason text,
|
|
reporter_comment text,
|
|
reported_snapshot text,
|
|
status text,
|
|
resolution_code text,
|
|
reviewed_by uuid,
|
|
reviewed_at timestamptz,
|
|
created_at timestamptz
|
|
)
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
v_actor_id uuid;
|
|
v_actor_role text;
|
|
BEGIN
|
|
v_actor_id := auth.uid();
|
|
SELECT profile.role INTO v_actor_role
|
|
FROM public.profiles AS profile
|
|
WHERE profile.id = v_actor_id;
|
|
IF v_actor_role IS NULL OR v_actor_role NOT IN ('manager', 'admin', 'super_admin') THEN
|
|
RAISE EXCEPTION 'content_report_manager_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_status IS NOT NULL
|
|
AND p_status NOT IN ('pending', 'reviewing', 'actioned', 'dismissed') THEN
|
|
RAISE EXCEPTION 'invalid_content_report_status' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_limit IS NULL OR p_limit NOT BETWEEN 1 AND 100 THEN
|
|
RAISE EXCEPTION 'invalid_content_report_limit' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
report.id,
|
|
report.reporter_id,
|
|
report.kind,
|
|
report.source_type,
|
|
report.source_id,
|
|
report.reason,
|
|
report.reporter_comment,
|
|
report.reported_snapshot,
|
|
report.status,
|
|
report.resolution_code,
|
|
report.reviewed_by,
|
|
report.reviewed_at,
|
|
report.created_at
|
|
FROM public.content_reports AS report
|
|
WHERE (p_status IS NULL OR report.status = p_status)
|
|
AND (p_before IS NULL OR report.created_at < p_before)
|
|
ORDER BY report.created_at DESC, report.id DESC
|
|
LIMIT p_limit;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.admin_list_content_reports_v1(text, integer, timestamptz)
|
|
FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.admin_list_content_reports_v1(text, integer, timestamptz)
|
|
TO authenticated;
|
|
|
|
CREATE OR REPLACE FUNCTION public.admin_act_on_content_report_v1(
|
|
p_report_id uuid,
|
|
p_idempotency_key uuid,
|
|
p_action text,
|
|
p_note text
|
|
) RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth, extensions
|
|
AS $$
|
|
DECLARE
|
|
v_actor_id uuid;
|
|
v_actor_role text;
|
|
report public.content_reports%ROWTYPE;
|
|
existing_action public.content_report_review_actions%ROWTYPE;
|
|
normalized_note text;
|
|
request_digest text;
|
|
next_status text;
|
|
next_resolution text;
|
|
result jsonb;
|
|
BEGIN
|
|
v_actor_id := auth.uid();
|
|
SELECT profile.role INTO v_actor_role
|
|
FROM public.profiles AS profile
|
|
WHERE profile.id = v_actor_id;
|
|
IF v_actor_role IS NULL OR v_actor_role NOT IN ('manager', 'admin', 'super_admin') THEN
|
|
RAISE EXCEPTION 'content_report_manager_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_report_id IS NULL OR p_idempotency_key IS NULL THEN
|
|
RAISE EXCEPTION 'content_report_action_identifiers_required' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_action NOT IN ('begin_review', 'dismiss', 'confirm_violation') THEN
|
|
RAISE EXCEPTION 'invalid_content_report_action' USING ERRCODE = '22023';
|
|
END IF;
|
|
normalized_note := trim(coalesce(p_note, ''));
|
|
IF char_length(normalized_note) NOT BETWEEN 3 AND 1000 THEN
|
|
RAISE EXCEPTION 'invalid_content_report_action_note' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
request_digest := encode(extensions.digest(jsonb_build_object(
|
|
'reportId', p_report_id,
|
|
'action', p_action,
|
|
'note', normalized_note
|
|
)::text, 'sha256'), 'hex');
|
|
|
|
PERFORM pg_advisory_xact_lock(
|
|
pg_catalog.hashtextextended('d3ro:content-report-review:' || v_actor_id::text, 0)
|
|
);
|
|
|
|
SELECT * INTO existing_action
|
|
FROM public.content_report_review_actions
|
|
WHERE actor_id = v_actor_id AND idempotency_key = p_idempotency_key;
|
|
IF existing_action.id IS NOT NULL THEN
|
|
IF existing_action.request_hash <> request_digest THEN
|
|
RAISE EXCEPTION 'content_report_action_idempotency_conflict' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
RETURN existing_action.response_data || jsonb_build_object('idempotent', true);
|
|
END IF;
|
|
|
|
SELECT * INTO report FROM public.content_reports
|
|
WHERE id = p_report_id FOR UPDATE;
|
|
IF report.id IS NULL THEN
|
|
RAISE EXCEPTION 'content_report_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
|
|
IF p_action = 'begin_review' THEN
|
|
IF report.status NOT IN ('pending', 'reviewing') THEN
|
|
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
next_status := 'reviewing';
|
|
next_resolution := NULL;
|
|
ELSIF p_action = 'dismiss' THEN
|
|
IF report.status NOT IN ('pending', 'reviewing') THEN
|
|
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
next_status := 'dismissed';
|
|
next_resolution := 'dismiss';
|
|
ELSE
|
|
IF report.status NOT IN ('pending', 'reviewing') THEN
|
|
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
|
|
END IF;
|
|
next_status := 'actioned';
|
|
next_resolution := 'confirm_violation';
|
|
END IF;
|
|
|
|
UPDATE public.content_reports
|
|
SET status = next_status,
|
|
resolution_code = next_resolution,
|
|
reviewed_by = v_actor_id,
|
|
reviewed_at = now(),
|
|
evidence_expires_at = CASE
|
|
WHEN next_status IN ('actioned', 'dismissed') THEN now() + interval '180 days'
|
|
ELSE NULL
|
|
END,
|
|
updated_at = now()
|
|
WHERE id = report.id
|
|
RETURNING reviewed_at INTO report.reviewed_at;
|
|
|
|
result := jsonb_build_object(
|
|
'reportId', report.id,
|
|
'status', next_status,
|
|
'action', p_action,
|
|
'reviewedAt', report.reviewed_at,
|
|
'idempotent', false
|
|
);
|
|
|
|
INSERT INTO public.content_report_review_actions(
|
|
report_id, actor_id, idempotency_key, request_hash, action, note,
|
|
before_status, after_status, response_data
|
|
) VALUES (
|
|
report.id, v_actor_id, p_idempotency_key, request_digest, p_action,
|
|
normalized_note, report.status, next_status, result
|
|
);
|
|
|
|
-- Audit metadata only: never duplicate the report snapshot into general logs.
|
|
INSERT INTO public.audit_log(
|
|
admin_id, action, target_type, target_id, before_data, after_data, memo
|
|
) VALUES (
|
|
v_actor_id,
|
|
'content_report.' || p_action,
|
|
'content_report',
|
|
report.id,
|
|
jsonb_build_object('status', report.status),
|
|
jsonb_build_object('status', next_status, 'resolution', next_resolution),
|
|
normalized_note
|
|
);
|
|
|
|
RETURN result;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.admin_act_on_content_report_v1(uuid, uuid, text, text)
|
|
FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.admin_act_on_content_report_v1(uuid, uuid, text, text)
|
|
TO authenticated;
|
|
|
|
CREATE OR REPLACE FUNCTION public.purge_expired_content_reporting_data_v1()
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
purged_reports integer;
|
|
purged_receipts integer;
|
|
BEGIN
|
|
IF auth.role() <> 'service_role' THEN
|
|
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Pending/reviewing evidence is never time-purged. Final moderation evidence
|
|
-- receives a fixed 180-day appeal/abuse-defense window when it is resolved.
|
|
DELETE FROM public.content_reports
|
|
WHERE status IN ('actioned', 'dismissed')
|
|
AND evidence_expires_at <= now();
|
|
GET DIAGNOSTICS purged_reports = ROW_COUNT;
|
|
|
|
-- Receipts never contain content. Once their reporting window ends they no
|
|
-- longer prove a live source and can be removed; linked reports retain their
|
|
-- source UUID while the foreign-key pointer is nulled.
|
|
DELETE FROM public.content_generation_receipts
|
|
WHERE expires_at <= now();
|
|
GET DIAGNOSTICS purged_receipts = ROW_COUNT;
|
|
|
|
RETURN jsonb_build_object(
|
|
'purgedReports', purged_reports,
|
|
'purgedReceipts', purged_receipts
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.purge_expired_content_reporting_data_v1()
|
|
FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.purge_expired_content_reporting_data_v1()
|
|
TO service_role;
|
|
|
|
COMMIT;
|