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