* feat: multi-tenant company refactor (GNU-19) Introduce companies table, company_members, and user_preferences to support multiple companies per user. All data scoping changes from user_id to company_id across the entire codebase. Key changes: - Database migration: new tables, company_id on 40+ tables, backfill, RLS rewrite from user_id to company-member-based, updated RPCs - Types: Company, CompanyMember, CompanyRole, UserPreferences types; company_id added to all entity interfaces; companyId on all events - Engine: all 7 core functions take companyId; storno, period, year-end services updated; 16 report generators updated - Middleware: company context resolution (cookie → prefs → first company) - API routes: ~120 routes updated with requireCompanyId() - Frontend: CompanyProvider context, layout/dashboard/onboarding updated - Extensions: context factory, 9 extensions, all lib files updated - Tests: 1880 tests passing, all helpers updated with company_id defaults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add database migrations for multi-tenant company and team system (GNU-19) Adds company_invitations, company creation RPC, team_members, account deletion RPC, and teams table refactor migrations. Updates base multi-tenant migration with cascading FKs and onboarding_step column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team types and update core infrastructure for multi-tenancy (GNU-19) Adds TeamRole, MemberSource, and Team types. Refactors Supabase service client to be stateless, updates middleware for team-aware routing, extends CompanyContext with team/role fields, and updates extension service types to accept companyId. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through business logic functions (GNU-19) Replaces user_id scoping with company_id across all lib modules: bookkeeping, documents, transactions, invoices, reconciliation, tax, deadlines, and import. Updates corresponding tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through API routes and extensions (GNU-19) Updates all existing API routes to extract and pass companyId. Updates enable-banking and arcim-migration extensions for company-scoped transaction ingestion and sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add company and team management API routes (GNU-19) Adds CRUD endpoints for company members, company invitations, team members, and team invitations. Includes invite token utilities, email templates, and company switch server action. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team/company UI components, pages, and dashboard updates (GNU-19) Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company members and team management panels. Updates dashboard layout for team-aware routing, onboarding for multi-step role choice, and auth callback for team invite acceptance. Ignores supabase/.branches/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in import page (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move appUrl declaration to outer scope in invite route (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for second company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in extension components (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update tests to use companyId instead of userId and improve type handling --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
635 lines
18 KiB
TypeScript
635 lines
18 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
|
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
|
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
|
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
|
|
|
// Distributed rate limiting backed by Supabase extension_data table
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
interface RateLimitState {
|
|
count: number
|
|
window_start: number
|
|
}
|
|
|
|
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
|
|
const now = Date.now()
|
|
const WINDOW_MS = 60000
|
|
|
|
const { data } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', userId)
|
|
.eq('extension_id', 'ai-chat')
|
|
.eq('key', 'rate_limit')
|
|
.single()
|
|
|
|
const state = data?.value as RateLimitState | null
|
|
|
|
if (!state || now - state.window_start > WINDOW_MS) {
|
|
// New window
|
|
await supabase.from('extension_data').upsert(
|
|
{
|
|
user_id: userId,
|
|
extension_id: 'ai-chat',
|
|
key: 'rate_limit',
|
|
value: { count: 1, window_start: now },
|
|
},
|
|
{ onConflict: 'user_id,extension_id,key' }
|
|
)
|
|
return true
|
|
}
|
|
|
|
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
|
return false
|
|
}
|
|
|
|
// Increment count
|
|
await supabase.from('extension_data').upsert(
|
|
{
|
|
user_id: userId,
|
|
extension_id: 'ai-chat',
|
|
key: 'rate_limit',
|
|
value: { count: state.count + 1, window_start: state.window_start },
|
|
},
|
|
{ onConflict: 'user_id,extension_id,key' }
|
|
)
|
|
return true
|
|
}
|
|
|
|
// ============================================================
|
|
// POST / — Send a message and get a response
|
|
// ============================================================
|
|
|
|
async function handlePostChat(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
// Rate limiting
|
|
if (!await checkRateLimitDB(supabase, userId)) {
|
|
return NextResponse.json(
|
|
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
|
{ status: 429 }
|
|
)
|
|
}
|
|
|
|
try {
|
|
const body: ChatRequest = await request.json()
|
|
const { message, session_id } = body
|
|
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Message is required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
let sessionId = session_id
|
|
|
|
// Create new session if not provided
|
|
if (!sessionId) {
|
|
const { data: newSession, error: sessionError } = await supabase
|
|
.from('chat_sessions')
|
|
.insert({
|
|
user_id: userId,
|
|
title: message.slice(0, 100), // Use first 100 chars of message as title
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (sessionError) {
|
|
console.error('Error creating session:', sessionError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create chat session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
sessionId = newSession.id
|
|
} else {
|
|
// Verify session belongs to user
|
|
const { data: existingSession } = await supabase
|
|
.from('chat_sessions')
|
|
.select('id')
|
|
.eq('id', sessionId)
|
|
.eq('company_id', userId)
|
|
.single()
|
|
|
|
if (!existingSession) {
|
|
return NextResponse.json(
|
|
{ error: 'Session not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save user message
|
|
const { error: userMsgError } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'user',
|
|
content: message.trim(),
|
|
sources: [],
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (userMsgError) {
|
|
console.error('Error saving user message:', userMsgError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to save message' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// Get conversation history
|
|
const { data: history } = await supabase
|
|
.from('chat_messages')
|
|
.select('role, content')
|
|
.eq('session_id', sessionId)
|
|
.order('created_at', { ascending: true })
|
|
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
|
|
|
const conversationHistory = (history || []) as ChatMessage[]
|
|
|
|
// Generate AI response
|
|
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
|
|
|
|
// Save assistant message
|
|
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'assistant',
|
|
content: result.content,
|
|
sources: result.sources,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (assistantMsgError) {
|
|
console.error('Error saving assistant message:', assistantMsgError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to save response' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
message: assistantMessage,
|
|
session_id: sessionId,
|
|
})
|
|
} catch (err) {
|
|
console.error('Chat error:', err)
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /stream — Streaming chat response via Server-Sent Events
|
|
// ============================================================
|
|
|
|
async function handlePostStream(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
if (!await checkRateLimitDB(supabase, userId)) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
try {
|
|
const body: ChatRequest = await request.json()
|
|
const { message, session_id } = body
|
|
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Message is required' }),
|
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
let sessionId = session_id
|
|
|
|
// Create new session if not provided
|
|
if (!sessionId) {
|
|
const { data: newSession, error: sessionError } = await supabase
|
|
.from('chat_sessions')
|
|
.insert({
|
|
user_id: userId,
|
|
title: message.slice(0, 100),
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (sessionError) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Failed to create session' }),
|
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
sessionId = newSession.id
|
|
} else {
|
|
// Verify session belongs to user
|
|
const { data: existingSession } = await supabase
|
|
.from('chat_sessions')
|
|
.select('id')
|
|
.eq('id', sessionId)
|
|
.eq('company_id', userId)
|
|
.single()
|
|
|
|
if (!existingSession) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Session not found' }),
|
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save user message
|
|
await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'user',
|
|
content: message.trim(),
|
|
sources: [],
|
|
})
|
|
|
|
// Get conversation history
|
|
const { data: history } = await supabase
|
|
.from('chat_messages')
|
|
.select('role, content')
|
|
.eq('session_id', sessionId)
|
|
.order('created_at', { ascending: true })
|
|
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
|
|
|
const conversationHistory = (history || []) as ChatMessage[]
|
|
|
|
// Create streaming response
|
|
const encoder = new TextEncoder()
|
|
let fullContent = ''
|
|
let sources: SourceReference[] = []
|
|
let artifact: ArtifactSpec | null = null
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
// Send session ID first
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
|
)
|
|
|
|
// Stream the routed response (handles knowledge, data, and hybrid)
|
|
for await (const event of streamRoutedResponse(
|
|
message.trim(),
|
|
conversationHistory,
|
|
supabase,
|
|
userId,
|
|
sessionId
|
|
)) {
|
|
if (event.type === 'content') {
|
|
fullContent += event.content
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
|
|
)
|
|
} else if (event.type === 'sources') {
|
|
sources = event.sources
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
|
|
)
|
|
} else if (event.type === 'tool_start') {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
|
|
)
|
|
} else if (event.type === 'artifact') {
|
|
artifact = event.artifact
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save the complete assistant message (including artifact)
|
|
const { data: savedMessage } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'assistant',
|
|
content: fullContent,
|
|
sources,
|
|
...(artifact ? { artifact } : {}),
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
// Send done signal with message ID
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
|
)
|
|
|
|
controller.close()
|
|
} catch (error) {
|
|
console.error('Streaming error:', error)
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
|
)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
})
|
|
} catch (err) {
|
|
console.error('Stream setup error:', err)
|
|
return new Response(
|
|
JSON.stringify({ error: 'Failed to setup stream' }),
|
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /sessions — List all chat sessions for the user
|
|
// ============================================================
|
|
|
|
async function handleGetSessions(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const limit = parseInt(searchParams.get('limit') || '20')
|
|
const offset = parseInt(searchParams.get('offset') || '0')
|
|
|
|
const { data, error, count } = await supabase
|
|
.from('chat_sessions')
|
|
.select('*', { count: 'exact' })
|
|
.eq('company_id', userId)
|
|
.order('created_at', { ascending: false })
|
|
.range(offset, offset + limit - 1)
|
|
|
|
if (error) {
|
|
console.error('Error fetching sessions:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch sessions' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data, count })
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /sessions — Create a new chat session
|
|
// ============================================================
|
|
|
|
async function handlePostSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
try {
|
|
const body = await request.json()
|
|
const { title } = body
|
|
|
|
const { data, error } = await supabase
|
|
.from('chat_sessions')
|
|
.insert({
|
|
user_id: userId,
|
|
title: title || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
console.error('Error creating session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /sessions/:id — Get a single chat session with messages
|
|
// ============================================================
|
|
|
|
async function handleGetSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
// Get session with messages
|
|
const { data: session, error: sessionError } = await supabase
|
|
.from('chat_sessions')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('company_id', userId)
|
|
.single()
|
|
|
|
if (sessionError || !session) {
|
|
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get messages
|
|
const { data: messages, error: messagesError } = await supabase
|
|
.from('chat_messages')
|
|
.select('*')
|
|
.eq('session_id', id)
|
|
.order('created_at', { ascending: true })
|
|
|
|
if (messagesError) {
|
|
console.error('Error fetching messages:', messagesError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch messages' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
session,
|
|
messages: messages || [],
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// PATCH /sessions/:id — Update a chat session (e.g., rename)
|
|
// ============================================================
|
|
|
|
async function handlePatchSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
const body = await request.json()
|
|
const { title } = body
|
|
|
|
const { data, error } = await supabase
|
|
.from('chat_sessions')
|
|
.update({ title })
|
|
.eq('id', id)
|
|
.eq('company_id', userId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
console.error('Error updating session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
if (!data) {
|
|
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// DELETE /sessions/:id — Delete a chat session and its messages
|
|
// ============================================================
|
|
|
|
async function handleDeleteSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
// Delete session (messages will cascade delete due to FK)
|
|
const { error } = await supabase
|
|
.from('chat_sessions')
|
|
.delete()
|
|
.eq('id', id)
|
|
.eq('company_id', userId)
|
|
|
|
if (error) {
|
|
console.error('Error deleting session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to delete session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
}
|
|
|
|
// ============================================================
|
|
// Route definitions
|
|
// ============================================================
|
|
|
|
export const aiChatApiRoutes: ApiRouteDefinition[] = [
|
|
{
|
|
method: 'POST',
|
|
path: '/',
|
|
handler: handlePostChat,
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/stream',
|
|
handler: handlePostStream,
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/sessions',
|
|
handler: handleGetSessions,
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/sessions',
|
|
handler: handlePostSession,
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/sessions/:id',
|
|
handler: handleGetSession,
|
|
},
|
|
{
|
|
method: 'PATCH',
|
|
path: '/sessions/:id',
|
|
handler: handlePatchSession,
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/sessions/:id',
|
|
handler: handleDeleteSession,
|
|
},
|
|
]
|