feat: production readiness — 3-extension deploy with security hardening and observability
- Strip extensions to enable-banking, ai-categorization, ai-chat only - Remove push-notifications cron from vercel.json - Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy) - Add /api/health endpoint for uptime monitoring - Add env var validation in ensureInitialized() - Fix SIE4 #IB opening balance records from year-end closing entry - Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting - Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook) - Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions) - Include pending enable-banking and dashboard improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1a1c6ba40f
commit
13725ffc16
@@ -14,6 +14,7 @@ import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { TransactionCategory, EntityType } from '@/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -72,10 +73,16 @@ export interface CategorizationSuggestion {
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
export interface TrackingContext {
|
||||
supabase: SupabaseClient
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext,
|
||||
tracking?: TrackingContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
|
||||
@@ -198,7 +205,8 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
|
||||
async categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext,
|
||||
tracking?: TrackingContext
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
// Cap batch size
|
||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||
@@ -318,6 +326,16 @@ ${transactionList}`
|
||||
],
|
||||
})
|
||||
|
||||
// Track token usage (fire-and-forget)
|
||||
if (tracking && message.usage) {
|
||||
const { trackTokenUsage } = await import('@/lib/ai/usage-tracker')
|
||||
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-categorization', {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
model: this.model,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tool_use block from response
|
||||
const toolUseBlock = message.content.find(
|
||||
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CategorizationSuggestion,
|
||||
type AccountUsageEntry,
|
||||
type MerchantHistoryEntry,
|
||||
type TrackingContext,
|
||||
} from './categorizer'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
@@ -124,7 +125,7 @@ export async function categorizeTransactions(
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||
|
||||
// Store suggestions
|
||||
await storeSuggestions(userId, suggestions, supabase)
|
||||
@@ -174,7 +175,7 @@ async function handleTransactionSynced(
|
||||
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||
|
||||
// Store only suggestions above confidence threshold
|
||||
const qualifiedSuggestions = suggestions.filter(
|
||||
|
||||
@@ -4,26 +4,56 @@ import { generateChatResponse, streamChatResponse, streamRoutedResponse } from '
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
// Distributed rate limiting backed by Supabase extension_data table
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
interface RateLimitState {
|
||||
count: number
|
||||
window_start: number
|
||||
}
|
||||
|
||||
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
const WINDOW_MS = 60000
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000, // 1 minute window
|
||||
})
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_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 (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -40,7 +70,7 @@ async function handlePostChat(
|
||||
const supabase = await createClient()
|
||||
|
||||
// Rate limiting
|
||||
if (!checkRateLimit(userId)) {
|
||||
if (!await checkRateLimitDB(supabase, userId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||
{ status: 429 }
|
||||
@@ -129,7 +159,7 @@ async function handlePostChat(
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Generate AI response
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory)
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
|
||||
|
||||
// Save assistant message
|
||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||
@@ -177,7 +207,7 @@ async function handlePostStream(
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
if (!checkRateLimit(userId)) {
|
||||
if (!await checkRateLimitDB(supabase, userId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
|
||||
@@ -17,6 +17,7 @@ import { streamAgentResponse, type ToolResultEntry } from './agent'
|
||||
import { generateArtifact, type ArtifactSpec } from './artifacts'
|
||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
@@ -35,7 +36,8 @@ export interface ChatResult {
|
||||
|
||||
export async function generateChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
conversationHistory: ChatMessage[],
|
||||
tracking?: { supabase: SupabaseClient; userId: string }
|
||||
): Promise<ChatResult> {
|
||||
// 1. Retrieve relevant documents
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
@@ -74,7 +76,16 @@ export async function generateChatResponse(
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
// 6. Extract content and sources
|
||||
// 6. Track token usage
|
||||
if (tracking && response.usage_metadata) {
|
||||
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
|
||||
inputTokens: response.usage_metadata.input_tokens ?? 0,
|
||||
outputTokens: response.usage_metadata.output_tokens ?? 0,
|
||||
model: CHATBOT_CONFIG.model,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Extract content and sources
|
||||
const content =
|
||||
typeof response.content === 'string'
|
||||
? response.content
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
@@ -67,8 +68,19 @@ export function BankConnectionStatus({
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
balance_updated_at?: string
|
||||
}>) || []
|
||||
|
||||
const [now] = useState(() => Date.now())
|
||||
|
||||
function formatBalanceAge(updatedAt: string): string {
|
||||
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
|
||||
if (hoursAgo < 1) return 'Nyss uppdaterat'
|
||||
if (hoursAgo < 24) return `${hoursAgo}h sedan`
|
||||
const daysAgo = Math.floor(hoursAgo / 24)
|
||||
return `${daysAgo}d sedan`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
@@ -155,6 +167,11 @@ export function BankConnectionStatus({
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
{account.balance_updated_at && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatBalanceAge(account.balance_updated_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Landmark } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankSelector, type Bank } from './BankSelector'
|
||||
@@ -18,8 +19,10 @@ export default function BankingSettingsPanel() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
|
||||
@@ -71,7 +74,7 @@ export default function BankingSettingsPanel() {
|
||||
}
|
||||
|
||||
async function handleSyncTransactions(connectionId: string) {
|
||||
setIsSyncing(true)
|
||||
setSyncingConnectionId(connectionId)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
@@ -100,27 +103,41 @@ export default function BankingSettingsPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
setIsSyncing(false)
|
||||
setSyncingConnectionId(null)
|
||||
}
|
||||
|
||||
async function handleDisconnectBank(connectionId: string) {
|
||||
const { error } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'revoked' })
|
||||
.eq('id', connectionId)
|
||||
const ok = await confirm({
|
||||
title: 'Koppla bort bank?',
|
||||
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
|
||||
confirmLabel: 'Koppla bort',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte koppla bort bank',
|
||||
variant: 'destructive',
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
} else {
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Disconnect failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Bank bortkopplad',
|
||||
description: 'Bankanslutningen har tagits bort',
|
||||
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
|
||||
})
|
||||
fetchConnections()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +153,8 @@ export default function BankingSettingsPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
@@ -149,7 +168,7 @@ export default function BankingSettingsPanel() {
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
isSyncing={isSyncing}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import {
|
||||
startAuthorization,
|
||||
getASPSPs,
|
||||
deleteSession,
|
||||
type ASPSP,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
@@ -80,6 +81,15 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine PSU type from entity type
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const psuType = companySettings?.entity_type === 'aktiebolag' ? 'business' : 'personal'
|
||||
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
@@ -87,7 +97,7 @@ export const enableBankingExtension: Extension = {
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id,
|
||||
'personal'
|
||||
psuType
|
||||
)
|
||||
|
||||
const { data: connection, error } = await supabase
|
||||
@@ -160,11 +170,8 @@ export const enableBankingExtension: Extension = {
|
||||
// Use ctx.services.ingestTransactions when available
|
||||
const ingestFn = ctx?.services.ingestTransactions
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
const results = await Promise.all(
|
||||
accounts.map(account => syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
@@ -172,11 +179,11 @@ export const enableBankingExtension: Extension = {
|
||||
fromDate,
|
||||
toDate,
|
||||
ingestFn
|
||||
)
|
||||
))
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
||||
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
|
||||
|
||||
const syncedAt = new Date().toISOString()
|
||||
await supabase
|
||||
@@ -220,6 +227,57 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/disconnect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id } = await request.json()
|
||||
|
||||
if (!connection_id) {
|
||||
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: connection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, session_id, status')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (findError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Revoke PSD2 consent if session exists
|
||||
if (connection.session_id) {
|
||||
try {
|
||||
await deleteSession(connection.session_id)
|
||||
} catch (error) {
|
||||
// Consent may already be expired — log and continue
|
||||
log.error('Failed to revoke PSD2 session (may be expired):', error)
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'revoked', session_id: null })
|
||||
.eq('id', connection.id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
|
||||
@@ -63,8 +63,9 @@ export async function syncAccountTransactions(
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
account.balance_updated_at = new Date().toISOString()
|
||||
} catch {
|
||||
// Ignore balance fetch errors
|
||||
// Keep previous balance, don't update timestamp
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface StoredAccount {
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
balance_updated_at?: string
|
||||
}
|
||||
|
||||
// Re-export API types from the client
|
||||
|
||||
Reference in New Issue
Block a user