wacrm/src/app/api/ai/knowledge/[id]/route.ts
Arnas Donauskas 020dc2ae11 fix(ai-kb): address code-review findings
- Security: REVOKE ALL FROM PUBLIC + GRANT EXECUTE (authenticated,
  service_role) on the match RPCs so anon can't read a KB by supplying
  an account_id (mirrors migrations 018/025).
- Ingest: store chunks even when embedding fails (insert first, rethrow
  after) so a doc stays lexically searchable; the "lexical still works"
  warning is now truthful.
- Retrieval: short-circuit on an empty KB (cheap COUNT) to avoid a paid
  query embedding + 2 RPCs on every draft/auto-reply.
- Migration: HNSW instead of IVFFlat (robust on small/growing tables);
  language-neutral 'simple' FTS config (was 'english'); semantic RPC
  takes text and casts ::vector(1536) to remove PostgREST bind ambiguity.
- Embeddings key: distinguish corrupt-from-absent (loadEmbeddingsKey
  returns {key,corrupt}); log on decrypt failure; reindex now reports
  failure instead of false success; POST/PATCH warn on keyword-only save.
- embedTexts: fail loud when the provider omits result indices (was
  silently misaligning chunks with vectors).

Tests: +3 (empty-KB guard, embed-failure-still-indexes, missing-index).
587 pass; typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:20:38 +03:00

133 lines
4.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
type Params = { params: Promise<{ id: string }> }
/**
* GET /api/ai/knowledge/[id] — full document (any member).
*/
export async function GET(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await getCurrentAccount()
const { id } = await params
const { data, error } = await supabase
.from('ai_knowledge_documents')
.select('id, title, content, updated_at')
.eq('account_id', accountId)
.eq('id', id)
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] GET] error:', error)
return NextResponse.json({ error: 'Failed to load document' }, { status: 500 })
}
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(data)
} catch (err) {
return toErrorResponse(err)
}
}
/**
* PATCH /api/ai/knowledge/[id] (admin+) — update title/content and
* re-index when the content changed.
*/
export async function PATCH(request: Request, { params }: Params) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const { id } = await params
const body = await request.json().catch(() => null)
const title = typeof body?.title === 'string' ? body.title.trim() : undefined
const content = typeof body?.content === 'string' ? body.content.trim() : undefined
if (title === undefined && content === undefined) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
if (title !== undefined && !title) {
return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 })
}
if (content !== undefined && !content) {
return NextResponse.json({ error: 'content cannot be empty' }, { status: 400 })
}
const update: Record<string, string> = {}
if (title !== undefined) update.title = title
if (content !== undefined) update.content = content
const { data: updated, error } = await supabase
.from('ai_knowledge_documents')
.update(update)
.eq('account_id', accountId)
.eq('id', id)
.select('id')
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] PATCH] error:', error)
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
}
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (content !== undefined) {
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
try {
await ingestDocument(supabase, accountId, { embeddingsApiKey }, id, content)
} catch (err) {
const message = err instanceof AiError ? err.message : 'indexing failed'
console.error('[ai/knowledge/[id] PATCH] ingest error:', err)
return NextResponse.json(
{
success: true,
warning: `Updated, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
},
{ status: 200 },
)
}
if (corrupt) {
return NextResponse.json({
success: true,
warning:
'Updated with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
})
}
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* DELETE /api/ai/knowledge/[id] (admin+) — chunks cascade.
*/
export async function DELETE(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await requireRole('admin')
const { id } = await params
const { error } = await supabase
.from('ai_knowledge_documents')
.delete()
.eq('account_id', accountId)
.eq('id', id)
if (error) {
console.error('[ai/knowledge/[id] DELETE] error:', error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}