From c7672e538a963edbdeebd115b46ab396ea646ab7 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:31:58 +0100 Subject: [PATCH] Logging (#30) * Added loggin for the onboarding flow. * Logging for potential errors * feat: enhance onboarding error logging with server-side capture * Update app/(onboarding)/onboarding/page.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/(onboarding)/layout.tsx | 18 + app/(onboarding)/onboarding/page.tsx | 429 ++++++++++++------ app/api/log/route.ts | 14 + components/onboarding/Step1EntityType.tsx | 8 +- components/onboarding/Step2CompanyDetails.tsx | 6 +- .../onboarding/Step3TaxRegistration.tsx | 6 +- components/onboarding/Step4PreliminaryTax.tsx | 6 +- components/onboarding/Step6ConnectBank.tsx | 6 +- 8 files changed, 340 insertions(+), 153 deletions(-) create mode 100644 app/(onboarding)/layout.tsx create mode 100644 app/api/log/route.ts diff --git a/app/(onboarding)/layout.tsx b/app/(onboarding)/layout.tsx new file mode 100644 index 00000000..82588029 --- /dev/null +++ b/app/(onboarding)/layout.tsx @@ -0,0 +1,18 @@ +import { createClient } from '@/lib/supabase/server' +import { SentryIdentify } from '@/components/SentryIdentify' + +export default async function OnboardingLayout({ + children, +}: { + children: React.ReactNode +}) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + return ( + <> + {children} + {user && } + + ) +} diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 769376f6..810f806b 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' +import * as Sentry from '@sentry/nextjs' import { createClient } from '@/lib/supabase/client' import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' @@ -44,6 +45,25 @@ export default function OnboardingPage() { ) } +const LOG = '[onboarding]' + +/** Log to browser console, Vercel server logs (via API), and Sentry. */ +function logError(message: string, extra?: Record) { + console.error(LOG, message, extra ?? '') + + // Send to server so it appears in Vercel Logs + fetch('/api/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, extra }), + }).catch(() => {}) // fire-and-forget, never block the UI + + Sentry.captureMessage(`onboarding: ${message}`, { + level: 'error', + extra: { ...extra, component: 'onboarding' }, + }) +} + function OnboardingPageContent() { const router = useRouter() const searchParams = useSearchParams() @@ -57,27 +77,51 @@ function OnboardingPageContent() { const totalSteps = 5 + // Detect stuck state: onboarding marked complete but still on this page + useEffect(() => { + if (settings.onboarding_complete && !isLoading) { + const timeout = setTimeout(() => { + logError('still on onboarding page after onboarding_complete=true — redirect may have failed') + }, 3000) + return () => clearTimeout(timeout) + } + }, [settings.onboarding_complete, isLoading]) + // Load existing settings on mount useEffect(() => { async function loadSettings() { - const { data: { user } } = await supabase.auth.getUser() + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError) { + logError('auth.getUser() failed on mount', { message: authError.message }) + } if (!user) { + logError('no authenticated user on mount, redirecting to login') router.push('/login') return } - const { data } = await supabase + const { data, error } = await supabase .from('company_settings') .select('*') .eq('user_id', user.id) .single() + if (error && error.code !== 'PGRST116') { + logError('failed to load settings', { message: error.message, code: error.code }) + } + if (data) { - setSettings(data) const step = data.onboarding_step || 1 - // Clamp to max steps (handles existing users mid-onboarding from old 7-step flow) - setCurrentStep(step > totalSteps ? totalSteps : step) + const clampedStep = step > totalSteps ? totalSteps : step + if (step > totalSteps) { + logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps }) + } + // Important milestone: where we resume + console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type }) + setSettings(data) + setCurrentStep(clampedStep) } setIsLoading(false) @@ -87,46 +131,64 @@ function OnboardingPageContent() { }, [supabase, router, toast]) const saveSettings = async (updates: Partial, nextStep?: number) => { + const targetStep = nextStep ?? currentStep setIsSaving(true) - const { data: { user } } = await supabase.auth.getUser() + try { + const { data: { user }, error: authError } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return false - } + if (authError) { + logError('auth.getUser() failed during save', { message: authError.message, step: targetStep }) + } - const updatedSettings = { - ...settings, - ...updates, - onboarding_step: nextStep || currentStep, - } + if (!user) { + logError('save aborted: no authenticated user', { step: targetStep }) + router.push('/login') + return false + } - // Remove read-only and transient fields before updating - const { - id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record + const updatedSettings = { + ...settings, + ...updates, + onboarding_step: targetStep, + } - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' }) + // Remove read-only and transient fields before updating + const { + id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record - if (error) { - // Error details available in Sentry via toast context + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' }) + + if (error) { + logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details }) + toast({ + title: 'Fel', + description: error.message || 'Kunde inte spara. Försök igen.', + variant: 'destructive', + }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logError('saveSettings threw unexpectedly', { message, step: targetStep }) + Sentry.captureException(err) toast({ title: 'Fel', - description: error.message || 'Kunde inte spara. Försök igen.', + description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive', }) - setIsSaving(false) return false + } finally { + setIsSaving(false) } - - setSettings(updatedSettings) - setIsSaving(false) - return true } // Handle bank_connected callback from PSD2 flow @@ -138,7 +200,9 @@ function OnboardingPageContent() { }) // Complete onboarding after bank connection saveSettings({ onboarding_complete: true }, totalSteps).then((success) => { - if (success) { + if (!success) { + logError('failed to complete onboarding after bank_connected callback') + } else { toast({ title: 'Välkommen!', description: 'Din profil är nu redo.', @@ -152,145 +216,200 @@ function OnboardingPageContent() { const handleNext = async (stepData: Partial) => { // Fix org number bug: clear dependent fields when entity type changes if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { + console.warn(LOG, 'entity type changed from', settings.entity_type, 'to', stepData.entity_type, '— clearing dependent fields') stepData = { ...stepData, org_number: '', company_name: '' } } const nextStep = currentStep + 1 const success = await saveSettings(stepData, nextStep) - if (success) { - // After step 1 (entity type selection): seed chart of accounts - if (currentStep === 1 && stepData.entity_type) { - try { - const { data: { user } } = await supabase.auth.getUser() - if (user) { - await supabase.rpc('seed_chart_of_accounts', { - p_user_id: user.id, - p_entity_type: stepData.entity_type, + if (!success) { + logError('handleNext aborted: saveSettings failed', { step: currentStep }) + return + } + + // After step 1 (entity type selection): seed chart of accounts + if (currentStep === 1 && stepData.entity_type) { + try { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError) { + logError('auth.getUser() failed before seeding chart of accounts', { message: authError.message }) + } + if (user) { + const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { + p_user_id: user.id, + p_entity_type: stepData.entity_type, + }) + if (rpcError) { + logError('chart of accounts seeding failed', { + entity_type: stepData.entity_type, + message: rpcError.message, + code: rpcError.code, + details: rpcError.details, }) } - } catch { - // Non-critical — chart of accounts can be seeded later + } else { + logError('chart of accounts seeding skipped: no user') } + } catch (err) { + logError('chart of accounts seeding threw', { error: String(err) }) + Sentry.captureException(err) } + } - // After step 3 (tax registration): create initial fiscal period - if (currentStep === 3) { - try { - const { data: { user } } = await supabase.auth.getUser() - if (user) { - const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined - const firstYearStart = stepData.first_year_start as string | undefined - const firstYearEnd = stepData.first_year_end as string | undefined + // After step 3 (tax registration): create initial fiscal period + if (currentStep === 3) { + try { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError) { + logError('auth.getUser() failed before fiscal period creation', { message: authError.message }) + } + if (!user) { + logError('fiscal period creation skipped: no user') + } else { + const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined + const firstYearStart = stepData.first_year_start as string | undefined + const firstYearEnd = stepData.first_year_end as string | undefined - let startStr: string - let endStr: string - let periodName: string + let startStr: string + let endStr: string + let periodName: string - if (isFirstYear && firstYearStart && firstYearEnd) { - // First fiscal year: use exact dates provided - startStr = firstYearStart - endStr = firstYearEnd + if (isFirstYear && firstYearStart && firstYearEnd) { + // First fiscal year: use exact dates provided + startStr = firstYearStart + endStr = firstYearEnd - const startYear = new Date(firstYearStart).getFullYear() - const endYear = new Date(firstYearEnd).getFullYear() - periodName = startYear === endYear - ? `Första räkenskapsåret ${startYear}` - : `Första räkenskapsåret ${startYear}/${endYear}` + const startYear = new Date(firstYearStart).getFullYear() + const endYear = new Date(firstYearEnd).getFullYear() + periodName = startYear === endYear + ? `Första räkenskapsåret ${startYear}` + : `Första räkenskapsåret ${startYear}/${endYear}` + } else { + // Ongoing: compute 12-month period from fiscal_year_start_month + let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 + + // For enskild firma: force calendar year + if (settings.entity_type === 'enskild_firma') { + startMonth = 1 + } + + const currentYear = new Date().getFullYear() + startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` + + let endYear: number + let endMonth: number + if (startMonth === 1) { + endYear = currentYear + endMonth = 12 } else { - // Ongoing: compute 12-month period from fiscal_year_start_month - let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 + endYear = currentYear + 1 + endMonth = startMonth - 1 + } + const lastDay = new Date(endYear, endMonth, 0).getDate() + endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - // For enskild firma: force calendar year - if (settings.entity_type === 'enskild_firma') { - startMonth = 1 + periodName = startMonth === 1 + ? `Räkenskapsår ${currentYear}` + : `Räkenskapsår ${currentYear}/${currentYear + 1}` + } + + // Validate period duration + const validationError = validatePeriodDuration(startStr, endStr) + if (validationError) { + logError('fiscal period validation failed', { + validationError, startStr, endStr, isFirstYear, entity_type: settings.entity_type, + }) + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(validationError), + variant: 'destructive', + }) + setCurrentStep(3) + return + } + + // Delete any existing fiscal periods that have no journal entries, + // so re-running onboarding with different dates doesn't create + // overlapping periods (DB exclusion constraint would reject it). + const { data: existingPeriods, error: fetchPeriodsError } = await supabase + .from('fiscal_periods') + .select('id') + .eq('user_id', user.id) + + if (fetchPeriodsError) { + logError('failed to fetch existing fiscal periods', { + message: fetchPeriodsError.message, code: fetchPeriodsError.code, + }) + } + + if (existingPeriods && existingPeriods.length > 0) { + for (const ep of existingPeriods) { + const { count, error: countError } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('fiscal_period_id', ep.id) + + if (countError) { + logError('failed to count journal entries for period', { periodId: ep.id, message: countError.message }) + continue } - const currentYear = new Date().getFullYear() - startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` + if (count === 0) { + const { error: deleteError } = await supabase + .from('fiscal_periods') + .delete() + .eq('id', ep.id) - let endYear: number - let endMonth: number - if (startMonth === 1) { - endYear = currentYear - endMonth = 12 - } else { - endYear = currentYear + 1 - endMonth = startMonth - 1 - } - const lastDay = new Date(endYear, endMonth, 0).getDate() - endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - periodName = startMonth === 1 - ? `Räkenskapsår ${currentYear}` - : `Räkenskapsår ${currentYear}/${currentYear + 1}` - } - - // Validate period duration - const validationError = validatePeriodDuration(startStr, endStr) - if (validationError) { - toast({ - title: 'Ogiltigt räkenskapsår', - description: translatePeriodError(validationError), - variant: 'destructive', - }) - setCurrentStep(3) - return - } - - // Delete any existing fiscal periods that have no journal entries, - // so re-running onboarding with different dates doesn't create - // overlapping periods (DB exclusion constraint would reject it). - const { data: existingPeriods } = await supabase - .from('fiscal_periods') - .select('id') - .eq('user_id', user.id) - - if (existingPeriods && existingPeriods.length > 0) { - for (const ep of existingPeriods) { - const { count } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('fiscal_period_id', ep.id) - - if (count === 0) { - await supabase - .from('fiscal_periods') - .delete() - .eq('id', ep.id) + if (deleteError) { + logError('failed to delete empty fiscal period', { periodId: ep.id, message: deleteError.message }) } } } + } - await supabase.from('fiscal_periods').upsert({ - user_id: user.id, - name: periodName, - period_start: startStr, - period_end: endStr, - }, { - onConflict: 'user_id,period_start,period_end', + const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ + user_id: user.id, + name: periodName, + period_start: startStr, + period_end: endStr, + }, { + onConflict: 'user_id,period_start,period_end', + }) + + if (upsertError) { + logError('fiscal period upsert failed', { + message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details, }) } - } catch { - toast({ - title: 'Kunde inte skapa räkenskapsår', - description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', - variant: 'destructive', - }) } - } - - if (nextStep > totalSteps) { - await saveSettings({ onboarding_complete: true }, totalSteps) + } catch (err) { + logError('fiscal period creation threw', { error: String(err) }) + Sentry.captureException(err) toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', + title: 'Kunde inte skapa räkenskapsår', + description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', + variant: 'destructive', }) - router.push('/') - } else { - setCurrentStep(nextStep) } } + + if (nextStep > totalSteps) { + const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) + if (!finalSuccess) { + logError('failed to set onboarding_complete after all steps') + return + } + // Important milestone + console.log(LOG, 'onboarding completed') + toast({ + title: 'Välkommen!', + description: 'Din profil är nu redo.', + }) + router.push('/') + } else { + setCurrentStep(nextStep) + } } const handleBack = () => { @@ -303,9 +422,11 @@ function OnboardingPageContent() { const nextStep = currentStep + 1 const success = await saveSettings({}, nextStep) - if (success) { - setCurrentStep(nextStep) + if (!success) { + logError('skip failed: saveSettings returned false', { step: currentStep }) + return } + setCurrentStep(nextStep) } if (isLoading) { @@ -386,9 +507,18 @@ function OnboardingPageContent() { }} onComplete={async (data) => { if (data) { - await saveSettings(data, totalSteps) + const bankSaved = await saveSettings(data, totalSteps) + if (!bankSaved) { + logError('failed to save bank details at step 5') + return + } } - await saveSettings({ onboarding_complete: true }, totalSteps) + const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) + if (!finalSuccess) { + logError('failed to set onboarding_complete at step 5') + return + } + console.log(LOG, 'onboarding completed') toast({ title: 'Välkommen!', description: 'Din profil är nu redo.', @@ -397,7 +527,12 @@ function OnboardingPageContent() { }} onBack={handleBack} onSkip={async () => { - await saveSettings({ onboarding_complete: true }, totalSteps) + const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) + if (!finalSuccess) { + logError('failed to set onboarding_complete when skipping step 5') + return + } + console.log(LOG, 'onboarding completed') toast({ title: 'Välkommen!', description: 'Din profil är nu redo.', diff --git a/app/api/log/route.ts b/app/api/log/route.ts new file mode 100644 index 00000000..59cfc950 --- /dev/null +++ b/app/api/log/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const { message, extra } = await request.json() + + // This console.error runs server-side → visible in Vercel Logs + console.error('[onboarding]', message, extra ? JSON.stringify(extra) : '') + + return NextResponse.json({ ok: true }) + } catch { + return NextResponse.json({ ok: false }, { status: 400 }) + } +} diff --git a/components/onboarding/Step1EntityType.tsx b/components/onboarding/Step1EntityType.tsx index 6fec0de1..75c0b391 100644 --- a/components/onboarding/Step1EntityType.tsx +++ b/components/onboarding/Step1EntityType.tsx @@ -39,9 +39,13 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1 const [selected, setSelected] = useState(initialData.entity_type) const handleNext = () => { - if (selected) { - onNext({ entity_type: selected }) + if (!selected) { + const msg = 'step 1: fortsätt clicked without entity type selected' + console.error('[onboarding]', msg) + fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg }) }).catch(() => {}) + return } + onNext({ entity_type: selected }) } return ( diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx index a0202dbc..0717f269 100644 --- a/components/onboarding/Step2CompanyDetails.tsx +++ b/components/onboarding/Step2CompanyDetails.tsx @@ -67,7 +67,11 @@ export default function Step2CompanyDetails({ -
+ { + const fields = Object.keys(errs).join(', ') + console.error('[onboarding] step 2 validation failed:', fields, errs) + fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 2 validation failed', extra: { fields } }) }).catch(() => {}) + })} className="space-y-4">