d3ro-voice/server/supabase/migrations/20260829000032_meeting_document_content_reporting.sql
2026-08-29 18:33:45 +09:00

180 lines
6.5 KiB
PL/PgSQL

-- Extend the authenticated AI-output report boundary to generated meeting
-- documents. Existing ephemeral generations continue to require a live receipt;
-- meeting documents instead require an owned document plus its immutable
-- generation audit row.
BEGIN;
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;
resolved_generation_receipt_id uuid;
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', 'meeting_document'
) 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;
IF p_source_type = 'meeting_document' THEN
-- A document is reportable only when it belongs to the actor and an
-- immutable generation audit row proves that the document came from the
-- server-side generation path. Manual or cross-user rows fail identically.
PERFORM 1
FROM public.meeting_documents AS document
INNER JOIN public.meeting_document_generation_audit AS generation
ON generation.document_id = document.id
AND generation.user_id = document.user_id
AND generation.meeting_id = document.meeting_id
WHERE document.id = p_source_id
AND document.user_id = p_actor_id
FOR KEY SHARE OF document, generation;
IF NOT FOUND THEN
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
resolved_generation_receipt_id := NULL;
ELSE
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;
resolved_generation_receipt_id := receipt.id;
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,
resolved_generation_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;
COMMIT;