From ce66a210107b3d7825e6f671c45818e3376f3b24 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 23 Feb 2026 13:00:16 +0100 Subject: [PATCH] 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 --- .../toggles/[sector]/[slug]/route.ts | 18 ++++++++++- .../supplier-invoices/__tests__/route.test.ts | 4 +++ app/api/supplier-invoices/route.ts | 3 ++ app/page.tsx | 2 ++ components/chat/ChatWidget.tsx | 14 ++++++--- components/dashboard/DashboardContent.tsx | 15 +++++++++ .../__tests__/document-service.test.ts | 6 +++- lib/core/documents/document-service.ts | 31 ++++++++++++++++++- 8 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/api/extensions/toggles/[sector]/[slug]/route.ts b/app/api/extensions/toggles/[sector]/[slug]/route.ts index 7b326dc8..3c8df51d 100644 --- a/app/api/extensions/toggles/[sector]/[slug]/route.ts +++ b/app/api/extensions/toggles/[sector]/[slug]/route.ts @@ -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( diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 1c9d3c9a..d23e8631 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -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), diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 564c4428..e702d6e3 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -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() diff --git a/app/page.tsx b/app/page.tsx index 39b0883f..9143e22a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { }} /> + ) diff --git a/components/chat/ChatWidget.tsx b/components/chat/ChatWidget.tsx index ad50594a..c74ea0c0 100644 --- a/components/chat/ChatWidget.tsx +++ b/components/chat/ChatWidget.tsx @@ -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 ( <> diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index ce280b6c..97a1362d 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -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 ) })} + {/* AI assistant quick action */} + diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts index 3168f4d6..916b36cc 100644 --- a/lib/core/documents/__tests__/document-service.test.ts +++ b/lib/core/documents/__tests__/document-service.test.ts @@ -25,6 +25,8 @@ function makeClient(storageOverrides: Record = {}) { 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 = {}) { 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 diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index 01a3afc8..1a307b90 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -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 { + 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 { + 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 { + await ensureDocumentsBucket() const supabase = await createClient() // Compute SHA-256 hash