* fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133) Lazy auth is by design: Claude lists the tools before any sign-in and the first company-scoped call answers 401, which opens the Accounted sign-in. Nothing told the user, so a "connected" status with an unanswered first question read as a broken connection (Axel, Discord). - Settings -> API & MCP: one sentence of expectation under the button, and the step-by-step guide link moved from under two disclosures to directly under the button. - Docs (connect-claude / anslut-claude): new "What happens after you click" section for Path A covering the connector dialog, the tools appearing before sign-in, the first-call login + consent screen, "ask again", and the "Required when the server asks" auth setting that only the manual path mentioned. - Hem checklist step "Anslut till Claude": deep link now carries client=claude-connector like the settings button (claudeConnectorLink), the footnote carries the same expectation line plus the guide link, and the done-signal is an unrevoked api_keys row minted by the MCP OAuth token route (OAUTH_MCP_KEY_NAME) instead of the in-app AI-profile flag, which never meant "connected to Claude". - Tests: claudeStepDone with/without a key row, deep-link snapshot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L * fix(mcp): correct consent-page claims, stop the completion PATCH loop, count OAuth keys past RLS (#2133) Three skeptic refutations on PR #2147, fixed in one pass: - Docs (EN + SV): the consent page shows the company active in the app and pre-selects every scope for Claude's connector (founder decision 2026-08-26); it has no company picker and nothing to tick. Steps 3-4 of the new section, the "Read-only by default" paragraph above it, the sandbox note and the 10-minute test now describe Endast läs under Behörigheter instead. - Checklist completion: users with initial_setup_path NULL (skipped the books question, then imported) hit the route's "Välj först hur du vill komma igång" 400 and, with saving as an effect dependency, retried it forever with a toast. completionPatchBody() records path=migration when none was chosen, and a rejected PATCH is not retried within the session. - hasMcpKey: api_keys' SELECT policy is company-scoped, so the user client could not see companyless (NULL company_id) or archived-company keys and the step stayed open for the user who had just connected. The head count now runs through the service client with an explicit user_id filter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L * fix(mcp): surface a failed OAuth-key count and reserve the marker name (#2133) CodeRabbit round on PR #2147: - app/(dashboard)/page.tsx: a failed api_keys count answered count null, which claudeStepDone read as "never connected". Throw to the error boundary like the settings fetch does instead of guessing. - app/api/settings/api-keys: reject a hand-minted key named MCP-klient (OAuth) (400 VALIDATION_ERROR): that name is the marker the Hem checklist reads as "connected to Claude", so a manual key with it would tick the step without any connection. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
240 lines
9.5 KiB
TypeScript
240 lines
9.5 KiB
TypeScript
import { after } from 'next/server'
|
|
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
|
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
|
import ResumePane from '@/components/dashboard/ResumePane'
|
|
import { HemNotices } from '@/components/dashboard/HemNotices'
|
|
import { getWorklistCounts, listSuggestedMatches, SUGGESTED_MATCH_SCAN_CAP } from '@/lib/worklist'
|
|
import { listResumeItems } from '@/lib/worklist/resume'
|
|
import { getCompanyNotices } from '@/lib/notices'
|
|
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
|
|
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
|
import type { InitialSetupState, MomsPeriod, OnboardingProgress } from '@/types'
|
|
import { getDashboardAuthContext } from './request-context'
|
|
|
|
/**
|
|
* Hem's streamed sections. Each is an async server component rendered behind
|
|
* its own <Suspense> in page.tsx, so the greeting shell paints as soon as the
|
|
* page's small first wave resolves and each block fills in when its own
|
|
* queries land. The Supabase client comes from the request-cached auth
|
|
* context, never from props (not serialisable).
|
|
*/
|
|
|
|
type BankConnectionRow = {
|
|
id: string
|
|
status: string
|
|
consent_expires: string | null
|
|
bank_name: string
|
|
last_sie_sweep: unknown
|
|
}
|
|
|
|
type SieSweepSummaryLite = {
|
|
auto_linked?: number
|
|
suggested?: number
|
|
unmatched?: number
|
|
errors?: number
|
|
ran_at?: string
|
|
}
|
|
|
|
export async function HemNoticesSection({
|
|
companyId,
|
|
userId,
|
|
now,
|
|
}: {
|
|
companyId: string
|
|
userId: string
|
|
now: Date
|
|
}) {
|
|
const { supabase } = await getDashboardAuthContext()
|
|
// Degraded-state notices (lib/notices): broken/expiring bank connections,
|
|
// Skatteverket reconnect, failing backups, the wrong-account hint (#1231),
|
|
// priority-ordered and dismissal-filtered. The stale-dismissal reap is
|
|
// hygiene and runs after the response (Next `after`), not on the read path.
|
|
const notices = await getCompanyNotices(supabase, companyId, {
|
|
userId,
|
|
now,
|
|
deferReap: (task) => after(task),
|
|
})
|
|
return <HemNotices notices={notices} />
|
|
}
|
|
|
|
export async function HemChecklistSection({
|
|
companyId,
|
|
userId,
|
|
now,
|
|
initialSetup,
|
|
hasMcpKey,
|
|
vatRegistered,
|
|
momsPeriod,
|
|
}: {
|
|
companyId: string
|
|
userId: string
|
|
now: Date
|
|
initialSetup: InitialSetupState
|
|
/** Live OAuth-minted MCP key exists for this user: see claudeStepDone(). */
|
|
hasMcpKey: boolean
|
|
vatRegistered: boolean
|
|
momsPeriod: MomsPeriod | null
|
|
}) {
|
|
const { supabase } = await getDashboardAuthContext()
|
|
const [
|
|
{ count: customerCount },
|
|
{ count: invoiceCount },
|
|
{ count: transactionCount },
|
|
{ data: bankConnections },
|
|
{ count: sieImportCount },
|
|
{ count: skatteverketTokenCount },
|
|
{ count: inboxItemCount },
|
|
{ data: nextVatDeadline },
|
|
{ data: latestFileSweep },
|
|
] = await Promise.all([
|
|
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
|
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
|
// Skatteverket connections are per (user, company): filtering on user_id
|
|
// alone made a connection on ANY of the user's companies hide the connect
|
|
// nudge on all of them.
|
|
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', userId).eq('company_id', companyId),
|
|
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
|
// marks the receipts checklist step done: same "has ever done X" shape
|
|
// as the other flags above.
|
|
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
|
// Rows are system-generated per company settings; dismissed rows are
|
|
// excluded everywhere deadlines are listed, so here too.
|
|
supabase
|
|
.from('deadlines')
|
|
.select('due_date')
|
|
.eq('company_id', companyId)
|
|
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
|
.eq('is_completed', false)
|
|
.is('dismissed_at', null)
|
|
.gte('due_date', now.toISOString().slice(0, 10))
|
|
.order('due_date', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
// Latest bank-file SIE sweep (PSD2 sync stamps bank_connections.last_sie_sweep
|
|
// instead). Feeds the checklist's bank step with "X matchade, Y att
|
|
// granska". Best-effort: absent rows just render no note.
|
|
supabase
|
|
.from('bank_file_imports')
|
|
.select('sie_sweep')
|
|
.eq('company_id', companyId)
|
|
.not('sie_sweep', 'is', null)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
])
|
|
|
|
const connections = (bankConnections ?? []) as BankConnectionRow[]
|
|
const onboardingProgress: OnboardingProgress = {
|
|
hasCustomers: (customerCount || 0) > 0,
|
|
hasInvoices: (invoiceCount || 0) > 0,
|
|
hasBankConnected: connections.length > 0 || (transactionCount || 0) > 0,
|
|
hasSIEImport: (sieImportCount || 0) > 0,
|
|
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
|
hasInboxItems: (inboxItemCount || 0) > 0,
|
|
}
|
|
|
|
const vatLine = vatDeadlineLine({
|
|
vatRegistered,
|
|
momsPeriod,
|
|
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
|
})
|
|
|
|
const sweepCandidates: SieSweepSummaryLite[] = [
|
|
...connections
|
|
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
|
|
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
|
|
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
|
|
]
|
|
const sieSweep =
|
|
sweepCandidates.length > 0
|
|
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
|
|
: null
|
|
|
|
return (
|
|
<NewUserChecklist
|
|
initialState={initialSetup}
|
|
hasBookkeepingImported={onboardingProgress.hasSIEImport}
|
|
hasBankConnected={onboardingProgress.hasBankConnected}
|
|
hasSkatteverketConnected={onboardingProgress.hasSkatteverketConnected}
|
|
hasInboxItems={onboardingProgress.hasInboxItems}
|
|
hasMcpKey={hasMcpKey}
|
|
vatLine={vatLine}
|
|
sieSweep={
|
|
sieSweep
|
|
? {
|
|
auto_linked: sieSweep.auto_linked ?? 0,
|
|
suggested: sieSweep.suggested ?? 0,
|
|
unmatched: sieSweep.unmatched ?? 0,
|
|
errors: sieSweep.errors ?? 0,
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export async function HemPanesSection({
|
|
companyId,
|
|
now,
|
|
setupOpen,
|
|
}: {
|
|
companyId: string
|
|
now: Date
|
|
setupOpen: boolean
|
|
}) {
|
|
const { supabase } = await getDashboardAuthContext()
|
|
// Fetched once at the scan cap: the Att göra pane shows the first five and
|
|
// the worklist count is the list's length (it used to scan the same rows
|
|
// twice). Everything else runs in the same wave.
|
|
const suggestedMatchesPromise = listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP)
|
|
const [worklist, suggestedMatches, resumeItems, bankConnectionsRes, postedEntries] =
|
|
await Promise.all([
|
|
// Pending-work counts come from lib/worklist: the same source as the
|
|
// sidebar badges, so the numbers can never diverge.
|
|
getWorklistCounts(supabase, companyId, { suggestedMatches: suggestedMatchesPromise }),
|
|
suggestedMatchesPromise,
|
|
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
|
listResumeItems(supabase, companyId, now),
|
|
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
|
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
|
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
|
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
|
])
|
|
|
|
// "Empty ledger" only matters while the setup checklist is still open; once
|
|
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
|
// count must NOT read as empty: that would tell a company with real
|
|
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
|
const emptyLedger = setupOpen && !postedEntries.error && (postedEntries.count || 0) === 0
|
|
|
|
const bankConnections = bankConnectionsRes.data
|
|
// Same degrade-on-error rule as emptyLedger: a failed fetch must NOT tell a
|
|
// connected company that no bank is connected, so errors read as connected.
|
|
const hasActiveBankConnection = !!bankConnectionsRes.error || (bankConnections ?? []).length > 0
|
|
|
|
// Same day-math as the bank_connection_expiring notice predicate
|
|
// (lib/notices/categories.ts): the Bevaka row and the notice can never
|
|
// disagree on the threshold.
|
|
const expiringBankConnections = expiringBankConnectionsFrom(
|
|
(bankConnections ?? []) as BankConnectionRow[],
|
|
now,
|
|
)
|
|
|
|
return (
|
|
<div className={resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined}>
|
|
<AttGoraSection
|
|
worklist={worklist}
|
|
suggestedMatches={suggestedMatches.slice(0, 5)}
|
|
expiringBankConnections={expiringBankConnections}
|
|
emptyLedger={emptyLedger}
|
|
hasActiveBankConnection={hasActiveBankConnection}
|
|
/>
|
|
<ResumePane items={resumeItems} />
|
|
</div>
|
|
)
|
|
}
|