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,39 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
export default async function ExtensionWorkspacePage({
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) redirect('/login')
const definition = getExtensionDefinition(sector, slug)
if (!definition) notFound()
// Check toggle
const { data: toggle } = await supabase
.from('extension_toggles')
.select('enabled')
.eq('user_id', user.id)
.eq('sector_slug', sector)
.eq('extension_slug', slug)
.single()
if (!toggle?.enabled) redirect('/extensions')
return (
<ExtensionWorkspaceLoader
sector={sector}
slug={slug}
definition={definition}
userId={user.id}
/>
)
}
@@ -0,0 +1,85 @@
import { notFound } from 'next/navigation'
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import CategoryBadge from '@/components/extensions/CategoryBadge'
import ExtensionToggleButton from '@/components/extensions/ExtensionToggleButton'
import Link from 'next/link'
export default async function ExtensionDetailPage({
params,
}: {
params: Promise<{ sector: string; extension: string }>
}) {
const { sector: sectorSlug, extension: extensionSlug } = await params
const definition = getExtensionDefinition(sectorSlug, extensionSlug)
if (!definition) notFound()
const sector = getSector(sectorSlug as any)
const Icon = resolveIcon(definition.icon)
const dataPatternLabels: Record<string, string> = {
core: 'Använder bokföringsdata',
manual: 'Manuell inmatning',
both: 'Bokföringsdata + manuell inmatning',
}
return (
<div>
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
<Link href="/extensions" className="hover:text-foreground transition-colors">
Tillägg
</Link>
<span>/</span>
<Link
href={`/extensions/${sectorSlug}`}
className="hover:text-foreground transition-colors"
>
{sector?.name ?? sectorSlug}
</Link>
<span>/</span>
<span className="text-foreground">{definition.name}</span>
</nav>
{/* Header with toggle */}
<div className="flex items-start justify-between gap-4 mb-8">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-7 w-7 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">{definition.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{definition.description}</p>
<div className="mt-2">
<CategoryBadge category={definition.category} />
</div>
</div>
</div>
<ExtensionToggleButton sectorSlug={sectorSlug} extensionSlug={extensionSlug} />
</div>
{/* Details */}
<div className="space-y-6">
<div>
<h2 className="text-sm font-semibold mb-2">Beskrivning</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{definition.longDescription}
</p>
</div>
<div>
<h2 className="text-sm font-semibold mb-2">Datakälla</h2>
<p className="text-sm text-muted-foreground">
{dataPatternLabels[definition.dataPattern]}
</p>
{definition.readsCoreTables && definition.readsCoreTables.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Läser från: {definition.readsCoreTables.join(', ')}
</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,49 @@
import { notFound } from 'next/navigation'
import { getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import Link from 'next/link'
export default async function SectorExtensionsPage({
params,
}: {
params: Promise<{ sector: string }>
}) {
const { sector: sectorSlug } = await params
const sector = getSector(sectorSlug as any)
if (!sector) notFound()
const Icon = resolveIcon(sector.icon)
return (
<div>
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
<Link href="/extensions" className="hover:text-foreground transition-colors">
Tillägg
</Link>
<span>/</span>
<span className="text-foreground">{sector.name}</span>
</nav>
{/* Header */}
<div className="flex items-start gap-4 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">{sector.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{sector.description}</p>
</div>
</div>
{/* Extensions grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{sector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
</div>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { SECTORS } from '@/lib/extensions/sectors'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import SectorCard from '@/components/extensions/SectorCard'
export default function ExtensionsPage() {
const generalSector = SECTORS.find(s => s.slug === 'general')
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
return (
<div>
<div className="mb-8">
<h1 className="text-xl font-semibold tracking-tight">Tillägg</h1>
<p className="text-sm text-muted-foreground mt-1">
Utöka ditt bokföringssystem med verktyg och branschspecifika funktioner.
</p>
</div>
{/* General extensions */}
{generalSector && (
<section className="mb-10">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
{generalSector.name}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{generalSector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
</div>
</section>
)}
{/* Industry sectors */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Branschverktyg
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{industrySectors.map(sector => (
<SectorCard key={sector.slug} sector={sector} />
))}
</div>
</section>
</div>
)
}
+7
View File
@@ -29,6 +29,12 @@ export default async function DashboardLayout({
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
@@ -41,6 +47,7 @@ export default async function DashboardLayout({
<DashboardNav
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
enabledExtensions={enabledToggles || []}
/>
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
+1 -1
View File
@@ -1 +1 @@
export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage'
export { default } from '@/extensions/general/receipt-ocr/pages/ReceiptsPage'
+1 -1
View File
@@ -1 +1 @@
export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage'
export { default } from '@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'
+1 -1
View File
@@ -24,7 +24,7 @@ import {
Calendar,
} from 'lucide-react'
import type { CompanySettings, BankConnection } from '@/types'
import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings'
import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
export default function SettingsPage() {
+77 -27
View File
@@ -1,10 +1,9 @@
'use client'
import { useState, useEffect, useMemo, Suspense } from 'react'
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
@@ -13,13 +12,18 @@ import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step6ConnectBank from '@/components/onboarding/Step6ConnectBank'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
import Step6SectorSelection from '@/components/onboarding/Step2SectorSelection'
import Step7ExtensionSuggestions from '@/components/onboarding/Step3ExtensionSuggestions'
const STEP_TITLES = [
'Verksamhetsform',
'Företagsuppgifter',
'Skatteregistrering',
'F-skatt',
'Anslut bank',
'Bransch',
'Tillägg',
]
export default function OnboardingPage() {
@@ -40,8 +44,9 @@ function OnboardingPageContent() {
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const [sectorSlug, setSectorSlug] = useState<string | null>(null)
const totalSteps = 5
const totalSteps = 7
const stepTitles = STEP_TITLES
// Load existing settings on mount
@@ -63,6 +68,9 @@ function OnboardingPageContent() {
if (data) {
setSettings(data)
setCurrentStep(data.onboarding_step || 1)
if ((data as Record<string, unknown>).sector_slug) {
setSectorSlug((data as Record<string, unknown>).sector_slug as string)
}
}
setIsLoading(false)
@@ -74,13 +82,13 @@ function OnboardingPageContent() {
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
saveSettings({ onboarding_complete: true }).then((success) => {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
saveSettings({}, 6).then((success) => {
if (success) {
toast({
title: 'Bank ansluten!',
description: 'Din bank är kopplad och profilen är redo.',
})
router.push('/')
setCurrentStep(6)
}
})
}
@@ -205,18 +213,6 @@ function OnboardingPageContent() {
}
}
const handleComplete = async () => {
const success = await saveSettings({ onboarding_complete: true })
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -227,6 +223,41 @@ function OnboardingPageContent() {
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const handleSectorNext = async (data: { sector_slug: string | null }) => {
setSectorSlug(data.sector_slug)
const nextStep = currentStep + 1
const settingsUpdate: Partial<CompanySettings> = {}
if (data.sector_slug) {
;(settingsUpdate as Record<string, unknown>).sector_slug = data.sector_slug
}
const success = await saveSettings(settingsUpdate, nextStep)
if (success) {
setCurrentStep(nextStep)
}
}
const handleExtensionsNext = async (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => {
// Insert extension toggles if any were selected
if (data.enabled_extensions.length > 0) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const rows = data.enabled_extensions.map(ext => ({
user_id: user.id,
sector_slug: ext.sector_slug,
extension_slug: ext.extension_slug,
enabled: true,
}))
await supabase.from('extension_toggles').upsert(rows, {
onConflict: 'user_id,sector_slug,extension_slug',
})
}
} catch (err) {
console.error('Failed to save extension toggles:', err)
}
}
}
const renderSteps = () => (
<>
{currentStep === 1 && (
@@ -281,7 +312,7 @@ function OnboardingPageContent() {
)}
{currentStep === 5 && (
<Step6ConnectBank
<Step5ConnectBank
initialData={{
bank_name: settings.bank_name ?? undefined,
clearing_number: settings.clearing_number ?? undefined,
@@ -291,10 +322,30 @@ function OnboardingPageContent() {
}}
onComplete={async (data) => {
if (data) {
await saveSettings({ ...data, onboarding_complete: true })
} else {
await saveSettings({ onboarding_complete: true })
await saveSettings(data, 6)
}
setCurrentStep(6)
}}
onBack={handleBack}
onSkip={() => setCurrentStep(6)}
isSaving={isSaving}
/>
)}
{currentStep === 6 && (
<Step6SectorSelection
onNext={handleSectorNext}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 7 && (
<Step7ExtensionSuggestions
sectorSlug={sectorSlug}
onNext={async (data) => {
await handleExtensionsNext(data)
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
@@ -302,7 +353,6 @@ function OnboardingPageContent() {
router.push('/')
}}
onBack={handleBack}
onSkip={handleComplete}
isSaving={isSaving}
/>
)}
@@ -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 })
}