180 lines
6.5 KiB
PL/PgSQL
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;
|