Added extension functionality
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 @@
|
||||
export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage'
|
||||
export { default } from '@/extensions/general/receipt-ocr/pages/ReceiptsPage'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage'
|
||||
export { default } from '@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user