import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import { resolveTypeId } from './_shared/resolve-ids' import { create as adminCreate, update as adminUpdate } from './admin-data' // ─── INTERFACES ─────────────────────────────────────────────────────────────── const DEFAULT_GLOBS = ['**/*.ts', '**/*.tsx', '**/*.sql'] const DEFAULT_EXCLUDE_GLOBS = ['node_modules/**', '.git/**', 'dist/**', 'build/**', '.netlify/**', '**/*.min.js', '**/*.d.ts'] interface SourceSet { id: string name: string base_path: string globs?: string[] exclude_globs?: string[] layer?: string } interface CreateProjectRequest { name: string description?: string repo_path: string language_mix?: string globs?: string[] exclude_globs?: string[] source_sets?: SourceSet[] dependencies?: string[] } interface ProjectStats { file_count: number chunk_count: number proposal_count: number approved_count: number pending_review_count: number dead_code_count: number doc_verified_count: number doc_ai_count: number } // ─── HELPERS ────────────────────────────────────────────────────────────────── async function getProjectStats(projectId: string): Promise { const [filesResult, chunksResult, proposalsResult] = await Promise.all([ adminDb.from('items').select('id, data', { count: 'exact' }) .filter('data->>kb_type', 'eq', 'cil_file') .filter('data->>project_id', 'eq', projectId) .eq('is_active', true), adminDb.from('items').select('id, data', { count: 'exact' }) .filter('data->>kb_type', 'eq', 'cil_chunk') .filter('data->>project_id', 'eq', projectId) .eq('is_active', true), adminDb.from('items').select('id, data', { count: 'exact' }) .filter('data->>kb_type', 'eq', 'cil_chunk_proposal') .filter('data->>project_id', 'eq', projectId) .eq('is_active', true), ]) const chunks = chunksResult.data || [] const proposals = proposalsResult.data || [] return { file_count: filesResult.count || 0, chunk_count: chunksResult.count || 0, proposal_count: proposalsResult.count || 0, approved_count: chunks.filter(c => c.data?.review_status === 'approved').length, pending_review_count: proposals.filter(p => p.data?.review_status === 'pending_review').length, dead_code_count: chunks.filter(c => c.data?.is_dead_code === true).length, doc_verified_count: chunks.filter(c => c.data?.doc_confidence === 'human_verified').length, doc_ai_count: chunks.filter(c => c.data?.doc_confidence === 'ai_generated').length, } } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = ctx.query?.action const method = (ctx as any).method || (body ? 'POST' : 'GET') // ── LIST proposals for a project (server-filtered, no limit cap) ──────────── if (action === 'list_proposals') { const id = ctx.query?.id if (!id) throw new Error('id is required') const status = ctx.query?.status || 'pending_review' const limit = parseInt(ctx.query?.limit || '1000', 10) const proposalTypeId = await resolveTypeId('item', 'cil_chunk_proposal') const { data, error } = await adminDb .from('items') .select('id, title, data, created_at') .eq('type_id', proposalTypeId) .eq('is_active', true) .filter('data->>project_id', 'eq', id) .filter('data->>review_status', 'eq', status) .order('created_at', { ascending: true }) .limit(limit) if (error) throw new Error(`Failed to list proposals: ${error.message}`) return { proposals: data || [], total: data?.length || 0 } } // ── LIST projects ──────────────────────────────────────────────────────────── if (action === 'list' || (!action && method === 'GET' && !ctx.query?.id)) { const typeId = await resolveTypeId('item', 'cil_project') const { data, error } = await adminDb .from('items') .select('id, title, description, status, data, created_at, updated_at') .eq('type_id', typeId) .eq('is_active', true) .order('created_at', { ascending: false }) if (error) throw new Error(`Failed to list projects: ${error.message}`) return { projects: data || [] } } // ── GET single project with stats ──────────────────────────────────────────── if (action === 'get' || (method === 'GET' && ctx.query?.id)) { const id = ctx.query?.id if (!id) throw new Error('id is required') const { data, error } = await adminDb .from('items') .select('id, title, description, status, data, created_at, updated_at') .eq('id', id) .single() if (error || !data) throw new Error(`Project not found: ${id}`) const stats = await getProjectStats(id) return { project: { ...data, stats } } } // ── CREATE project ─────────────────────────────────────────────────────────── if (action === 'create' || (!action && method === 'POST')) { const req = body as CreateProjectRequest if (!req?.name) throw new Error('name is required') if (!req?.repo_path) throw new Error('repo_path is required') const result = await adminCreate(ctx, { entity: 'items', type_slug: 'cil_project', title: req.name, description: req.description || '', status: 'active', data: { kb_type: 'cil_project', repo_path: req.repo_path, language_mix: req.language_mix || '', ingestion_status: 'not_started', file_count: 0, chunk_count: 0, total_ingestion_cost_usd: 0, last_ingested_at: null, globs: req.globs || DEFAULT_GLOBS, exclude_globs: req.exclude_globs || DEFAULT_EXCLUDE_GLOBS, source_sets: req.source_sets || [], dependencies: req.dependencies || [], } }) return { project: result } } // ── UPDATE project ─────────────────────────────────────────────────────────── if (action === 'update' || (!action && method === 'PATCH')) { const id = ctx.query?.id || body?.id if (!id) throw new Error('id is required') // Fetch the current record so we can merge — never replace the whole data object const { data: current, error: fetchErr } = await adminDb .from('items').select('data').eq('id', id).maybeSingle() if (fetchErr || !current) throw new Error(`Project not found: ${id}`) const mergedData = { ...(current.data || {}), ...(body?.data || {}) } const updateCtx = { ...ctx, query: { ...ctx.query, entity: 'items', id } } const result = await adminUpdate(updateCtx, { data: mergedData, ...(body?.status ? { status: body.status } : {}), ...(body?.title ? { title: body.title } : {}), }) return { project: result } } // ── DELETE project — cascades all cil_file, cil_chunk, cil_chunk_proposal, cil_config // items and all CIL link types for this project ──────────────────────────── if (action === 'delete' || method === 'DELETE') { const id = ctx.query?.id || body?.id if (!id) throw new Error('id is required') // Verify the project exists and is a cil_project const { data: projectItem } = await adminDb .from('items') .select('id, title, data') .eq('id', id) .maybeSingle() if (!projectItem) throw new Error(`Project not found: ${id}`) const deletedCounts: Record = {} // 1. Collect all cil_file IDs for this project (needed to delete links) const { data: fileItems } = await adminDb .from('items') .select('id') .filter('data->>project_id', 'eq', id) .filter('data->>kb_type', 'eq', 'cil_file') const fileIds = (fileItems || []).map((f: any) => f.id) // 2. Collect all cil_chunk IDs for this project (needed to delete call links) const { data: chunkItems } = await adminDb .from('items') .select('id') .filter('data->>project_id', 'eq', id) .filter('data->>kb_type', 'eq', 'cil_chunk') const chunkIds = (chunkItems || []).map((c: any) => c.id) // 3. Delete all links where source or target is a file or chunk in this project if (fileIds.length > 0 || chunkIds.length > 0) { const allIds = [...fileIds, ...chunkIds] const CHUNK_SIZE = 50 let linksDeleted = 0 // Supabase .in() has a limit — batch in chunks for (let i = 0; i < allIds.length; i += CHUNK_SIZE) { const batch = allIds.slice(i, i + CHUNK_SIZE) const { count: sourceCount } = await adminDb .from('links') .delete({ count: 'exact' }) .in('source_id', batch) const { count: targetCount } = await adminDb .from('links') .delete({ count: 'exact' }) .in('target_id', batch) linksDeleted += (sourceCount || 0) + (targetCount || 0) } deletedCounts.links = linksDeleted } // 4. Delete all embeddings for cil_chunk items if (chunkIds.length > 0) { const CHUNK_SIZE = 50 let embeddingsDeleted = 0 for (let i = 0; i < chunkIds.length; i += CHUNK_SIZE) { const batch = chunkIds.slice(i, i + CHUNK_SIZE) const { count } = await adminDb .from('embeddings') .delete({ count: 'exact' }) .in('document_id', batch) embeddingsDeleted += count || 0 } deletedCounts.embeddings = embeddingsDeleted } // 5. Delete all cil_* item types scoped to this project for (const kbType of ['cil_chunk', 'cil_chunk_proposal', 'cil_file', 'cil_config']) { const { count } = await adminDb .from('items') .delete({ count: 'exact' }) .filter('data->>project_id', 'eq', id) .filter('data->>kb_type', 'eq', kbType) deletedCounts[kbType] = count || 0 } // 6. Delete the project item itself await adminDb.from('items').delete().eq('id', id) deletedCounts.project = 1 return { deleted: true, project_id: id, project_title: projectItem.title, deleted_counts: deletedCounts, } } throw new Error(`Unknown action: ${action}. Valid: list, get, create, update, delete`) })