feat: add document bucket auto-creation, extension defaults, and chat widget on dashboard

Auto-create documents storage bucket on first upload. Default legacy general
extensions to enabled when no toggle row exists. Add ChatWidget to dashboard
root page with open-ai-chat event support and AI assistant quick action.
Add ensureInitialized to supplier invoices route for event emission.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-23 13:00:16 +01:00
co-authored by Claude Opus 4.6
parent 263c229903
commit ce66a21010
8 changed files with 86 additions and 7 deletions
@@ -1,6 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
// Legacy general extensions default to enabled when no toggle row exists
const LEGACY_GENERAL_EXTENSIONS = [
'receipt-ocr',
'ai-categorization',
'ai-chat',
'push-notifications',
'enable-banking',
]
export async function GET(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
@@ -21,7 +30,14 @@ export async function GET(
.eq('extension_slug', slug)
.single()
return NextResponse.json({ data: data ?? { enabled: false } })
if (data) {
return NextResponse.json({ data })
}
// No toggle row: legacy general extensions default to enabled
const defaultEnabled =
sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug)
return NextResponse.json({ data: { enabled: defaultEnabled } })
}
export async function DELETE(
@@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
const mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
+3
View File
@@ -1,8 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
export async function GET(request: Request) {
const supabase = await createClient()
+2
View File
@@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'
import DashboardNav from '@/components/dashboard/DashboardNav'
import DashboardContent from '@/components/dashboard/DashboardContent'
import type { Deadline, ReceiptQueueSummary } from '@/types'
import { ChatWidget } from '@/components/chat'
export default async function RootPage() {
const supabase = await createClient()
@@ -190,6 +191,7 @@ export default async function RootPage() {
}}
/>
</div>
<ChatWidget />
</main>
</div>
)
+10 -4
View File
@@ -10,7 +10,6 @@ export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
// Default to true for legacy compatibility (ai-chat defaults to enabled)
const [enabled, setEnabled] = useState(true)
const [loaded, setLoaded] = useState(false)
// Fetch initial toggle state
useEffect(() => {
@@ -22,8 +21,8 @@ export function ChatWidget() {
// Legacy: enabled by default when no toggle row exists
setEnabled(data?.enabled ?? true)
}
} finally {
setLoaded(true)
} catch {
// Keep default (enabled) on fetch failure
}
}
check()
@@ -42,7 +41,14 @@ export function ChatWidget() {
return () => window.removeEventListener('extension-toggle-changed', handler)
}, [])
if (!loaded || !enabled) return null
// Allow other components to open the chat via custom event
useEffect(() => {
const handler = () => setIsOpen(true)
window.addEventListener('open-ai-chat', handler)
return () => window.removeEventListener('open-ai-chat', handler)
}, [])
if (!enabled) return null
return (
<>
+15
View File
@@ -27,6 +27,7 @@ import {
Landmark,
CheckCircle2,
ClipboardList,
MessageCircle,
} from 'lucide-react'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
@@ -179,6 +180,8 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
// Quick action items
const quickActions = [
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
@@ -400,6 +403,18 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</Link>
)
})}
{/* AI assistant quick action */}
<button onClick={openAiChat} className="group text-left">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">AI-assistent</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
</div>
</div>
</button>
</div>
</section>
@@ -25,6 +25,8 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
storage: {
getBucket: vi.fn().mockResolvedValue({ data: { id: 'documents' }, error: null }),
createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }),
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
download: vi.fn().mockResolvedValue({
@@ -43,14 +45,16 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
createServiceClient: vi.fn(async () => makeClient()),
}))
import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service'
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
import { createClient } from '@/lib/supabase/server'
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
_resetBucketVerified()
resultIdx = 0
results = []
// Reset the mock to use default makeClient
+30 -1
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events'
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
@@ -10,6 +10,33 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS
* for documents linked to committed entries.
*/
let bucketVerified = false
/** @internal Reset bucket verification flag — for testing only */
export function _resetBucketVerified() {
bucketVerified = false
}
/**
* Ensure the 'documents' storage bucket exists, creating it if missing.
* Runs once per process lifetime (same pattern as ensureInitialized).
*/
async function ensureDocumentsBucket(): Promise<void> {
if (bucketVerified) return
const supabase = await createServiceClient()
const { data: bucket } = await supabase.storage.getBucket('documents')
if (!bucket) {
await supabase.storage.createBucket('documents', {
public: false,
fileSizeLimit: 52428800, // 50 MB
})
}
bucketVerified = true
}
/**
* Compute SHA-256 hash of a file buffer
*/
@@ -31,6 +58,7 @@ export async function uploadDocument(
journal_entry_line_id?: string
} = {}
): Promise<DocumentAttachment> {
await ensureDocumentsBucket()
const supabase = await createClient()
// Compute SHA-256 hash
@@ -102,6 +130,7 @@ export async function createNewVersion(
originalId: string,
file: { name: string; buffer: ArrayBuffer; type?: string }
): Promise<DocumentAttachment> {
await ensureDocumentsBucket()
const supabase = await createClient()
// Compute SHA-256 hash