From 4031ab680d2283cd3781aabfb814e76b480eed0f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 2 Mar 2026 15:09:23 +0100 Subject: [PATCH] fix: downgrade missing extension env vars to warning, add error boundaries and cron auth fixes - Change missing extension env vars from throw to log.warn (graceful degradation) - Move initialized flag after all init steps complete - Add error.tsx and loading.tsx for dashboard error boundaries - Fix cron route auth header checks - Add ensureInitialized() to journal entry reverse and invoice mark-paid/sent routes Co-Authored-By: Claude Opus 4.6 --- app/(dashboard)/deadlines/page.tsx | 5 ++-- app/(dashboard)/error.tsx | 27 +++++++++++++++++++ app/(dashboard)/loading.tsx | 9 +++++++ .../[id]/reverse/__tests__/route.test.ts | 4 +++ .../journal-entries/[id]/reverse/route.ts | 3 +++ app/api/deadlines/status/cron/route.ts | 2 +- app/api/documents/verify/cron/route.ts | 2 +- .../enable-banking/sync/cron/route.ts | 2 +- .../push-notifications/cron/route.ts | 2 +- .../[id]/mark-paid/__tests__/route.test.ts | 4 +++ app/api/invoices/[id]/mark-paid/route.ts | 3 +++ app/api/invoices/[id]/mark-sent/route.ts | 3 +++ app/api/tax-deadlines/cron/route.ts | 2 +- app/api/transactions/[id]/categorize/route.ts | 2 +- lib/bookkeeping/engine.ts | 13 +++++++-- lib/init.ts | 5 ++-- 16 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 app/(dashboard)/error.tsx create mode 100644 app/(dashboard)/loading.tsx diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 211863e0..0e90a0c4 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -10,13 +10,14 @@ import { Badge } from '@/components/ui/badge' import { AlertTriangle, ArrowRight } from 'lucide-react' import type { Deadline } from '@/types' +const supabase = createClient() + export default function DeadlinesPage() { const [deadlines, setDeadlines] = useState([]) const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 }) const [isLoading, setIsLoading] = useState(true) const { toast } = useToast() - const supabase = createClient() const fetchData = useCallback(async () => { setIsLoading(true) @@ -66,7 +67,7 @@ export default function DeadlinesPage() { } finally { setIsLoading(false) } - }, [supabase, toast]) + }, [toast]) useEffect(() => { fetchData() diff --git a/app/(dashboard)/error.tsx b/app/(dashboard)/error.tsx new file mode 100644 index 00000000..c920c6a5 --- /dev/null +++ b/app/(dashboard)/error.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useEffect } from 'react' +import { Button } from '@/components/ui/button' + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[dashboard] Unhandled error:', error) + }, [error]) + + return ( +
+

Nagot gick fel

+

+ Ett oväntat fel uppstod. Försök igen eller kontakta support om problemet + kvarstår. +

+ +
+ ) +} diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx new file mode 100644 index 00000000..7eb21b04 --- /dev/null +++ b/app/(dashboard)/loading.tsx @@ -0,0 +1,9 @@ +export default function DashboardLoading() { + return ( +
+
+
+
+
+ ) +} diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts index 33961f2e..e1a9bd03 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient(), })) +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + const mockReverseEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index a262ff0f..eca91f23 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -1,6 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() export async function POST( request: Request, diff --git a/app/api/deadlines/status/cron/route.ts b/app/api/deadlines/status/cron/route.ts index a705fbcb..ec739748 100644 --- a/app/api/deadlines/status/cron/route.ts +++ b/app/api/deadlines/status/cron/route.ts @@ -14,7 +14,7 @@ export async function GET(request: Request) { const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/documents/verify/cron/route.ts b/app/api/documents/verify/cron/route.ts index fa7c8491..5ba67933 100644 --- a/app/api/documents/verify/cron/route.ts +++ b/app/api/documents/verify/cron/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 128b929e..2a9cf790 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -21,7 +21,7 @@ export async function GET(request: Request) { const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index c4b8087c..60dabf0e 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -22,7 +22,7 @@ export async function GET(request: Request) { const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 4f01d0aa..502c52da 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -13,6 +13,10 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + const mockCreateInvoicePaymentJournalEntry = vi.fn() const mockCreateInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index fb9aad97..f78c6bc7 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -5,8 +5,11 @@ import { createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' +import { ensureInitialized } from '@/lib/init' import type { EntityType, Invoice } from '@/types' +ensureInitialized() + /** * POST /api/invoices/[id]/mark-paid * diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 25b3a993..fe501782 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -1,8 +1,11 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { ensureInitialized } from '@/lib/init' import type { EntityType, Invoice } from '@/types' +ensureInitialized() + /** * POST /api/invoices/[id]/mark-sent * diff --git a/app/api/tax-deadlines/cron/route.ts b/app/api/tax-deadlines/cron/route.ts index c1973be9..0326b6ee 100644 --- a/app/api/tax-deadlines/cron/route.ts +++ b/app/api/tax-deadlines/cron/route.ts @@ -14,7 +14,7 @@ export async function GET(request: Request) { const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index b8265f2c..7bc0c954 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -201,7 +201,7 @@ export async function POST( if (is_business && body.account_override) { // Validate the account exists in the user's chart of accounts const { data: accountExists } = await supabase - .from('accounts') + .from('chart_of_accounts') .select('account_number, account_class') .eq('user_id', user.id) .eq('account_number', body.account_override) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index de05f87d..4d58d980 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -394,14 +394,23 @@ export async function reverseEntry( throw new Error(`Failed to post reversal entry: ${postError.message}`) } - // Mark original as reversed with reversed_by_id link - await supabase + // Mark original as reversed with reversed_by_id link (CAS guard: only if still 'posted') + const { data: updatedOriginal, error: casError } = await supabase .from('journal_entries') .update({ status: 'reversed', reversed_by_id: reversalEntry.id, }) .eq('id', entryId) + .eq('status', 'posted') + .select('id') + + if (casError || !updatedOriginal || updatedOriginal.length === 0) { + // Another concurrent reversal already changed the status — roll back our reversal + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) + await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) + throw new Error('Entry was already reversed by a concurrent operation') + } // Fetch complete reversal entry with lines const { data: completeEntry } = await supabase diff --git a/lib/init.ts b/lib/init.ts index 19358ea2..7643999b 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -46,7 +46,7 @@ function validateEnvironment(): void { } if (missingExt.length > 0) { - throw new Error(`Missing required extension environment variables: ${missingExt.join(', ')}`) + log.warn(`Missing extension environment variables (extensions needing them may not work): ${missingExt.join(', ')}`) } for (const v of OPTIONAL_VARS) { @@ -64,10 +64,11 @@ function validateEnvironment(): void { */ export function ensureInitialized(): void { if (initialized) return - initialized = true validateEnvironment() setContextFactory(createExtensionContext) registerSupplierInvoiceHandler() loadExtensions() + + initialized = true }