d3ro-voice/server/supabase/functions/_shared/content-report-contract.ts
2026-08-29 18:33:45 +09:00

174 lines
5.4 KiB
TypeScript

export const CONTENT_REPORT_REASONS = [
'harmful',
'sexual',
'hateful',
'violent',
'self_harm',
'misinformation',
'privacy',
'spam',
'other',
] as const
export type ContentReportReason = typeof CONTENT_REPORT_REASONS[number]
export const CONTENT_REPORT_SOURCE_TYPES = [
'talk_response',
'command_response',
'action_response',
'meeting_document',
] as const
export type ContentReportSourceType = typeof CONTENT_REPORT_SOURCE_TYPES[number]
export interface ContentReportRequest {
kind: 'ai_output'
source: {
type: ContentReportSourceType
generationId: string
}
reason: ContentReportReason
comment?: string
snapshot: string
}
export interface ContentReportReceipt {
reportId: string
status: 'submitted'
idempotent: boolean
createdAt: string
}
export interface ContentReportRpcError {
status: number
code: string
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const REASON_SET = new Set<string>(CONTENT_REPORT_REASONS)
const SOURCE_TYPE_SET = new Set<string>(CONTENT_REPORT_SOURCE_TYPES)
export class ContentReportRequestError extends Error {
constructor(
readonly code: string,
readonly status: number = 400,
) {
super(code)
this.name = 'ContentReportRequestError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function codePointLength(value: string): number {
return Array.from(value).length
}
function assertExactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): void {
const allowed = new Set([...required, ...optional])
const keys = Object.keys(value)
if (
required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))
|| keys.some((key) => !allowed.has(key))
) {
throw new ContentReportRequestError('invalid_request')
}
}
function requireUuid(value: unknown): string {
if (typeof value !== 'string') throw new ContentReportRequestError('invalid_request')
const normalized = value.trim().toLowerCase()
if (!UUID_PATTERN.test(normalized)) throw new ContentReportRequestError('invalid_request')
return normalized
}
function requireBoundedText(value: unknown, minimum: number, maximum: number): string {
if (typeof value !== 'string') throw new ContentReportRequestError('invalid_request')
const normalized = value.trim()
const length = codePointLength(normalized)
if (length < minimum || length > maximum) {
throw new ContentReportRequestError('invalid_request')
}
return normalized
}
export function parseIdempotencyKey(value: string | null): string {
if (value === null) throw new ContentReportRequestError('idempotency_key_required')
return requireUuid(value)
}
export function parseContentReportRequest(value: unknown): ContentReportRequest {
if (!isRecord(value)) throw new ContentReportRequestError('invalid_request')
assertExactKeys(value, ['kind', 'source', 'reason', 'snapshot'], ['comment'])
if (value.kind !== 'ai_output' || !isRecord(value.source)) {
throw new ContentReportRequestError('invalid_request')
}
assertExactKeys(value.source, ['type', 'generationId'])
if (typeof value.source.type !== 'string' || !SOURCE_TYPE_SET.has(value.source.type)) {
throw new ContentReportRequestError('invalid_request')
}
if (typeof value.reason !== 'string' || !REASON_SET.has(value.reason)) {
throw new ContentReportRequestError('invalid_request')
}
const comment = value.comment === undefined
? undefined
: requireBoundedText(value.comment, 1, 500)
return {
kind: 'ai_output',
source: {
type: value.source.type as ContentReportSourceType,
generationId: requireUuid(value.source.generationId),
},
reason: value.reason as ContentReportReason,
...(comment === undefined ? {} : { comment }),
snapshot: requireBoundedText(value.snapshot, 1, 4_000),
}
}
export function parseContentReportReceipt(value: unknown): ContentReportReceipt {
if (
!isRecord(value)
|| typeof value.reportId !== 'string'
|| !UUID_PATTERN.test(value.reportId)
|| value.status !== 'submitted'
|| typeof value.idempotent !== 'boolean'
|| typeof value.createdAt !== 'string'
|| Number.isNaN(Date.parse(value.createdAt))
) {
throw new ContentReportRequestError('invalid_rpc_response', 500)
}
return {
reportId: value.reportId.toLowerCase(),
status: 'submitted',
idempotent: value.idempotent,
createdAt: value.createdAt,
}
}
export function mapContentReportRpcError(error: { message?: string; code?: string }): ContentReportRpcError {
const message = error.message ?? ''
if (message.includes('content_report_source_not_found')) {
return { status: 404, code: 'source_not_found' }
}
if (message.includes('content_report_rate_limited') || error.code === 'PT429') {
return { status: 429, code: 'report_rate_limited' }
}
if (message.includes('content_report_source_already_reported')) {
return { status: 409, code: 'source_already_reported' }
}
if (message.includes('content_report_idempotency_conflict') || error.code === 'PT409') {
return { status: 409, code: 'idempotency_conflict' }
}
if (error.code === '22023' || message.includes('invalid_content_report')) {
return { status: 400, code: 'invalid_request' }
}
return { status: 500, code: 'internal_error' }
}