Added extension functionality

This commit is contained in:
Emil
2026-02-21 11:39:45 +01:00
parent 2f83f232c6
commit 026497ed75
132 changed files with 3533 additions and 60 deletions
@@ -0,0 +1,115 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const extensionId = `${sector}/${slug}`
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
let query = supabase
.from('extension_data')
.select('*')
.eq('user_id', user.id)
.eq('extension_id', extensionId)
if (key) {
query = query.eq('key', key)
}
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { key, value } = body
if (!key) {
return NextResponse.json({ error: 'key is required' }, { status: 400 })
}
const extensionId = `${sector}/${slug}`
const { data, error } = await supabase
.from('extension_data')
.upsert(
{
user_id: user.id,
extension_id: extensionId,
key,
value,
},
{ onConflict: 'user_id,extension_id,key' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
if (!key) {
return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 })
}
const extensionId = `${sector}/${slug}`
const { error } = await supabase
.from('extension_data')
.delete()
.eq('user_id', user.id)
.eq('extension_id', extensionId)
.eq('key', key)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
@@ -0,0 +1,74 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const extensionId = `${sector}/${slug}`
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
return NextResponse.json({ data: data?.value ?? {} })
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const extensionId = `${sector}/${slug}`
// Get existing settings and merge
const { data: existing } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
const mergedSettings = { ...(existing?.value ?? {}), ...body }
const { data, error } = await supabase
.from('extension_data')
.upsert(
{
user_id: user.id,
extension_id: extensionId,
key: 'settings',
value: mergedSettings,
},
{ onConflict: 'user_id,extension_id,key' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: data.value })
}
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSettings, saveSettings } from '@/extensions/ai-categorization'
import { getSettings, saveSettings } from '@/extensions/general/ai-categorization'
/**
* GET /api/extensions/ai-categorization/settings
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { categorizeTransactions } from '@/extensions/ai-categorization'
import type { CategorizationSuggestion } from '@/extensions/ai-categorization/categorizer'
import { categorizeTransactions } from '@/extensions/general/ai-categorization'
import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer'
/**
* GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,...
+2 -2
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
import { generateChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest } from '@/types/chat'
// Simple in-memory rate limiting (per user)
+2 -2
View File
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
import { streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
// Simple in-memory rate limiting (per user)
@@ -1,7 +1,7 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/enable-banking/types'
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
/**
* GET /api/extensions/enable-banking/callback
@@ -1,8 +1,8 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/enable-banking/types'
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
/**
* GET /api/extensions/enable-banking/sync/cron
@@ -4,7 +4,7 @@ import { loadExtensions } from '@/lib/extensions/loader'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
} from '@/extensions/push-notifications/notification-scheduler'
} from '@/extensions/general/push-notifications/notification-scheduler'
/**
* GET /api/extensions/push-notifications/cron
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSettings, saveSettings } from '@/extensions/push-notifications'
import { getSettings, saveSettings } from '@/extensions/general/push-notifications'
/**
* GET /api/extensions/push-notifications/settings
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender'
import { getVapidPublicKey } from '@/extensions/general/push-notifications/notification-sender'
/**
* GET /api/extensions/push-notifications/subscribe
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher'
import { findTransactionMatches } from '@/extensions/general/receipt-ocr/lib/receipt-matcher'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { Receipt, Transaction } from '@/types'
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSettings, saveSettings } from '@/extensions/receipt-ocr'
import { getSettings, saveSettings } from '@/extensions/general/receipt-ocr'
/**
* GET /api/extensions/receipt-ocr/settings
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
import { analyzeReceipt } from '@/extensions/general/receipt-ocr/lib/receipt-analyzer'
import { processLineItems } from '@/extensions/general/receipt-ocr/lib/receipt-categorizer'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
@@ -0,0 +1,51 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data } = await supabase
.from('extension_toggles')
.select('*')
.eq('user_id', user.id)
.eq('sector_slug', sector)
.eq('extension_slug', slug)
.single()
return NextResponse.json({ data: data ?? { enabled: false } })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('extension_toggles')
.delete()
.eq('user_id', user.id)
.eq('sector_slug', sector)
.eq('extension_slug', slug)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeExtensionToggle,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
import { GET, POST } from '../route'
describe('GET /api/extensions/toggles', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET()
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns enabled toggles for user', async () => {
const toggles = [
makeExtensionToggle({ extension_slug: 'receipt-ocr' }),
makeExtensionToggle({ extension_slug: 'ai-chat' }),
]
enqueue({ data: toggles, error: null })
const response = await GET()
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(toggles)
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
})
})
describe('POST /api/extensions/toggles', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when missing fields', async () => {
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general' },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('sector_slug, extension_slug, and enabled are required')
})
it('upserts toggle and returns data', async () => {
const toggle = makeExtensionToggle({
sector_slug: 'general',
extension_slug: 'receipt-ocr',
enabled: true,
})
enqueue({ data: toggle, error: null })
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(toggle)
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
})
})
+62
View File
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('extension_toggles')
.select('*')
.eq('user_id', user.id)
.eq('enabled', true)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { sector_slug, extension_slug, enabled } = body
if (!sector_slug || !extension_slug || typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'sector_slug, extension_slug, and enabled are required' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('extension_toggles')
.upsert(
{
user_id: user.id,
sector_slug,
extension_slug,
enabled,
},
{ onConflict: 'user_id,sector_slug,extension_slug' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}