Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)
* fix(sie-import): accept tab as field separator (Bollbok exports) The SIE 4 spec allows either space or tab between fields, but splitSIELine() only treated space (0x20) as a separator. Bollbok exports tab-separated lines for every record except #RAR, which silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS records — imports appeared empty even though the file was well-formed. Also adds a parser-side diagnostic that emits a warning when raw #IB or #VER lines are present in the input but parsing produced none. The previous silent failure is how this bug stayed hidden; the warning gives the import preview something visible to surface next time. Verified against two real reproducer files (Sean / Erik Hellqvist): erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS. erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers. Both now parse with zero warnings/errors. Tests: + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants). + 4 silent-failure diagnostic-warning tests. All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning Two non-blocking P2 findings from Greptile review on PR #513: 1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'. Latent defect — accountType is unused downstream today, but my tab- separator fix made the quoted-value path reachable. Now routes through parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T") land as 'T'. 2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning fired alongside per-record 'error'-severity issues for malformed #IB / #VER records, producing a misleading hint when the parser had already pinpointed the structural problem. Now suppressed when an error-severity issue with the same tag already exists. Test coverage: + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes. + VER aggregate-warning test now uses #VER lines without { } blocks (silent loss, no per-record error) — the canonical case the diagnostic is designed for. + New suppression test: bare #VER produces per-record errors AND the aggregate warning is absent. 75/75 sie-parser tests pass; 156/156 in lib/import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: agent chat + composer + memory + document extraction In-progress work on this branch beyond the SIE-import fixes: - Specialized accountant agent (composer + intents + chat loop) - Persistent agent_conversations/messages, agent_profiles, agent_memory - /chat surface + /onboarding/agent + /settings/agent-memory - document-extraction extension with status hooks - MCP server staging refactor + new skills (atoms, bank reconciliation, customer onboarding, kreditfaktura) - pending_operations rejection feedback (category + reason) + realtime - TIC company profile cached snapshot on companies - 17 migrations (all additive — see prior conversation analysis) Parked while branch waits for review/merge. Migrations are already applied to prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(tic): migrate company-data client from api-core v1 to Lens v2 Swaps the seven TIC company-data endpoints we call from the api-core paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`). Hard cutover; proxy pattern preserved. Schema shifts handled inside the extension so consumers (TicWorkspace, Step2CompanyDetails) don't need changes: - `/companies/{id}/bank-accounts` now returns Bankgirot only — map to the existing `{ type, accountNumber, bic }` shape, drop terminated. - `/companies/{id}/industries` returns a discriminated array — filter to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior. - `/companies/{id}/phone-numbers` renamed the field to `phoneNumberFormatted` (fall back to `e164PhoneNumber`). - `/companies/{id}/documents` replaces `/financial-report-summaries`; filter `type === 'annualReport'` and read nested `financialReportMetadata` to rebuild the legacy summary shape. - `isCeased` is now a top-level boolean; `activityStatus` is an enum. Translate enum -> 'ceased' for the workspace's existing check. BankID identity flow (id.tic.io) is untouched — separate TIC product. Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io with an `x-api-key` Lens key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tic): expose v2 onboarding & workspace data Adds six new Lens (v2) fetchers on top of the migration that already landed in this branch, surfacing the data through /lookup and /profile. New fetchers in lib/tic-client.ts: - getFiscalYears /companies/{id}/fiscal-years - getAccountingPeriods /companies/{id}/accounting-periods - getPayrolls /companies/{id}/payrolls - getSignatory /companies/{id}/signatory - getRepresentatives /companies/{id}/representatives - getCompanyStatus /companies/{id}/status /lookup gains a fiscalYear field (current fiscal-year configuration) so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult extended with optional fiscalYear; consumers without it keep working. /profile gains five new sections on TICCompanyProfile: - fiscalYear + fiscalYearHistory current + deduped period list - signatory firmateckning descriptions - board + representatives board-composition summary + active officers (positionEnd in future) - payrolls payroll2 array newest-first, with deviation vs annual-report - statuses current+historical status entries with red/yellow/green/neutral color TicWorkspace renders the new data as four cards (Status, Fiscal year + Signatory, Board + Representatives, Payroll history) plus a Badge mapping for the traffic-light status color. Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2 paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus Three small wins that unlock more of the v2 cutover. No new endpoints — the data was already in the snapshot, just not flowing where it should. Step 1 (entity_type) — deep-link path only: - /lookup now returns `legalEntityType` and `registrationDate` (added to CompanyLookupResult). - /onboarding/page.tsx does a server-side /lookup prefetch when ?org_number= is present (BankID picker path), maps "AB"/"EF" to the EntityType enum, and seeds Step 1's radio. Falls through silently for unsupported codes (HB, KB, …) and on TIC errors. - WelcomeOnboarding hydrates ticLookup state from the server prefetch so Step 2's debounced client fetch and Step 3's first-year inference both have data on first render — no flash. Step 3 (is_first_fiscal_year) — every path: - deriveFirstYearDefaults() parses ticLookup.registrationDate and returns { isFirstFiscalYear, firstYearStart } when registered <12 months ago. Step 3's initialData picks it up; the user only confirms the end date. - Settings value wins when present so existing users with a saved choice don't get overridden. Composer prompt: - redactTic allowlist was the bottleneck — it stripped beneficialOwners, signatory, board, representatives, payrolls, statuses, fiscalYear before Opus ever saw the JSON. Existing filterRedundantQuestions ownership logic was effectively dead because the data path was severed. Expanded allowlist to include those v2 sections; kept bankAccounts/ email/phone/fiscalYearHistory/financialReports out (token cost > signal). - SYSTEM_PROMPT now documents each v2 section and the rules Opus should apply: payroll signal switches from "registration.payroll" to "actual payrolls[] filings" (kills the false-positive swedish-payroll selection for newly registered employers); beneficialOwners[] becomes the authoritative ownership source (single owner → FMB modifier; multiple → multi-owner); statuses[] isCeased/red triggers an uncertainty_note. Tests: 4112 unchanged. Build: green. No schema or migration changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): onboarding polish + composer signal fixes from first-run feedback UX: - AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval" escape hatch. The fallback path runs automatically on timeout; the manual skip just teased users into a degraded build. - ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna" (imperative form matches the rest of the steps). - Drop em-dashes from user-visible Swedish strings in AgentOnboarding + ReviewCard (fallback labels, subtitles, placeholder, error message, final CTA). Em-dashes survive in code comments only. - "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced: AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard fallback comment, general.help intent buttonLabel + prompt text. - AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink / TransactionInboxCard ask-button all gated on identity.isVerified. Pre-onboarding users no longer see the floating FAB or per-page Sparkle buttons. AgentSheetProvider.identity gained an isVerified field; (dashboard)/layout.tsx selects agent_profiles.verified_at and passes it through. TIC verksamhetsbeskrivning: - tic/index.ts /profile: /companies/{id}/purposes returns every historical verksamhetsföremål filing. Picking [0] was returning the oldest "äga och förvalta" holding-company boilerplate for companies whose later filings narrowed the purpose ("tillhandahålla företagskrediter och finansiella teknologilösningar"). Sort the array by lastUpdatedAtUtc desc and take the most recent non-empty purpose. Composer banking signal: - loadBankingSummary now reads journal_entry_id alongside description/amount/date and returns per-counterparty `direction` ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked). Aggregate `unbooked_count` accompanies the rollup. - buildUserPrompt emits each counterparty as `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost on sight and tell which counterparties are still open questions. - SYSTEM_PROMPT now explicitly forbids verification questions about counterparties whose direction is unambiguous AND status is 'bokförd'. Should kill the regressions from the first agent build: * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is clearly negative. * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is already categorized. Tests: 4112 unchanged. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon Representation booking: - transaction-categorization prompt now requires the agent to capture participants (name + company) AND purpose before staging a representation categorization. SKV's representationsregler + ML 8 kap require the verifikation to document who attended and what the meeting was about; without that the avdrag is denied and the post should be booked as non-deductible / personalkostnad. - The agent confirms back in plain text (audit trail in the chat), writes the deltagare + syfte to gnubok_remember_fact (long-term), THEN stages. Saknas deltagare/syfte: explicitly tell the user the avdrag won't go through and offer the non-deductible alternative. - Known gap (followup, not this commit): the staged op's journal entry description doesn't yet carry the deltagare text. Until we add a `notes` field to gnubok_categorize_transaction, the audit trail lives in chat + agent_memory only. TransactionInboxCard duplicate attachment indicator: - Drop the FileCheck2 "open document" button from the trailing slot. TransactionAttachmentIndicator (Paperclip) next to the description already opens the underlag on click. Two icons doing the same thing was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment, handleOpenAttachment) and dropped now-unused imports (FileCheck2, useToast). Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent,nav): notes on verifikation + redesigned sidebar Audit-trail notes for representation: - gnubok_categorize_transaction gains an optional `notes` string. Threaded through stagePendingOperation → commitCategorizeTransaction → createTransactionJournalEntry, which now appends notes to the entry's description (capped at 500 chars). The verifikation an external auditor reads now carries deltagare + syfte directly — not just chat history / agent_memory. - transaction-categorization prompt updated: representation flow now REQUIRES the agent to pass deltagare+syfte via the notes parameter. Without it the booking is non-deductible / personalkostnad per SKV. DashboardNav redesign: - Top section: flat, no header — Hem (/chat), Underlag (was Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline badge on /pending shows the count when there are pending ops. - Mid section: four collapsible dropdowns (Försäljning, Inköp, Redovisning, Personal). Each auto-expands when the active route lives inside it. KPI moved from main to Redovisning. Extension nav items (TIC workspace, etc.) fold into Redovisning. - Bottom-left: new account popover (DropdownMenu, opens upward) holding CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces the old top company-switcher card + the bottom Support/Logout block. - Mobile drawer mirrors the new structure: top items as flat list, same four dropdown groups, separate "Tillägg" section when extensions exist, "Mitt konto" section at the bottom. - i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag" ("Documents" in en). New keys: mitt_konto, group_extensions. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): unhide Leverantörer under Inköp The /suppliers entry existed in navItems but was marked hidden — leftover from when the supplier list lived elsewhere in the IA. Removing the hidden flag puts Leverantörer in the Inköp dropdown alongside Leverantörsfakturor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): CompanySwitcher back to top-left, user account moves bottom-left The previous pass collapsed both concepts into the bottom popover. They mean different things: the company is the org context everything below operates against (top-of-sidebar, scannable); the user is the account-holder (bottom-of-sidebar, where settings/logout live). - (dashboard)/layout.tsx: fetch profiles.full_name alongside the existing identity queries; pass userName + userEmail into DashboardNav. - DashboardNav: restore CompanySwitcher at the top of the sidebar (pre-redesign placement). Bottom-left popover trigger now shows the signed-in user's name + single-letter initial (accountInitial helper falls back to email's first char, then "?"). Popover header carries full name + email; items unchanged (Inställningar, Hjälp, Support, Logga ut). CompanySwitcher removed from inside the popover — nested dropdowns were awkward and the top placement is where it belongs. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pending): trim the agent context strip The row-level AgentContextStrip on /pending was rendering the model name (eu.anthropic.claude-sonnet-4-6) and the full atoms array (horizontal/swedish-vat, vertical/konsult-it, …) inline, which made each row 60–80 chars of mostly-the-same metadata. Reviewers never scan that text; they scan amounts and decide approve/reject. Now the strip shows only the conversation deep-link (Konversation #<short id>) — the one piece that's actually useful for diving into context. Model + atoms remain available in agent_metadata for debugging surfaces; they're just not in the list view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): shared ground rules + paragraph breaks after tool calls Two regressions surfaced in real usage. Both are systemic. Shared agent ground rules: - /chat surface (general.help) was happily inventing four-digit BAS account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående moms…") and proposing booking decisions on invoices it had never seen, with no follow-up questions about currency/scope/etc. - transaction-categorization had those rules baked into its prompt; general-help / bokslut-step / invoice-draft / supplier-invoice-review / verifikation-draft / vat-review never inherited them. - Extracted lib/agent/intents/shared-rules.ts with five cross-cutting rules: underlag first (check inbox + ask user to upload to Dokumentinkorgen when missing), ask follow-ups when ambiguous, never write four-digit BAS account numbers in chat (category names only), cite atoms / load skills (don't guess), check counterparty history before proposing. - Injected renderAgentGroundRules() into all six intents above. transaction-categorization left alone — it has more detailed inline rules tied to its specific underlag-flow. Paragraph break after tool calls: - text_delta from the model often resumes after a tool call without a leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget historik hittades…" appended directly). Markdown rendered the concatenation as one paragraph. - AgentChat text_delta handler now inserts \n\n when (a) the buffer ends with text content, (b) the incoming delta starts with text content, (c) at least one tool call has run, and (d) the buffer doesn't already end with a blank line. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): default-open dropdown groups; closing is per-user Dropdowns started collapsed which meant first-time users had to open each group to discover what's inside. Inverted the state: default open, user can collapse, active route still forces a group open. - manualExpanded → manualCollapsed (semantics flip) - toggleGroup unchanged externally; flips the bit - isGroupExpanded returns !manualCollapsed[g] || hasActiveChild Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings Three pre-ship quality wins. Rate-limit-safe TIC v2 upgrade: - The /profile endpoint fans out to ~13 Lens calls; the account has a ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across the customer base would blow the budget. - ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still inside the 7-day window is re-fetched only when (a) the caller passes upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only `statuses` key). Gated to the two agent-onboarding call sites — a deliberate, once-per-company action and the only consumer of the v2 sections. Workspace + signup keep the natural 7-day staleness, so the v1→v2 migration is lazy and bounded to companies actually building an agent. Known-counterparty defaults (shared-rules): - Agent now proposes a sensible default for well-known counterparties instead of asking the same question monthly: Almi → lån, Tillväxtverket/ Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring, Bolagsverket → avgift, Försäkringskassan → ersättning, EF private withdrawal → eget uttag. Stated as an assumption the user can correct, not a hard rule — underlag/history still wins. Företagsprofil settings page: - New /settings/agent-profile (Företagsprofil / "Company profile"): view + edit the agent's company profile after onboarding — assistant name + avatar, the profile summary the agent reasons from, and a read-only chip view of loaded specialities (atoms). Backed by the existing GET/PATCH /api/agent/profile. - New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles for the chips (registry is globally-readable reference data). - Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil" / en "Company profile"). Note: /chat already redirects unverified users to / (chat layout guard), and / renders WelcomeGate → /onboarding/agent. No redirect work needed. AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it). Tests: 4112. Build: green. Both new routes compile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup Nav restructure: - "Hem" now points to / (Översikt dashboard) again, not /chat. The agent chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat. Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner). - / restored to render DashboardContent (the Översikt) for built-agent users instead of redirecting to /chat. Users who haven't built their assistant yet still get WelcomeGate (the build-agent checklist); once verified, / shows the dashboard. Chat is reachable anytime via its nav entry. Restored main's dashboard data-fetch; added an agent_profiles verified_at probe to drive the WelcomeGate branch. - i18n: nav.assistant ("Assistent" / "Assistant"). agent_memory dedup (gnubok_remember_fact): - The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd skattskyldighet" on every Vercel categorization), which would bloat agent_memory with paraphrases over months. - Before insert, compare the incoming fact against the 300 most-recent active memories by word-set Jaccard similarity (lowercased, punctuation- stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as already-known: bump its relevance toward the new score + refresh updated_at instead of writing a new row. Embedding-free, zero added latency beyond one bounded SELECT. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting Företagsprofil settings page (the right content this time): - Replaced the agent atoms/summary panel with CompanyProfileView — a read-only "Bolagsuppgifter" view of the cached TIC company snapshot (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank, verksamhet, employees, latest financials, status traffic-lights, fiscal year, firmateckning, företrädare). Server component reads the companies.tic_snapshot column directly — no extension import, stays inside the core-build boundary. - Route renamed /settings/agent-profile → /settings/company-profile. Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles endpoint. "Assistent" nav icon = the agent's chosen avatar: - DashboardNav reads agent identity from AgentSheetProvider and renders the onboarding-chosen avatar for the /chat ("Assistent") entry across desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to the Sparkles glyph pre-onboarding (no avatar yet). Nav cleanup: - Dropped the beta badge from Underlag. - Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of the nav — the same Bolagsuppgifter now lives under Inställningar → Företagsprofil, so it shouldn't appear in two places. Doubled intake greeting fix: - /chat/intake fires an invoke with no conversation_id, then swaps the URL to /chat/[id] the instant the `conversation` event lands — which can beat the greeting being persisted. /chat/[id] then hydrated with 0 messages and, because the auto-fire guard keyed on (id && messages>0), fired a SECOND invoke on the same conversation → two greetings. Guard now keys on conversation-id presence alone: a set id means resume, never bootstrap. Closes the race. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): paragraph-break-after-tool split words mid-stream The earlier "insert \n\n when text resumes after a tool call" heuristic re-evaluated on EVERY text_delta (any delta not starting/ending with whitespace, once a tool had run). Streaming deltas arrive in sub-word chunks, so it injected breaks between fragments of the same word: "minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation". Replace the per-delta heuristic with a consume-once ref: - tool_use sets breakBeforeNextTextRef = true - the next text_delta consumes it: prepends \n\n exactly once (only when the buffer has content, doesn't already end in whitespace, and the delta doesn't start with whitespace), then clears the flag So the break fires once per tool→text resume, never mid-word. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): much shorter replies, representation headcount + VAT cap, dot separator Brevity (system-prompt Svarsformat — affects every reply): - Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with the answer/action, no warm-up ("Här är vad som gäller…"), don't derive VAT in prose, don't restate what the approval card shows, one question at a time. The agent was writing textbook-length essays. Representation rule now in shared-rules (so verifikation-draft, vat-review, etc. all get it — previously only transaction-categorization had it, which is why the verifikation flow guessed 25% VAT and skipped the cap): - Require ANTAL deltagare (headcount), not just one name — the moms deduction is per person (underlag cap 300 kr/person ex moms). - Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume 25%. - Meal representation isn't income-tax deductible (post-2017); whole cost booked as non-deductible representation. Verifikation description separator: - createTransactionJournalEntry appended notes with an em-dash ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a middle dot " · ". journal_entries has no separate notes column — the description IS the BFL verifikationstext / audit field, so deltagare + syfte correctly live there. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning From first-look feedback on the Företagsprofil page: - Status: dropped the coloured traffic-light badges (red/yellow/green). Per the design system semantic colour is data-only, never chrome, so status now renders as plain label + date. Also filtered to dated entries only — Bolagsverket emits flags like "Har aldrig varit verksam" with no date that read as noise next to the real status. Ceased status gets muted destructive text (the one chrome colour the system keeps). - Firmateckning: the source text carries ">" list markers and crams several rules onto one line, and repeats "Firman tecknas av styrelsen" across rows. cleanSignatory() strips the markers, normalises whitespace, splits run-on "Firman tecknas …" clauses onto separate lines, and the render dedupes — so each rule reads as its own sentence. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): inbox items expose all terminal links + processed flag The Eatnam receipt was booked against its bank transaction (so the inbox row had matched_transaction_id + created_journal_entry_id set), yet the agent reported it as loose/unmatched and a duplicate risk. Root cause: gnubok_list_inbox_items only selected and returned matched_supplier_id + created_supplier_invoice_id — the supplier-invoice path. The transaction-match and direct-journal-entry paths were invisible, so any receipt cleared via /transactions looked unprocessed. - list_inbox_items now selects + returns matched_transaction_id and created_journal_entry_id alongside the supplier fields, plus a derived `processed` boolean (true when ANY of the three terminal links is set). - New unprocessed_only=true input filters to items with no terminal link — the "what still needs handling" view that prevents the agent from flagging already-booked docs as duplicates. (Fetches a wider window then filters client-side so limit applies post-filter.) - Description updated to document the processed semantics, within the 280-char tool-description budget. The DB linkage itself already worked: /transactions attach-document sets matched_transaction_id, and commitCategorizeTransaction stamps created_journal_entry_id. This was purely a read/surface gap. Tests: 4112 (+ MCP description guard). Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): repair stage-but-never-commit tools + consolidate tool surface - post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member. - Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration. - import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count. - batch-match-invoices passed user.id where companyId was expected (silently matched zero). - VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): load skill atom bodies from the DB so they survive the build Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead: - Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard. - Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms). - The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors - Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate. - FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page). - Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users. - Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status. - /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(pending): declutter the review queue rows + header Fold the conversation deep-link onto the actor label (drop the separate "Konversation #xxxx" strip and its icon), hide the quick-pick when there's only one operation type (it duplicated "Markera alla"), and drop the "(0)" from the disabled bulk-approve button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(vat): enhance VAT handling by integrating document validation and improving error messaging * feat(settings): add assistant knowledge surface + consolidate settings tabs Expose the agent's skill atoms (agent_atom_registry) in a read-only surface beside the existing memory view, and tighten the settings tab bar from 14 to 10 tabs. - New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags which are active for the company from agent_profiles, and lazy-loads each SKILL.md body on expand. - New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills); /settings/agent-memory and /settings/agent-skills redirect into it. - Merge Företagsprofil (TIC snapshot) into the Företag tab via CompanyProfileSection; /settings/company-profile redirects. - Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the callback toast now target /settings/tax; /settings/skatteverket redirects. - Drop the Säkerhetsbackup tab (already under Importera/Exportera). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(inbox): keep booked underlag out of the unmatched queue + widen match window - categorize: after booking an inbox underlag onto a verifikat, backfill the inbox row's matched_transaction_id + created_journal_entry_id so it stops showing as unmatched (mirrors the /attach-document paperclip path). - TransactionMatchPicker: bias the candidate window forward (60d before → 180d after the invoice date) so late payments aren't dropped before scoring, and widen the ranking date tolerance to 120d so the true match floats to the top instead of collapsing to "Svag match". Fix "okatigoriserade" typo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: bundle in-progress branch work + agent onboarding chat optimizations Captures the uncommitted work-in-progress on this branch so it lives on the remote. Heterogeneous changeset — bundled as one commit since the work was already entangled across files. Headline change in this commit (from this session): - Remove the double interview in agent onboarding. Phase B's verification- question form stepper is gone — the Phase C chat (onboarding.intake) now owns the entire interview and reads the composer's verification_questions server-side as its question bank. - ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with value-first ordering: profile + "vad jag kan hjälpa dig med" + facts + optional seed note. CTA reads "Möt {namn}" to signal the chat follows. - ChatIntakeStarter handoff subcopy updated to match reality (assistant greets first; user can leave anytime). - Stamp agent_profiles.intake_completed_at server-side in app/api/agent/invoke/route.ts on the first user-typed reply in any onboarding.intake conversation (idempotent IS NULL guard, best-effort). Closes the previously dead-write column and unlocks the opportunistic- follow-up hook the migration anticipated. Plus in-progress branch work being carried forward (not introduced here): agent runtime + intent prompts, composer + atom-discovery scripts, MCP server skills surface, onboarding flow components, dashboard/inbox tweaks, two new agent_atom_registry migrations, additional agent-chat tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware and picks the right intent per page, so duplicating it as inline page- header buttons and empty-state links is noise. Removed: - EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång") + the AgentHelpLink component + agent_default_name/agent_ask_link i18n keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions. - AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi (kpi.explain) page headers. The FAB stays — when verified, it appears on those routes and routes to the right intent automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): gate the last two ungated "Fråga assistenten" affordances Both surfaces previously called useAgentSheet directly without checking identity.isVerified, so they appeared pre-onboarding (everywhere else the FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at). - Settings page header: remove the "Fråga {namn}" pill entirely. The FAB covers /settings routes route-aware (settings.help) — no need for a duplicate inline trigger. - Invoice inbox transaction picker: hide the "Fråga assistenten" button when the agent isn't built. Done at the parent (InvoiceInboxWorkspace) by passing onAskAssistant only when identity.isVerified is true; the child renders the button only when the callback is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice - TIC: collapse the company lookup from 6 endpoint calls to 1 (search-public already exposes sniCodes, bank accounts, emails, phones, and registration flags). Derive fiscal-year MM-DD from mostRecentFinancialSummary; newly-registered companies fall through to the client's first-year defaults. - Onboarding: BankID picker no longer auto-provisions companies. Every pick routes through the wizard with orgnr (and entity_type via the CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding reuses CompanyLookupResult and adds a defensive top-level catch so server-action errors surface to the UI instead of being redacted. - Agent composer: loadUserDirectorship() checks BankID CompanyRoles for a director-like position (ceo/boardMember/chairman/externalSignatory, active) before the narrative uses second-person ownership voice ("Du driver…"); unknown users get neutral third-person voice so we never put ownership words in the user's mouth. Tests cover loadUserDirectorship, narrative voice, tic-fetch path, onboarding page, and updated TIC client + lookup/profile suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers The 5s TIC fetch timeout aborted client-side before the upstream Lens fan-out (~13 calls) could complete, but the in-flight upstream calls still counted against quota — actions.ts already documents ~530 wasted calls from this in May. Same bug still applied to the agent-onboarding stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so deliberate wait-screen callers (agent onboarding stream) can run with 10s while background/dev callers stay on the conservative 5s default. Page-level server fetch (page.tsx) intentionally stays at 5s to avoid blocking TTFB without a visible progress affordance. Backfill migration mirrors `company_settings.org_number` to `companies.org_number` for the 105 cases where it's safe (after dedup + conflict filtering). 56 of those are on active companies — unblocks duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC API calls — pure data move. Idempotent. Also sweeps a pre-existing SSRF guard on the stream route's origin derivation that was sitting unstaged in the working tree — it lives in the same diff hunks as the TIC budget change and couldn't be split cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: bundle in-progress branch work Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully backed up to origin. Not reviewed in detail — committed as-is to preserve working state alongside the TIC fixes in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta Adds a Beta badge next to the assistant-setup heading on the dashboard banner, dashboard inline card, and onboarding checklist row. Also drops the stale "Gratis i 30 dagar" subline from the dashboard card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions PR #584 went red on three things: 1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on 'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing both literals. Add them to the union. 2. Supabase preview: migration version 20260526120000 collided with main's newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql. Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of 20260526120100_restvardeavskrivning so ordering is preserved. 3. 20260527170000 was used twice on this branch (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second to 20260527170100 so the pair stays orderable and Supabase doesn't choke on the duplicate schema_migrations PK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): reword comment so core-only guard stops flagging it The "Check no core imports from extensions" step greps for the literal \`from '@/extensions/\` across lib/, app/api/, components/. A comment in lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to explain *why* the file does a self-fetch instead of importing the TIC extension directly — which the grep matched even though no actual import exists. Rewrite the line to keep the same meaning without the literal pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Emil
parent
a9b43ebeb7
commit
f53725b20a
@@ -137,12 +137,14 @@ export default function BookkeepingPage() {
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
<Button variant="outline" asChild className="w-full sm:w-auto">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" asChild className="w-full sm:w-auto">
|
||||
<Link href="/bookkeeping/year-end">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{t('year_end')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, Lock } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -200,14 +201,22 @@ export default function YearEndPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h1 className="font-display text-3xl md:text-4xl tracking-tight">Årsbokslut</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/bookkeeping">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Bokföring
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<AgentSparkleButton
|
||||
intentId="bokslut.step"
|
||||
intentArgs={{ step_id: null }}
|
||||
contextRef="bokslut:overview"
|
||||
size="default"
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/bookkeeping">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Bokföring
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periods === null && !periodsError && (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import ChatConversationView from '@/components/agent/ChatConversationView'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// /chat/[id] — server-renders the conversation row + ordered messages, then
|
||||
// hydrates the client AgentChat with them so the user can continue typing
|
||||
// against the existing conversation_id. The agent loop on the server picks
|
||||
// up via /api/agent/invoke with conversation_id supplied.
|
||||
export default async function ChatConversationPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
const { data: conversation } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!conversation) notFound()
|
||||
|
||||
const { data: messages } = await supabase
|
||||
.from('agent_messages')
|
||||
.select('role, content, hidden, created_at')
|
||||
.eq('conversation_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
return (
|
||||
<ChatConversationView
|
||||
conversationId={id}
|
||||
intentId={conversation.intent_id}
|
||||
contextRef={conversation.context_ref}
|
||||
title={conversation.title ?? intentLabel(conversation.intent_id)}
|
||||
rawMessages={(messages ?? []) as { role: string; content: unknown; hidden?: boolean }[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function intentLabel(intentId: string): string {
|
||||
switch (intentId) {
|
||||
case 'general.help':
|
||||
return 'Fråga din assistent'
|
||||
case 'transaction.categorization':
|
||||
return 'Hjälp med transaktion'
|
||||
case 'invoice.draft':
|
||||
return 'Hjälp med faktura'
|
||||
case 'supplier_invoice.review':
|
||||
return 'Granska leverantörsfaktura'
|
||||
default:
|
||||
return intentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// /chat/intake — Phase C bootstrap surface. ReviewCard navigates here after
|
||||
// Phase B "kör" succeeds. The client component mounts AgentChat with
|
||||
// intent='onboarding.intake' in fresh-start mode; AgentChat auto-fires the
|
||||
// first invoke which creates the conversation row, and we swap the URL to
|
||||
// /chat/[id] when the new id streams back.
|
||||
//
|
||||
// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
|
||||
export default async function ChatIntakePage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
return <ChatIntakeStarter />
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import ChatSidebar from '@/components/agent/ChatSidebar'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Two-pane chat layout: sidebar with conversations on the left, active
|
||||
// conversation (or empty state) in the main panel. Both /chat and /chat/[id]
|
||||
// share this layout so the sidebar doesn't unmount on conversation switches.
|
||||
export default async function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
// Block the chat surface until the agent is built. Without this a user
|
||||
// who deep-links to /chat (bookmark, ⌘K, "+ Ny" elsewhere) lands on an
|
||||
// empty conversations list with no Anna to talk to. The home route at /
|
||||
// renders NewUserChecklist for the same state, so we forward there
|
||||
// instead of duplicating the welcome screen here.
|
||||
const { data: agent } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!agent?.verified_at) redirect('/')
|
||||
|
||||
const { data: conversations } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select(
|
||||
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('archived', false)
|
||||
.order('pinned', { ascending: false })
|
||||
.order('last_message_at', { ascending: false, nullsFirst: false })
|
||||
.limit(100)
|
||||
|
||||
return (
|
||||
// MainContainer hands /chat a full-bleed h-full wrapper, so we don't
|
||||
// need negative margins to break out of any chrome padding.
|
||||
//
|
||||
// dvh handles mobile browser chrome shrinking on scroll. Mobile: subtract
|
||||
// the bottom nav (h-16 = 64px) + safe-area-inset-bottom so the chat
|
||||
// pane fills the visible viewport exactly. Desktop: full viewport.
|
||||
<div className="flex h-[calc(100dvh-4rem-env(safe-area-inset-bottom,0px))] md:h-screen">
|
||||
<ChatSidebar initialConversations={conversations ?? []} />
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-background">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import ChatNewStarter from '@/components/agent/ChatNewStarter'
|
||||
import { getIntent } from '@/lib/agent/intents/registry'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ intent?: string; prompt?: string }>
|
||||
}
|
||||
|
||||
// /chat/new — generic conversation bootstrap. Reads ?intent= and ?prompt=
|
||||
// from the URL and mounts AgentChat in fresh mode; AgentChat creates the
|
||||
// conversation server-side on first invoke and the client swaps the URL
|
||||
// to /chat/[id] when the id streams back. Mirrors /chat/intake but with
|
||||
// caller-chosen intent/seed, so suggestion chips and ⌘K can route here
|
||||
// inline instead of opening the slide-in sheet.
|
||||
export default async function ChatNewPage({ searchParams }: PageProps) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
const sp = await searchParams
|
||||
const requested = typeof sp.intent === 'string' && sp.intent.trim() ? sp.intent.trim() : 'general.help'
|
||||
// Validate against the registry — a bogus ?intent= would otherwise render the
|
||||
// chat shell and then fail at invoke with a 400, which reads as "Anna is broken"
|
||||
// rather than "bad link". Fall back to general help instead.
|
||||
const intent = getIntent(requested) ? requested : 'general.help'
|
||||
const prompt = typeof sp.prompt === 'string' ? sp.prompt : ''
|
||||
|
||||
return <ChatNewStarter intentId={intent} seedUserMessage={prompt} />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ChatEmptyState from '@/components/agent/ChatEmptyState'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Empty state for /chat. The sidebar (in the layout) shows the list; this
|
||||
// view appears when no specific conversation is selected. Offers an obvious
|
||||
// entry to start a fresh general.help conversation.
|
||||
export default function ChatIndexPage() {
|
||||
return <ChatEmptyState />
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
|
||||
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import {
|
||||
ROT_WORK_TYPES,
|
||||
RUT_WORK_TYPES,
|
||||
@@ -599,7 +600,7 @@ export default function NewInvoicePage() {
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{titleText}
|
||||
{numberPreview && (
|
||||
@@ -610,6 +611,11 @@ export default function NewInvoicePage() {
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{subtitleText}</p>
|
||||
</div>
|
||||
<AgentSparkleButton
|
||||
intentId="invoice.draft"
|
||||
intentArgs={{ customer_id: watchCustomerId ?? null }}
|
||||
contextRef={watchCustomerId ? `customer:${watchCustomerId}` : 'invoice:new'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasBankDetails === false && (
|
||||
|
||||
@@ -83,11 +83,13 @@ export default function KpiPage() {
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
saving={isSavingPrefs}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
saving={isSavingPrefs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FiscalYearSelector
|
||||
|
||||
+95
-59
@@ -4,6 +4,9 @@ import { headers } from 'next/headers'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import { MainContainer } from '@/components/dashboard/MainContainer'
|
||||
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
|
||||
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
|
||||
import AgentTrigger from '@/components/agent/AgentTrigger'
|
||||
import CommandPalette from '@/components/common/CommandPalette'
|
||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||
@@ -84,26 +87,28 @@ export default async function DashboardLayout({
|
||||
isSandbox: false,
|
||||
}}
|
||||
>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
className="safe-area-main-padding md:!pb-0 md:pl-64"
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AgentSheetProvider>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
className="safe-area-main-padding md:!pb-0 md:pl-64"
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AgentSheetProvider>
|
||||
</CompanyProvider>
|
||||
)
|
||||
}
|
||||
@@ -136,27 +141,35 @@ export default async function DashboardLayout({
|
||||
|
||||
return (
|
||||
<CompanyProvider value={companyContextValue}>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AgentSheetProvider>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AgentSheetProvider>
|
||||
</CompanyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
|
||||
const [
|
||||
{ data: settings },
|
||||
{ count: uncategorizedCount },
|
||||
{ count: pendingOpsCount },
|
||||
{ data: agentProfileIdentity },
|
||||
{ data: userProfile },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, onboarding_complete, entity_type, is_sandbox')
|
||||
@@ -172,6 +185,17 @@ export default async function DashboardLayout({
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending'),
|
||||
// Agent identity — name + avatar — surfaced on the FAB and chat
|
||||
// surfaces. Null when no agent_profile exists yet (banner CTA path).
|
||||
supabase
|
||||
.from('agent_profiles')
|
||||
.select('display_name, avatar_id, verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
// The signed-in user's profile — shown in the bottom-left account
|
||||
// popover (full_name + initial) so it's clear which user is logged
|
||||
// in, distinct from the active company shown at the top.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
])
|
||||
|
||||
// If onboarding incomplete, still render the dashboard — the page component
|
||||
@@ -203,28 +227,40 @@ export default async function DashboardLayout({
|
||||
|
||||
return (
|
||||
<CompanyProvider value={companyContextValue}>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||
>
|
||||
Hoppa till innehåll
|
||||
</a>
|
||||
{isSandbox && <SandboxBanner />}
|
||||
<DashboardNav
|
||||
companyName={settings?.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
uncategorizedTransactionCount={uncategorizedCount ?? 0}
|
||||
pendingOperationsCount={pendingOpsCount ?? 0}
|
||||
isSandbox={isSandbox}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
</div>
|
||||
<AgentSheetProvider
|
||||
identity={{
|
||||
displayName: agentProfileIdentity?.display_name ?? null,
|
||||
avatarId: agentProfileIdentity?.avatar_id ?? null,
|
||||
isVerified: Boolean(agentProfileIdentity?.verified_at),
|
||||
}}
|
||||
>
|
||||
<CompanyTabSync />
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||
>
|
||||
Hoppa till innehåll
|
||||
</a>
|
||||
{isSandbox && <SandboxBanner />}
|
||||
<DashboardNav
|
||||
companyName={settings?.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
uncategorizedTransactionCount={uncategorizedCount ?? 0}
|
||||
pendingOperationsCount={pendingOpsCount ?? 0}
|
||||
isSandbox={isSandbox}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
userName={userProfile?.full_name ?? null}
|
||||
userEmail={user.email ?? null}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</AgentSheetProvider>
|
||||
</CompanyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { cookies } from 'next/headers'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import WelcomeGate from '@/components/onboarding/WelcomeGate'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Home route = Översikt (DashboardContent). The agent chat has its own nav
|
||||
// entry at /chat, so / no longer forwards there. New users who haven't built
|
||||
// their assistant yet get WelcomeGate (the build-agent checklist) instead of
|
||||
// the dashboard; once the agent is verified, / renders the normal Översikt.
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -76,6 +82,7 @@ export default async function DashboardPage() {
|
||||
{ count: staleUncategorizedCount },
|
||||
{ count: uncategorizedCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
{ data: agentProfile },
|
||||
{ data: noDocRequiredEntries },
|
||||
] = await Promise.all([
|
||||
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
||||
@@ -106,6 +113,7 @@ export default async function DashboardPage() {
|
||||
// carry the active company_id; either filter would work — we use user_id
|
||||
// because that's what the token-store reads/writes against.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
||||
supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(),
|
||||
supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId),
|
||||
])
|
||||
|
||||
@@ -114,6 +122,34 @@ export default async function DashboardPage() {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
const agentBuilt = Boolean(agentProfile?.verified_at)
|
||||
|
||||
// "Has the company already been used?" Any real business data means we must
|
||||
// NOT hijack the dashboard with the full-screen onboarding gate — existing
|
||||
// and migrated users get the normal Översikt with a build-assistant prompt
|
||||
// in the hero slot (see DashboardContent's agentBuilt branch) instead.
|
||||
const hasData =
|
||||
(transactionCount || 0) > 0 ||
|
||||
(sieImportCount || 0) > 0 ||
|
||||
(invoiceCount || 0) > 0 ||
|
||||
(receiptCount || 0) > 0 ||
|
||||
(customerCount || 0) > 0 ||
|
||||
(postedEntriesCount || 0) > 0
|
||||
|
||||
// Only a genuinely empty company without an assistant sees the full
|
||||
// onboarding checklist (where building the assistant is the last step).
|
||||
// Everyone else falls through to the dashboard below.
|
||||
if (!agentBuilt && !hasData) {
|
||||
return (
|
||||
<WelcomeGate
|
||||
companyId={companyId}
|
||||
hasBookkeepingImported={(sieImportCount || 0) > 0}
|
||||
hasBankConnected={(transactionCount || 0) > 0}
|
||||
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
@@ -252,6 +288,7 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<DashboardContent
|
||||
companyId={companyId}
|
||||
agentBuilt={agentBuilt}
|
||||
summary={{
|
||||
ytd: ytdTotals,
|
||||
mtd: mtdTotals,
|
||||
|
||||
@@ -26,9 +26,19 @@ import {
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ArrowLeftRight,
|
||||
@@ -37,8 +47,16 @@ import {
|
||||
Bot,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import type { PendingOperation, PendingOperationStatus } from '@/types'
|
||||
import type {
|
||||
PendingOperation,
|
||||
PendingOperationStatus,
|
||||
PendingOperationRejectionCategory,
|
||||
} from '@/types'
|
||||
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
|
||||
import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview'
|
||||
|
||||
@@ -82,9 +100,12 @@ function bulkActionLabel(operationType: string, count: number, t: (key: string)
|
||||
return `${count} × ${fallback}`
|
||||
}
|
||||
|
||||
// Full-sentence warning for the single-op confirmation dialog. Phrased so the
|
||||
// user sees the consequence of clicking Godkänn, not a generic verifikation note.
|
||||
// Full-sentence warning for the single-op confirmation dialog AND the inline
|
||||
// list-view warning when risk is medium/high. The list-view truncates beyond
|
||||
// one line; the dialog shows it in full. Order roughly low → high risk so
|
||||
// reviewers scanning the source see the destructive paths grouped together.
|
||||
const singleActionWarnings: Record<string, string> = {
|
||||
// Low/medium risk — light verifikation work
|
||||
create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.',
|
||||
create_customer: 'Genom att klicka godkänn så skapar du en kund.',
|
||||
create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).',
|
||||
@@ -95,15 +116,60 @@ const singleActionWarnings: Record<string, string> = {
|
||||
send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.',
|
||||
mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.',
|
||||
mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.',
|
||||
create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt verifikationsnummer.',
|
||||
correct_entry: 'Genom att klicka godkänn så bokförs en storno och en ny korrigerad verifikation i samma period (BFL 5 kap 5§).',
|
||||
reverse_entry: 'Genom att klicka godkänn så makuleras verifikationen via en storno i samma period.',
|
||||
// High risk — period/year-end/voucher edits. These are the ones the reviewer
|
||||
// really needs the warning for, so we keep them concrete: name the
|
||||
// irreversibility or compliance consequence, not the generic risk-level.
|
||||
lock_period: 'Genom att klicka godkänn så låses perioden — inga nya verifikationer kan bokföras tills den låses upp.',
|
||||
unlock_period: 'Genom att klicka godkänn så låses perioden upp. Använd endast för rättelser; lås igen efter.',
|
||||
close_period: 'Genom att klicka godkänn så stängs perioden permanent (BFL). Stängningen kan inte ångras.',
|
||||
run_year_end: 'Genom att klicka godkänn så körs bokslut: resultatkonton nollställs, perioden låses, nästa period skapas.',
|
||||
set_opening_balances: 'Genom att klicka godkänn så bokförs ingående balans i nästa period.',
|
||||
run_currency_revaluation: 'Genom att klicka godkänn så bokförs valutaomvärdering (3960/7960).',
|
||||
create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt löpnummer.',
|
||||
correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).',
|
||||
reverse_entry: 'Genom att klicka godkänn så stornas verifikationen — originalet behålls synligt (BFL 5 kap).',
|
||||
credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.',
|
||||
credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.',
|
||||
approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.',
|
||||
convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.',
|
||||
import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.',
|
||||
explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).',
|
||||
post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning — en verifikation per tillgång.',
|
||||
}
|
||||
|
||||
function singleActionWarning(operationType: string): string {
|
||||
return singleActionWarnings[operationType] ?? ''
|
||||
}
|
||||
|
||||
// Period status carried inside preview_data when stagePendingOperation can
|
||||
// resolve it. Shape mirrors PeriodStatusForDate in lib/core/bookkeeping/period-service.ts.
|
||||
interface PeriodStatusShape {
|
||||
period_id: string | null
|
||||
status: 'open' | 'locked' | 'closed'
|
||||
lock_date: string | null
|
||||
}
|
||||
|
||||
function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null {
|
||||
const raw = (op.preview_data as Record<string, unknown>)?.period_status
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const obj = raw as Record<string, unknown>
|
||||
const status = obj.status
|
||||
if (status !== 'open' && status !== 'locked' && status !== 'closed') return null
|
||||
return {
|
||||
period_id: typeof obj.period_id === 'string' ? obj.period_id : null,
|
||||
status,
|
||||
lock_date: typeof obj.lock_date === 'string' ? obj.lock_date : null,
|
||||
}
|
||||
}
|
||||
|
||||
const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, string> = {
|
||||
wrong_category: 'Fel kategori / konto',
|
||||
wrong_amount: 'Fel belopp',
|
||||
duplicate: 'Dubblett',
|
||||
wrong_period: 'Fel period',
|
||||
other: 'Annat',
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
@@ -365,7 +431,9 @@ function renderPrimitive(value: unknown): string {
|
||||
}
|
||||
|
||||
function GenericPreview({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = Object.entries(data).filter(([, v]) => v != null && v !== '')
|
||||
// Skip period_status here — it's surfaced in the dedicated banner, not the
|
||||
// generic key-value dump (otherwise the approver sees the same fact twice).
|
||||
const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status')
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{entries.map(([key, value]) => (
|
||||
@@ -406,6 +474,34 @@ function OperationPreview({ op }: { op: PendingOperation }) {
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline period-lock banner. Renders when the staged operation touches a
|
||||
* period that's already locked or closed — the server's commit-time trigger
|
||||
* will reject it, so we tell the approver up front rather than letting them
|
||||
* click and see a generic "Misslyckades" toast. The fiscal_period_id link
|
||||
* goes to the periods management page where unlocking is possible.
|
||||
*/
|
||||
function PeriodLockBanner({ period }: { period: PeriodStatusShape }) {
|
||||
const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm">
|
||||
<Lock className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-destructive">
|
||||
{period.status === 'closed'
|
||||
? 'Perioden är stängd permanent (BFL) — kan inte ändras.'
|
||||
: `Perioden är låst${lockedThrough ? ` t.o.m. ${lockedThrough}` : ''}.`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{period.status === 'closed'
|
||||
? 'Använd en omprövning i en öppen period i stället.'
|
||||
: 'Lås upp perioden via Bokföring → Räkenskapsperioder, ändra entry-datum, eller avvisa.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'agent' | 'high_risk'
|
||||
|
||||
const sourceFilterLabels = (
|
||||
@@ -425,6 +521,7 @@ export default function PendingOperationsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [conversationFilter, setConversationFilter] = useState<string | null>(null)
|
||||
const [counts, setCounts] = useState<StatusCounts>({
|
||||
pending: null,
|
||||
committed: null,
|
||||
@@ -437,8 +534,22 @@ export default function PendingOperationsPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBulkDialog, setShowBulkDialog] = useState(false)
|
||||
const [isBulkCommitting, setIsBulkCommitting] = useState(false)
|
||||
// Reject dialog state — separate from the generic destructive-confirm so we
|
||||
// can ask for a category + free-text reason that feeds back to the agent.
|
||||
const [rejectOp, setRejectOp] = useState<PendingOperation | null>(null)
|
||||
const [rejectCategory, setRejectCategory] = useState<PendingOperationRejectionCategory | ''>('')
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
// Read ?conversation= once on mount so deep-links from the agent context
|
||||
// strip filter the list automatically.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const url = new URL(window.location.href)
|
||||
const conv = url.searchParams.get('conversation')
|
||||
if (conv) setConversationFilter(conv)
|
||||
}, [])
|
||||
|
||||
const fetchOperations = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
@@ -479,10 +590,34 @@ export default function PendingOperationsPage() {
|
||||
fetchAllCounts()
|
||||
}, [fetchAllCounts])
|
||||
|
||||
// Realtime subscription: refetch when ANY pending_operations row changes for
|
||||
// this company. RLS scopes the channel automatically — we don't see other
|
||||
// tenants' events. We refetch the whole list (rather than patching state
|
||||
// in-place) so server-side filtering, sorting, and computed fields stay in
|
||||
// sync with whatever the API route returned. The counts endpoint isn't
|
||||
// pushed by the same trigger, so we also refresh counts on every change.
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
const channel = supabase
|
||||
.channel('pending_operations:list')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'pending_operations' },
|
||||
() => {
|
||||
fetchOperations()
|
||||
fetchAllCounts()
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}, [fetchOperations, fetchAllCounts])
|
||||
|
||||
// Clear selection when filters/tab change
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [activeTab, sourceFilter])
|
||||
}, [activeTab, sourceFilter, conversationFilter])
|
||||
|
||||
async function handleCommit() {
|
||||
if (!selectedOp) return
|
||||
@@ -552,27 +687,51 @@ export default function PendingOperationsPage() {
|
||||
setIsBulkCommitting(false)
|
||||
}
|
||||
|
||||
async function handleReject(op: PendingOperation) {
|
||||
const ok = await confirm({
|
||||
title: 'Avvisa operation?',
|
||||
description: `"${op.title}" kommer att avvisas.`,
|
||||
confirmLabel: 'Avvisa',
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!ok) return
|
||||
function openRejectDialog(op: PendingOperation) {
|
||||
setRejectOp(op)
|
||||
setRejectCategory('')
|
||||
setRejectReason('')
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!rejectOp) return
|
||||
setIsRejecting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/pending-operations/${op.id}/reject`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Misslyckades')
|
||||
toast({ title: 'Avvisad', description: op.title })
|
||||
const body =
|
||||
rejectCategory || rejectReason.trim()
|
||||
? {
|
||||
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
|
||||
...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}),
|
||||
}
|
||||
: undefined
|
||||
const res = await fetch(`/api/pending-operations/${rejectOp.id}/reject`, {
|
||||
method: 'POST',
|
||||
...(body
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
throw new Error(json.error || 'Misslyckades')
|
||||
}
|
||||
toast({ title: 'Avvisad', description: rejectOp.title })
|
||||
setRejectOp(null)
|
||||
fetchOperations()
|
||||
fetchAllCounts()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte avvisa', variant: 'destructive' })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte avvisa',
|
||||
description: err instanceof Error ? err.message : 'Okänt fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsRejecting(false)
|
||||
}
|
||||
|
||||
const filteredOperations = operations.filter((op) => {
|
||||
if (conversationFilter && op.agent_metadata?.conversation_id !== conversationFilter) {
|
||||
return false
|
||||
}
|
||||
switch (sourceFilter) {
|
||||
case 'agent':
|
||||
return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron'
|
||||
@@ -585,8 +744,19 @@ export default function PendingOperationsPage() {
|
||||
})
|
||||
|
||||
const showBulkControls = activeTab === 'pending'
|
||||
// Pending ops that meet two criteria: not high risk AND the period covering
|
||||
// them is open. We exclude locked/closed periods from bulk because they will
|
||||
// be rejected at commit time anyway — silently letting the user "select all"
|
||||
// and watching some fail is a worse UX than excluding them up front.
|
||||
const bulkEligible = useMemo(
|
||||
() => filteredOperations.filter((op) => op.status === 'pending' && op.risk_level !== 'high'),
|
||||
() =>
|
||||
filteredOperations.filter((op) => {
|
||||
if (op.status !== 'pending') return false
|
||||
if (op.risk_level === 'high') return false
|
||||
const period = getPeriodStatus(op)
|
||||
if (period && period.status !== 'open') return false
|
||||
return true
|
||||
}),
|
||||
[filteredOperations]
|
||||
)
|
||||
const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible])
|
||||
@@ -594,6 +764,9 @@ export default function PendingOperationsPage() {
|
||||
bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id))
|
||||
const someSelected = bulkEligibleIds.some((id) => selectedIds.has(id))
|
||||
|
||||
const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length
|
||||
const excludedFromBulk = pendingTotal - bulkEligible.length
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -654,6 +827,33 @@ export default function PendingOperationsPage() {
|
||||
description={t('subtitle')}
|
||||
/>
|
||||
|
||||
{conversationFilter && (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
{t('conversation_filter_label')}{' '}
|
||||
<span className="font-mono">#{conversationFilter.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setConversationFilter(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('conversation')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('conversation_filter_clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
|
||||
<TabsList>
|
||||
@@ -703,11 +903,19 @@ export default function PendingOperationsPage() {
|
||||
<label htmlFor="select-all" className="text-sm cursor-pointer">
|
||||
{selectedCount > 0
|
||||
? t('selected_count', { count: selectedCount })
|
||||
: t('select_all_count', { count: bulkEligible.length })}
|
||||
: excludedFromBulk > 0
|
||||
? t('select_all_count_partial', {
|
||||
eligible: bulkEligible.length,
|
||||
total: pendingTotal,
|
||||
excluded: excludedFromBulk,
|
||||
})
|
||||
: t('select_all_count', { count: bulkEligible.length })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{typeCounts.length > 0 && selectedCount === 0 && (
|
||||
{/* Only worth showing when there's more than one type to pick from —
|
||||
with a single type it just duplicates "Markera alla". */}
|
||||
{typeCounts.length >= 2 && selectedCount === 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('quick_pick')}</span>
|
||||
{typeCounts.map(([type, count]) => {
|
||||
@@ -745,7 +953,9 @@ export default function PendingOperationsPage() {
|
||||
disabled={selectedCount === 0 || isBulkCommitting}
|
||||
onClick={() => setShowBulkDialog(true)}
|
||||
>
|
||||
{t('approve_selected', { count: selectedCount })}
|
||||
{selectedCount > 0
|
||||
? t('approve_selected', { count: selectedCount })
|
||||
: t('approve_selected_none')}
|
||||
</Button>
|
||||
</div>
|
||||
</DataListHeader>
|
||||
@@ -776,9 +986,16 @@ export default function PendingOperationsPage() {
|
||||
? { label: t(entry.labelKey), icon: entry.icon, variant: entry.variant }
|
||||
: { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
|
||||
const isExpanded = expandedId === op.id
|
||||
const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high'
|
||||
const period = getPeriodStatus(op)
|
||||
const periodLocked = period != null && period.status !== 'open'
|
||||
const canBulkSelect =
|
||||
showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked
|
||||
const isSelected = selectedIds.has(op.id)
|
||||
const isAgent = op.actor_type && op.actor_type !== 'user'
|
||||
const conversationId = op.agent_metadata?.conversation_id ?? null
|
||||
const warningSentence = singleActionWarning(op.operation_type)
|
||||
const showHighRiskWarning =
|
||||
op.risk_level === 'high' && warningSentence && op.status === 'pending'
|
||||
|
||||
return (
|
||||
<DataListRow
|
||||
@@ -803,8 +1020,11 @@ export default function PendingOperationsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={periodLocked}
|
||||
title={periodLocked ? 'Perioden är låst' : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (periodLocked) return
|
||||
setSelectedOp(op)
|
||||
setShowCommitDialog(true)
|
||||
}}
|
||||
@@ -817,7 +1037,7 @@ export default function PendingOperationsPage() {
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleReject(op)
|
||||
openRejectDialog(op)
|
||||
}}
|
||||
>
|
||||
{t('reject')}
|
||||
@@ -825,7 +1045,18 @@ export default function PendingOperationsPage() {
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
expandedContent={<OperationPreview op={op} />}
|
||||
expandedContent={
|
||||
<>
|
||||
{/* Period-lock banner sits ABOVE the preview so the reviewer
|
||||
sees the blocker as soon as they expand the row. */}
|
||||
{periodLocked && period && op.status === 'pending' && (
|
||||
<div className="mb-3">
|
||||
<PeriodLockBanner period={period} />
|
||||
</div>
|
||||
)}
|
||||
<OperationPreview op={op} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DataListPrimary>{op.title}</DataListPrimary>
|
||||
<DataListMeta>
|
||||
@@ -835,7 +1066,19 @@ export default function PendingOperationsPage() {
|
||||
<DataListMetaSeparator />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
{op.actor_label || op.actor_type}
|
||||
{/* The actor label doubles as the deep-link into the
|
||||
originating conversation — no separate strip needed. */}
|
||||
{conversationId ? (
|
||||
<a
|
||||
href={`/pending?conversation=${conversationId}`}
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{op.actor_label || op.actor_type}
|
||||
</a>
|
||||
) : (
|
||||
op.actor_label || op.actor_type
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -847,6 +1090,18 @@ export default function PendingOperationsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</DataListMeta>
|
||||
{showHighRiskWarning && (
|
||||
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span>{warningSentence}</span>
|
||||
</p>
|
||||
)}
|
||||
{op.status === 'rejected' && op.rejection_category && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Avvisad: {REJECTION_CATEGORY_LABELS[op.rejection_category]}
|
||||
{op.rejection_reason ? ` — "${op.rejection_reason}"` : ''}
|
||||
</p>
|
||||
)}
|
||||
</DataListRow>
|
||||
)
|
||||
})
|
||||
@@ -892,8 +1147,62 @@ export default function PendingOperationsPage() {
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
|
||||
{/* Reject confirmation dialog */}
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
{/* Reject dialog — category + free-text reason. Both optional so the user
|
||||
can still reject quickly without filling anything in. */}
|
||||
<Dialog open={rejectOp != null} onOpenChange={(open) => { if (!open) setRejectOp(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Avvisa operation</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rejectOp?.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="reject-category">
|
||||
Anledning (valfritt)
|
||||
</label>
|
||||
<Select
|
||||
value={rejectCategory}
|
||||
onValueChange={(v) => setRejectCategory(v as PendingOperationRejectionCategory)}
|
||||
>
|
||||
<SelectTrigger id="reject-category">
|
||||
<SelectValue placeholder="Välj kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(REJECTION_CATEGORY_LABELS) as PendingOperationRejectionCategory[]).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{REJECTION_CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="reject-reason">
|
||||
Notering (valfritt)
|
||||
</label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="T.ex. fel kund matchades, beloppet stämmer inte med fakturan…"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synlig för agenten via gnubok_get_recent_rejections — hjälper den att korrigera nästa förslag.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRejectOp(null)} disabled={isRejecting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleReject} disabled={isRejecting}>
|
||||
{isRejecting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Avvisa'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, FileSpreadsheet, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
@@ -1352,7 +1353,7 @@ function VatDeclarationView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1361,6 +1362,11 @@ function VatDeclarationView() {
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||
Ladda ner Excel
|
||||
</Button>
|
||||
<AgentSparkleButton
|
||||
intentId="vat.review"
|
||||
intentArgs={{ period_type: periodType, year, period }}
|
||||
contextRef={`vat:${year}-${periodType}-${period}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Period selection */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Merged into the "Assistenten" tab. Kept as a permanent redirect so existing
|
||||
// links (e.g. AgentChat's "what I remember" affordance) and old bookmarks land
|
||||
// in the right place.
|
||||
export default function AgentMemorySettingsPage() {
|
||||
redirect('/settings/assistant')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Merged into the "Assistenten" tab (Kompetens view). Kept as a permanent
|
||||
// redirect for old links.
|
||||
export default function AgentSkillsSettingsPage() {
|
||||
redirect('/settings/assistant?view=skills')
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
|
||||
// "Assistenten" — what the assistant remembers about this company (Minne,
|
||||
// editable) and the domain knowledge it ships with (Kompetens, read-only).
|
||||
// A toggle keeps both one click away instead of stacked, so the competence
|
||||
// view isn't buried below the memory list.
|
||||
type View = 'memory' | 'skills'
|
||||
|
||||
export default function AssistantSettingsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const view: View = searchParams.get('view') === 'skills' ? 'skills' : 'memory'
|
||||
|
||||
function setView(next: string) {
|
||||
// 'memory' is the default — keep its URL clean (no query string).
|
||||
router.replace(next === 'skills' ? '/settings/assistant?view=skills' : '/settings/assistant', {
|
||||
scroll: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Företagsprofil (TIC-snapshot Bolagsuppgifter) now lives as a section on the
|
||||
// Företag tab. Kept as a permanent redirect for old bookmarks and deep links.
|
||||
export default function CompanyProfileSettingsPage() {
|
||||
redirect('/settings/company')
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
|
||||
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
import { CompanyProfileSection } from '@/components/settings/CompanyProfileSection'
|
||||
import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor'
|
||||
import { LogoUpload } from '@/components/settings/LogoUpload'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
@@ -60,6 +61,8 @@ export default function CompanySettingsPage() {
|
||||
|
||||
<FiscalPeriodEditor />
|
||||
|
||||
<CompanyProfileSection />
|
||||
|
||||
<CompanyDangerZone />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ const TAB_TO_ROUTE: Record<string, string> = {
|
||||
team: '/settings/team',
|
||||
banking: '/settings/banking',
|
||||
templates: '/settings/templates',
|
||||
'agent-memory': '/settings/assistant',
|
||||
assistant: '/settings/assistant',
|
||||
account: '/settings/account',
|
||||
api: '/settings/api',
|
||||
}
|
||||
|
||||
@@ -1,42 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Skatteverket-anslutningen ligger numera som en sektion under Skatt-fliken.
|
||||
// Behålls som permanent omdirigering för gamla bokmärken och djuplänkar.
|
||||
export default function SkatteverketSettingsPage() {
|
||||
const t = useTranslations('settings_skatteverket')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const connected = searchParams.get('skv_connected')
|
||||
const error = searchParams.get('skv_error')
|
||||
|
||||
if (connected === 'true') {
|
||||
toast({
|
||||
title: t('connected_title'),
|
||||
description: t('connected_description'),
|
||||
})
|
||||
router.replace('/settings/skatteverket')
|
||||
} else if (error) {
|
||||
let msg: string
|
||||
try { msg = decodeURIComponent(error) } catch { msg = error }
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.replace('/settings/skatteverket')
|
||||
}
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SkatteverketConnectPanel />
|
||||
</div>
|
||||
)
|
||||
redirect('/settings/tax')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function TaxSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('settings_skatteverket')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [isSandbox, setIsSandbox] = useState(false)
|
||||
|
||||
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
|
||||
// Sandbox companies don't connect to the real Skatteverket — hide the panel,
|
||||
// matching the old Skatteverket tab's visibility gate.
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (data?.is_sandbox) setIsSandbox(true)
|
||||
})
|
||||
}, [company?.id])
|
||||
|
||||
// Skatteverket OAuth callback — the connect flow returns to /settings/tax with
|
||||
// a status query param (returnTo set in SkatteverketConnectPanel).
|
||||
useEffect(() => {
|
||||
const connected = searchParams.get('skv_connected')
|
||||
const error = searchParams.get('skv_error')
|
||||
if (connected === 'true') {
|
||||
toast({ title: t('connected_title'), description: t('connected_description') })
|
||||
router.replace('/settings/tax')
|
||||
} else if (error) {
|
||||
let msg: string
|
||||
try {
|
||||
msg = decodeURIComponent(error)
|
||||
} catch {
|
||||
msg = error
|
||||
}
|
||||
toast({ title: t('connect_failed_title'), description: msg, variant: 'destructive' })
|
||||
router.replace('/settings/tax')
|
||||
}
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
@@ -36,9 +88,15 @@ export default function TaxSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const showSkatteverket = hasSkatteverketExtension && !isSandbox
|
||||
|
||||
return (
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
|
||||
<TaxSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
|
||||
<TaxSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{showSkatteverket && <SkatteverketConnectPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function SkattekontoPage() {
|
||||
ansluta med BankID i inställningarna.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/settings/skatteverket">
|
||||
<Link href="/settings/tax">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut Skatteverket
|
||||
</Link>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
@@ -243,6 +244,12 @@ export default function SupplierInvoiceDetailPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AgentSparkleButton
|
||||
intentId="supplier_invoice.review"
|
||||
intentArgs={{ supplier_invoice_id: invoice.id }}
|
||||
contextRef={`supplier_invoice:${invoice.id}`}
|
||||
size="default"
|
||||
/>
|
||||
{invoice.status === 'registered' && !invoice.is_credit_note && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AiFilledIndicator from '@/components/ui/ai-filled-indicator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
@@ -23,7 +24,7 @@ import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
|
||||
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, Sparkles, Link2 } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react'
|
||||
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
interface LineItem {
|
||||
@@ -274,6 +275,28 @@ export default function NewSupplierInvoicePage() {
|
||||
const watchedCurrency = watch('currency')
|
||||
const watchedPaidPrivately = watch('paid_with_private_funds')
|
||||
const watchedReverseCharge = watch('reverse_charge')
|
||||
// Watched values used to decide whether the AI-filled indicator should
|
||||
// still be visible. Once the user edits a field, its value no longer
|
||||
// matches what the extractor wrote, and the dot fades out.
|
||||
const watchedInvoiceNumber = watch('supplier_invoice_number')
|
||||
const watchedInvoiceDate = watch('invoice_date')
|
||||
const watchedDueDate = watch('due_date')
|
||||
const watchedPaymentReference = watch('payment_reference')
|
||||
// Returns true when the field currently matches whatever the AI wrote
|
||||
// when the form first loaded. Edits diverge it, hiding the dot.
|
||||
function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean {
|
||||
if (!original) return false
|
||||
return (value ?? '') === (original ?? '')
|
||||
}
|
||||
const aiFlags = {
|
||||
invoiceNumber: stillFromAi(watchedInvoiceNumber, originalExtracted?.invoice?.invoiceNumber ?? null),
|
||||
invoiceDate: stillFromAi(watchedInvoiceDate, originalExtracted?.invoice?.invoiceDate ?? null),
|
||||
dueDate: stillFromAi(watchedDueDate, originalExtracted?.invoice?.dueDate ?? null),
|
||||
paymentReference: stillFromAi(
|
||||
watchedPaymentReference,
|
||||
originalExtracted?.invoice?.paymentReference ?? null,
|
||||
),
|
||||
}
|
||||
|
||||
const isEF = entityType === 'enskild_firma'
|
||||
|
||||
@@ -414,8 +437,8 @@ export default function NewSupplierInvoicePage() {
|
||||
|
||||
// Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and
|
||||
// the user hasn't typed a custom rate yet. Re-fetches when the invoice
|
||||
// date changes too. Never overwrites a user-entered rate.
|
||||
const watchedInvoiceDate = watch('invoice_date')
|
||||
// date changes too. Never overwrites a user-entered rate. Reuses the
|
||||
// watchedInvoiceDate declared above for the AI-filled-indicator flag.
|
||||
// The "user has manually edited the rate" flag is scoped *per currency*.
|
||||
// Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch — the
|
||||
// EUR rate is meaningless for a USD invoice. Tracking last-fetched currency
|
||||
@@ -968,7 +991,7 @@ export default function NewSupplierInvoicePage() {
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||||
<MessageCircle className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}
|
||||
@@ -1054,7 +1077,10 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('supplier_invoice_number_label')}<RequiredMark /></Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('supplier_invoice_number_label')}<RequiredMark /></Label>
|
||||
<AiFilledIndicator active={aiFlags.invoiceNumber} label="AI-fyllt" />
|
||||
</div>
|
||||
{(() => {
|
||||
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
|
||||
return (
|
||||
@@ -1075,17 +1101,29 @@ export default function NewSupplierInvoicePage() {
|
||||
watchedPaidPrivately ? 'sm:grid-cols-1' : 'sm:grid-cols-3',
|
||||
)}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
|
||||
<AiFilledIndicator active={aiFlags.invoiceDate} label="AI-fyllt" />
|
||||
</div>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
</div>
|
||||
{!watchedPaidPrivately && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('due_date_label')}<RequiredMark /></Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('due_date_label')}<RequiredMark /></Label>
|
||||
<AiFilledIndicator active={aiFlags.dueDate} label="AI-fyllt" />
|
||||
</div>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('payment_reference_label')}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('payment_reference_label')}</Label>
|
||||
<AiFilledIndicator
|
||||
active={aiFlags.paymentReference}
|
||||
label="AI-fyllt"
|
||||
/>
|
||||
</div>
|
||||
<Input placeholder={t('payment_reference_placeholder')} {...register('payment_reference')} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -437,9 +437,13 @@ export default function TransactionsPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// Auto-open categorize panel when arriving via /transactions?highlight=<id>
|
||||
// (used by the inbox "Bokför transaktionen" link). Runs once per distinct
|
||||
// highlight id so closing the panel doesn't re-trigger it.
|
||||
// Scroll the targeted row into view when arriving via
|
||||
// /transactions?highlight=<id>. Callers are inbox "Öppna transaktionen",
|
||||
// payment-booking dialog, and supplier-invoice cross-link — all "go look
|
||||
// at this row", not "start booking". The legacy auto-open-template-picker
|
||||
// behavior was removed in v5: booking happens in the inbox workspace now.
|
||||
// Runs once per distinct highlight id so closing/scrolling away doesn't
|
||||
// re-trigger it.
|
||||
useEffect(() => {
|
||||
if (!highlightId) return
|
||||
if (handledHighlightRef.current === highlightId) return
|
||||
@@ -459,11 +463,6 @@ export default function TransactionsPage() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (tx.is_business === null && !tx.journal_entry_id) {
|
||||
setTemplatePickerTransaction(tx)
|
||||
setTemplatePickerOpen(true)
|
||||
}
|
||||
}, [highlightId, transactions])
|
||||
|
||||
// Auto-fetch suggestions when transactions load
|
||||
@@ -1995,6 +1994,7 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { findCompanyRoleByOrgNumber } from '../page'
|
||||
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
|
||||
// `findCompanyRoleByOrgNumber` replaces the old prefetchLookup at /onboarding.
|
||||
// It reads bankid_enrichment.company_roles (populated by TIC Identity API at
|
||||
// BankID-completion time) and matches the role by orgnr. This costs zero
|
||||
// Lens calls — Identity API is on a different TIC product/quota. These tests
|
||||
// pin the behaviour because regressing this would silently re-add Lens spend
|
||||
// to every BankID signup.
|
||||
|
||||
function makeRole(overrides: Partial<EnrichmentCompanyRole> = {}): EnrichmentCompanyRole {
|
||||
return {
|
||||
companyId: 12345,
|
||||
companyRegistrationNumber: '5560125790',
|
||||
legalName: 'Acme AB',
|
||||
legalEntityType: 'Aktiebolag',
|
||||
positionTypes: ['boardMember'],
|
||||
positionDescriptions: ['Styrelseledamot'],
|
||||
positionStart: '2020-01-01',
|
||||
positionEnd: null,
|
||||
companyStatus: 'isActive',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mockSupabase(rolesData: EnrichmentCompanyRole[] | null) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: rolesData === null ? null : { company_roles: rolesData },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('findCompanyRoleByOrgNumber', () => {
|
||||
it('returns the matching role for a clean 10-digit orgnr', async () => {
|
||||
const supabase = mockSupabase([
|
||||
makeRole({ companyRegistrationNumber: '5560125790', legalName: 'Acme AB' }),
|
||||
makeRole({ companyRegistrationNumber: '5567890123', legalName: 'Other AB' }),
|
||||
])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
|
||||
|
||||
expect(result).toEqual({ legalName: 'Acme AB', legalEntityType: 'Aktiebolag' })
|
||||
})
|
||||
|
||||
it('matches orgnrs with hyphens stripped (TIC returns "556012-5790")', async () => {
|
||||
// CompanyRoles may carry the orgnr in formatted form; the function under
|
||||
// test cleans the registered form before comparing to the cleaned input.
|
||||
const supabase = mockSupabase([
|
||||
makeRole({ companyRegistrationNumber: '556012-5790', legalName: 'Acme AB' }),
|
||||
])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
|
||||
|
||||
expect(result).toEqual({ legalName: 'Acme AB', legalEntityType: 'Aktiebolag' })
|
||||
})
|
||||
|
||||
it('returns null when no enrichment row exists (user signed up without BankID)', async () => {
|
||||
const supabase = mockSupabase(null)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when enrichment row has an empty company_roles array', async () => {
|
||||
const supabase = mockSupabase([])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when none of the roles match the requested orgnr', async () => {
|
||||
const supabase = mockSupabase([
|
||||
makeRole({ companyRegistrationNumber: '5567890123', legalName: 'Other AB' }),
|
||||
])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves the TIC `legalEntityType` exactly so mapEntityType can classify v2 strings', async () => {
|
||||
// Important: TIC v2 returns full Swedish names like "Aktiebolag" and
|
||||
// "Enskild firma" (not the v1 "AB"/"EF" abbreviations). The canonical
|
||||
// mapEntityType in lib/company-lookup/entity-type-map.ts handles both
|
||||
// sets — but only if we pass the raw string through unchanged.
|
||||
const supabase = mockSupabase([
|
||||
makeRole({ companyRegistrationNumber: '8001011231', legalEntityType: 'Enskild firma' }),
|
||||
])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '8001011231')
|
||||
|
||||
expect(result?.legalEntityType).toBe('Enskild firma')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
|
||||
import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// /onboarding/agent — Phase A (real-timed build) and Phase B (review) of the
|
||||
// specialized accountant agent build sequence.
|
||||
//
|
||||
// Plan refs: dev_docs/specialized-agent-plan.md §7 (Build-sequence UX),
|
||||
// §15 Phase 2 (Build-sequence UX).
|
||||
export default async function AgentOnboardingPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
// Trigger the TIC live-fetch + cache before the field-resolving query
|
||||
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
|
||||
// best-effort on miss — it never throws. Phase A still runs through the
|
||||
// streaming endpoint; this just lets the initial Phase B render show the
|
||||
// SNI/verksamhetsbeskrivning when the user returns to the page after
|
||||
// stream completion.
|
||||
const hdrs = await headers()
|
||||
const cookieHeader = hdrs.get('cookie') ?? ''
|
||||
const host = hdrs.get('host') ?? 'localhost:3000'
|
||||
const proto = hdrs.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
|
||||
const origin = `${proto}://${host}`
|
||||
// upgradeV1: this is the one place the v2-only sections (statuses,
|
||||
// beneficialOwners, payrolls, …) materially drive the composer, and it's
|
||||
// a deliberate once-per-company action — safe to spend the TIC calls to
|
||||
// bring a pre-v2 snapshot up to date.
|
||||
await ensureTicSnapshot({ supabase, companyId, cookieHeader, origin, upgradeV1: true })
|
||||
|
||||
// Fetch the small handful of fields we render directly into Phase B so the
|
||||
// user sees real values (not "Laddar…") the moment the stream finishes.
|
||||
// company_settings is a separate fetch because it carries the onboarding-
|
||||
// form data (moms_period, fiscal_year_start_month, f_skatt, city, …) that
|
||||
// never makes it onto `companies` proper.
|
||||
const [{ data: company }, { data: profile }, { data: existingProfile }, { data: settings }] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('name, entity_type, org_number, tic_snapshot')
|
||||
.eq('id', companyId)
|
||||
.single(),
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
||||
supabase
|
||||
.from('agent_profiles')
|
||||
.select('company_id, profile_summary, verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select(
|
||||
'city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (!company) redirect('/onboarding')
|
||||
|
||||
const firstName = profile?.full_name?.split(' ')[0] ?? null
|
||||
// Pre-render-friendly snapshot of company info — used to seed Phase B fields
|
||||
// before the stream completes so the layout doesn't jump.
|
||||
const initialFields = buildInitialFields(company, settings)
|
||||
|
||||
// Atom titles — slug-derived labels look ugly ("Konsult It",
|
||||
// "Single Shareholder Ab Fmb"). Fetch the registry titles once and pass them
|
||||
// to the review card so chips render as authored.
|
||||
const { data: atomRows } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, title')
|
||||
.eq('is_active', true)
|
||||
.is('parent_atom_id', null) // skill titles only; reference children never appear as profile chips
|
||||
const atomTitles: Record<string, string> = {}
|
||||
for (const row of (atomRows ?? []) as { id: string; title: string }[]) {
|
||||
atomTitles[row.id] = row.title
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentOnboarding
|
||||
companyId={companyId}
|
||||
companyName={company.name}
|
||||
firstName={firstName}
|
||||
initialFields={initialFields}
|
||||
atomTitles={atomTitles}
|
||||
alreadyVerified={Boolean(existingProfile?.verified_at)}
|
||||
existingSummary={existingProfile?.profile_summary ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface InitialFields {
|
||||
entity_type_label: string
|
||||
// Multiple SNI codes — most companies have one but some are
|
||||
// multi-vertical (e.g. konsult + lagerförsäljning).
|
||||
sni_codes: { code: string; name: string }[]
|
||||
// Verksamhetsbeskrivning (purpose) from Bolagsverket via TIC.
|
||||
purpose: string | null
|
||||
city: string | null
|
||||
fiscal_period: string | null
|
||||
vat_period: string | null
|
||||
f_skatt: string | null
|
||||
employees: string | null
|
||||
}
|
||||
|
||||
interface CompanySettingsForPhaseB {
|
||||
city: string | null
|
||||
address_line1: string | null
|
||||
postal_code: string | null
|
||||
f_skatt: boolean | null
|
||||
vat_registered: boolean | null
|
||||
moms_period: string | null
|
||||
fiscal_year_start_month: number | null
|
||||
employee_count: number | null
|
||||
has_employees: boolean | null
|
||||
}
|
||||
|
||||
function buildInitialFields(
|
||||
company: {
|
||||
name: string
|
||||
entity_type: string
|
||||
org_number: string | null
|
||||
tic_snapshot: Record<string, unknown> | null
|
||||
},
|
||||
settings: CompanySettingsForPhaseB | null,
|
||||
): InitialFields {
|
||||
const tic = (company.tic_snapshot ?? null) as Record<string, unknown> | null
|
||||
const entityLabel =
|
||||
company.entity_type === 'aktiebolag'
|
||||
? 'AB'
|
||||
: company.entity_type === 'enskild_firma'
|
||||
? 'Enskild firma'
|
||||
: company.entity_type
|
||||
|
||||
// Tier the resolution: TIC snapshot (if cached) wins because it's the
|
||||
// authoritative Bolagsverket data; company_settings is the user-entered
|
||||
// fallback from onboarding. Either can be missing.
|
||||
let sniCodes: { code: string; name: string }[] = []
|
||||
let purpose: string | null = null
|
||||
let city: string | null = null
|
||||
let fSkatt: string | null = null
|
||||
let vatPeriod: string | null = null
|
||||
let fiscalPeriod: string | null = null
|
||||
let employees: string | null = null
|
||||
|
||||
if (tic) {
|
||||
const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? []
|
||||
if (Array.isArray(sni)) sniCodes = sni
|
||||
const tp = tic.purpose
|
||||
if (typeof tp === 'string' && tp.trim().length > 0) purpose = tp.trim()
|
||||
const addr = tic.address as { city: string | null } | null
|
||||
if (addr?.city) city = addr.city
|
||||
const reg = tic.registration as { fTax?: boolean } | undefined
|
||||
if (reg) fSkatt = reg.fTax ? 'Aktivt' : 'Saknas'
|
||||
if (tic.employeeRange) employees = tic.employeeRange as string
|
||||
// v2 caches `fiscalYear` (current fiscal-year configuration) on the
|
||||
// snapshot. Prefer it over the user-entered value below so onboarding
|
||||
// can show the registered fiscal period from Bolagsverket without
|
||||
// making the user re-enter it.
|
||||
const ticFiscal = tic.fiscalYear as { startMonthDay?: string | null } | null
|
||||
const startMonth = parseTicStartMonth(ticFiscal?.startMonthDay)
|
||||
if (startMonth != null) fiscalPeriod = fiscalYearLabel(startMonth)
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
if (!city && settings.city) city = settings.city
|
||||
if (!fSkatt && settings.f_skatt != null) {
|
||||
fSkatt = settings.f_skatt ? 'Aktivt' : 'Saknas'
|
||||
}
|
||||
if (settings.moms_period) {
|
||||
vatPeriod = momsPeriodLabel(settings.moms_period)
|
||||
}
|
||||
// settings.fiscal_year_start_month is the user-confirmed value — only
|
||||
// overwrite the TIC-derived label if the user has explicitly set it
|
||||
// (i.e. when there was no TIC fiscalYear AND they entered it manually).
|
||||
if (!fiscalPeriod && settings.fiscal_year_start_month != null) {
|
||||
fiscalPeriod = fiscalYearLabel(settings.fiscal_year_start_month)
|
||||
}
|
||||
if (!employees && settings.employee_count != null) {
|
||||
employees = String(settings.employee_count)
|
||||
} else if (!employees && settings.has_employees != null) {
|
||||
employees = settings.has_employees ? 'Ja' : 'Nej'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entity_type_label: entityLabel,
|
||||
sni_codes: sniCodes,
|
||||
purpose,
|
||||
city,
|
||||
fiscal_period: fiscalPeriod,
|
||||
vat_period: vatPeriod,
|
||||
f_skatt: fSkatt,
|
||||
employees,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TIC v2's `startMonthDay` ("MM-DD") into a month number 1-12. Returns
|
||||
// null when the field is missing or malformed so the caller falls back to
|
||||
// company_settings.fiscal_year_start_month.
|
||||
function parseTicStartMonth(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
|
||||
if (!match) return null
|
||||
const month = Number(match[1])
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) return null
|
||||
return month
|
||||
}
|
||||
|
||||
function momsPeriodLabel(period: string): string {
|
||||
switch (period) {
|
||||
case 'monthly':
|
||||
return 'Månadsmoms'
|
||||
case 'quarterly':
|
||||
return 'Kvartalsmoms'
|
||||
case 'yearly':
|
||||
return 'Årsmoms'
|
||||
default:
|
||||
return period
|
||||
}
|
||||
}
|
||||
|
||||
// "fiscal_year_start_month=1" → "januari–december".
|
||||
function fiscalYearLabel(startMonth: number): string {
|
||||
const months = [
|
||||
'januari',
|
||||
'februari',
|
||||
'mars',
|
||||
'april',
|
||||
'maj',
|
||||
'juni',
|
||||
'juli',
|
||||
'augusti',
|
||||
'september',
|
||||
'oktober',
|
||||
'november',
|
||||
'december',
|
||||
]
|
||||
if (startMonth < 1 || startMonth > 12) return ''
|
||||
const startIdx = startMonth - 1
|
||||
const endIdx = (startIdx + 11) % 12
|
||||
return `${months[startIdx]}–${months[endIdx]}`
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Look up the user's CompanyRoles enrichment (from BankID auth) and find the
|
||||
// role whose orgnr matches the incoming `?org_number=`. CompanyRoles lives on
|
||||
// the TIC Identity API — separate product, separate quota from the Lens
|
||||
// `/lookup` endpoint — so this is free: no Lens calls.
|
||||
//
|
||||
// Returns enough to pre-fill Step 1's entity-type radio + Step 2's
|
||||
// company_name field. The rest (address, F-skatt, VAT) is captured by the
|
||||
// user in Steps 2–4. F-skatt/VAT defaults can't be safely guessed without
|
||||
// Bolagsverket data (ML 17 kap 24§ violation if we default a momsregistrerat
|
||||
// bolag to false), so we make the user confirm in Step 4.
|
||||
//
|
||||
// Exported for unit testing.
|
||||
export async function findCompanyRoleByOrgNumber(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
orgNumber: string,
|
||||
): Promise<{ legalName: string; legalEntityType: string } | null> {
|
||||
const { data } = await supabase
|
||||
.from('bankid_enrichment')
|
||||
.select('company_roles')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
const roles = (data?.company_roles ?? []) as EnrichmentCompanyRole[]
|
||||
if (!Array.isArray(roles) || roles.length === 0) return null
|
||||
|
||||
const match = roles.find(
|
||||
(r) => r.companyRegistrationNumber.replace(/[\s-]/g, '') === orgNumber,
|
||||
)
|
||||
if (!match) return null
|
||||
|
||||
return { legalName: match.legalName, legalEntityType: match.legalEntityType }
|
||||
}
|
||||
|
||||
export default async function OnboardingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -46,12 +83,29 @@ export default async function OnboardingPage({
|
||||
|
||||
const firstName = profile?.full_name?.split(' ')[0] || null
|
||||
|
||||
// The BankID picker routes here with ?org_number=… when TIC /lookup fails
|
||||
// or the entity type isn't one-click-provisionable. Strip formatting so
|
||||
// whatever Step2 displays matches what the rest of the flow will store.
|
||||
// The BankID picker routes here with ?org_number=… for every pick. Strip
|
||||
// formatting so whatever Step 2 displays matches what the rest of the flow
|
||||
// will store.
|
||||
const { org_number: rawOrgNumber } = await searchParams
|
||||
const initialOrgNumber = rawOrgNumber ? rawOrgNumber.replace(/[\s-]/g, '') : undefined
|
||||
|
||||
// BankID prefill: look up the CompanyRoles row (no Lens call) to pre-fill
|
||||
// Step 1's entity_type radio and Step 2's company_name. If no role matches,
|
||||
// the user fills everything manually — same fallback as a non-BankID
|
||||
// signup. `preverifiedOrgNumber` tells Step 2 to skip the client-side
|
||||
// /lookup since CompanyRoles already confirms existence.
|
||||
let initialEntityType: EntityType | undefined
|
||||
let initialLegalName: string | undefined
|
||||
let preverifiedOrgNumber: string | undefined
|
||||
if (initialOrgNumber) {
|
||||
const match = await findCompanyRoleByOrgNumber(supabase, user.id, initialOrgNumber)
|
||||
if (match) {
|
||||
initialEntityType = mapTicEntityType(match.legalEntityType) ?? undefined
|
||||
initialLegalName = match.legalName
|
||||
preverifiedOrgNumber = initialOrgNumber
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeOnboarding
|
||||
firstName={firstName}
|
||||
@@ -59,6 +113,9 @@ export default async function OnboardingPage({
|
||||
skipWelcome
|
||||
hasExistingCompanies={hasCompanies}
|
||||
initialOrgNumber={initialOrgNumber}
|
||||
initialEntityType={initialEntityType}
|
||||
initialLegalName={initialLegalName}
|
||||
preverifiedOrgNumber={preverifiedOrgNumber}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { composeAgentProfile } from '@/lib/agent/composer'
|
||||
|
||||
const BodySchema = z.object({
|
||||
// Optional override; if absent we use the user's active_company_id.
|
||||
company_id: z.string().uuid().optional(),
|
||||
// dry_run=true returns the composed profile without writing to agent_profiles.
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// POST /api/agent/composer
|
||||
//
|
||||
// Runs the specialized accountant composer pipeline for a company:
|
||||
// 1. Gathers TIC snapshot, optional SIE summary, optional banking summary.
|
||||
// 2. Calls Opus 4.7 to select horizontal/vertical/modifier atoms.
|
||||
// 3. Calls Sonnet 4.6 to write the Swedish profile_summary.
|
||||
// 4. Persists to agent_profiles (skipped on dry_run).
|
||||
// 5. Fires fire-and-forget cache pre-warm.
|
||||
//
|
||||
// Auth: must be a member of the target company.
|
||||
//
|
||||
// Plan ref: dev_docs/specialized-agent-plan.md §6.
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const rate = await checkAgentRateLimit(supabase, user.id)
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(agentRateLimitResponseBody(rate), {
|
||||
status: 429,
|
||||
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json().catch(() => ({})))
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Defense in depth alongside RLS — confirm membership before composing.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
|
||||
return NextResponse.json({ data: composed })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Composer failed'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
// GET /api/agent/conversations/[id]
|
||||
//
|
||||
// Returns one conversation + its messages in chronological order. The chat
|
||||
// page hydrates with this on mount; the agent loop then continues via
|
||||
// /api/agent/invoke with the conversation_id.
|
||||
//
|
||||
// PATCH /api/agent/conversations/[id]
|
||||
//
|
||||
// Updates pin/archive state or title. The chat list relies on these.
|
||||
|
||||
const PatchSchema = z.object({
|
||||
pinned: z.boolean().nullable().optional(),
|
||||
archived: z.boolean().nullable().optional(),
|
||||
title: z.string().min(1).max(200).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const { data: conv, error: convErr } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select(
|
||||
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
|
||||
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
|
||||
// Defense in depth alongside RLS — verify caller is a member of the
|
||||
// conversation's company AND owns the conversation row. Conversations are
|
||||
// user-scoped within a company; one team member should not see another's.
|
||||
if (conv.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', conv.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data: messages, error: msgErr } = await supabase
|
||||
.from('agent_messages')
|
||||
.select('id, role, content, tool_use_id, hidden, created_at')
|
||||
.eq('conversation_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
if (msgErr) return NextResponse.json({ error: msgErr.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
let body: z.infer<typeof PatchSchema>
|
||||
try {
|
||||
body = PatchSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.pinned != null) update.pinned = body.pinned
|
||||
if (body.archived != null) update.archived = body.archived
|
||||
if (body.title != null) update.title = body.title
|
||||
if (Object.keys(update).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Defense in depth — verify ownership before update so a 404 is returned
|
||||
// (instead of relying solely on RLS, which would silently 0-row).
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('user_id, company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing || existing.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_conversations')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
// GET /api/agent/conversations
|
||||
//
|
||||
// Query params:
|
||||
// archived: 'true' | 'false' (default 'false')
|
||||
// pinned: 'true' filters to pinned only
|
||||
// intent: 'general.help' (or any intent id) — filter
|
||||
// q: case-insensitive substring match on title/context_ref
|
||||
// limit: 1..200, default 50
|
||||
//
|
||||
// Returns: { data: [{ id, intent_id, context_ref, title, pinned, archived,
|
||||
// last_message_at, created_at }] }
|
||||
//
|
||||
// Ordered: pinned first (within archived bucket), then last_message_at desc.
|
||||
// Used by the /chat sidebar and "resume conversation" UI in the sheet.
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const archived = url.searchParams.get('archived') === 'true'
|
||||
const pinnedOnly = url.searchParams.get('pinned') === 'true'
|
||||
const intent = url.searchParams.get('intent') ?? null
|
||||
const q = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 200)
|
||||
|
||||
let query = supabase
|
||||
.from('agent_conversations')
|
||||
.select(
|
||||
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('archived', archived)
|
||||
|
||||
if (pinnedOnly) query = query.eq('pinned', true)
|
||||
if (intent) query = query.eq('intent_id', intent)
|
||||
if (q.length > 0) {
|
||||
// Pattern is sanitized via Postgres' percent-handling; ilike accepts the
|
||||
// % wildcard and we only inject the user's substring between them.
|
||||
const safe = q.replace(/[%_]/g, (m) => `\\${m}`)
|
||||
query = query.or(`title.ilike.%${safe}%,context_ref.ilike.%${safe}%`)
|
||||
}
|
||||
|
||||
// Sort: pinned first, then most recent message.
|
||||
query = query
|
||||
.order('pinned', { ascending: false })
|
||||
.order('last_message_at', { ascending: false, nullsFirst: false })
|
||||
.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getIntent } from '@/lib/agent/intents/registry'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
|
||||
|
||||
// Make sure extensions are loaded — the chat loop dispatches against the
|
||||
// agent tool registry which is populated by the mcp-server extension at load.
|
||||
ensureInitialized()
|
||||
|
||||
// Hard cap on the per-turn user input. Generous for a chat composer (about
|
||||
// 5k words / 20 pages) but bounds Bedrock token cost if the rate limiter is
|
||||
// ever fail-open and a client floods large payloads.
|
||||
const MAX_USER_MESSAGE_LEN = 20_000
|
||||
|
||||
const BodySchema = z.object({
|
||||
intent_id: z.string().min(1).max(200),
|
||||
// Existing conversation to resume; if omitted, the route creates one. The
|
||||
// chat sheet's React state holds the conversation id as `string | null`
|
||||
// and serializes `null` on the first turn, so accept null alongside
|
||||
// undefined and treat both as "no existing conversation".
|
||||
conversation_id: z.string().uuid().nullable().optional(),
|
||||
// Optional company override; defaults to active_company_id.
|
||||
company_id: z.string().uuid().nullable().optional(),
|
||||
// The user's message (or, on the first turn, this is empty and we send the
|
||||
// intent's prompt template instead). Capped to bound LLM cost.
|
||||
user_message: z.string().max(MAX_USER_MESSAGE_LEN).nullable().optional(),
|
||||
// Intent-specific capture args (e.g. { transaction_id: '...' } for
|
||||
// transaction.categorization). Used only on the first turn to build the
|
||||
// prompt template. Each value is bounded so capture inputs can't be a
|
||||
// megabyte each; the dispatcher rejects oversize values upfront.
|
||||
intent_args: z
|
||||
.record(z.string().max(120), z.unknown())
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => {
|
||||
if (!v) return true
|
||||
try {
|
||||
return JSON.stringify(v).length <= MAX_USER_MESSAGE_LEN
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ message: 'intent_args too large' },
|
||||
),
|
||||
// Optional context_ref for the conversation row, e.g. 'transaction:<id>'.
|
||||
context_ref: z.string().max(200).nullable().optional(),
|
||||
// When true (and user_message is provided), persist the turn but flag it
|
||||
// hidden so it doesn't render as a user bubble on resume. Used by the chat's
|
||||
// rejection-correction flow (ApprovalCard → AgentChat) to feed the agent a
|
||||
// synthetic correction without showing it as something the user typed.
|
||||
user_message_hidden: z.boolean().nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/agent/invoke
|
||||
//
|
||||
// Streams NDJSON events from the chat loop. Each line is a JSON object whose
|
||||
// `kind` identifies the event type — see lib/agent/chat/run-turn.ts StreamEvent.
|
||||
//
|
||||
// Auth: the user must be a member of the resolved company.
|
||||
//
|
||||
// Plan ref: dev_docs/specialized-agent-plan.md §9 (chat loop).
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
// Generous per-user rate limit — bounds runaway Bedrock spend (loop-firing
|
||||
// sessions). Fails open on infra error.
|
||||
const rate = await checkAgentRateLimit(supabase, user.id)
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(agentRateLimitResponseBody(rate), {
|
||||
status: 429,
|
||||
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const intent = getIntent(body.intent_id)
|
||||
if (!intent) {
|
||||
return NextResponse.json({ error: `Unknown intent: ${body.intent_id}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
// onboarding.intake completion signal — once the user has actually
|
||||
// engaged (typed a real reply, not the auto-fired greeting prompt that
|
||||
// mounts the chat), stamp intake_completed_at on the profile so re-entry
|
||||
// logic and opportunistic follow-up logic in other intents can tell the
|
||||
// intake happened. Idempotent: the IS NULL guard ensures we never
|
||||
// overwrite the first engagement timestamp. Best-effort — failure here
|
||||
// doesn't break the chat; the next user turn retries.
|
||||
if (
|
||||
body.intent_id === 'onboarding.intake' &&
|
||||
typeof body.user_message === 'string' &&
|
||||
body.user_message.trim().length > 0 &&
|
||||
body.user_message_hidden !== true
|
||||
) {
|
||||
try {
|
||||
await supabase
|
||||
.from('agent_profiles')
|
||||
.update({ intake_completed_at: new Date().toISOString() })
|
||||
.eq('company_id', companyId)
|
||||
.is('intake_completed_at', null)
|
||||
} catch {
|
||||
// ignored — see comment above
|
||||
}
|
||||
}
|
||||
|
||||
// Load lightweight company + user signals for the system prompt.
|
||||
const [{ data: company }, { data: profile }] = await Promise.all([
|
||||
supabase.from('companies').select('name').eq('id', companyId).single(),
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
||||
])
|
||||
const companyName = company?.name ?? ''
|
||||
const firstName = profile?.full_name?.split(' ')[0] ?? null
|
||||
|
||||
// Resolve / create the conversation row.
|
||||
let conversationId = body.conversation_id ?? null
|
||||
if (!conversationId) {
|
||||
const { data: newConv, error: convErr } = await supabase
|
||||
.from('agent_conversations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
intent_id: body.intent_id,
|
||||
context_ref: body.context_ref ?? null,
|
||||
title: intent.sheetTitle,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (convErr || !newConv) {
|
||||
return NextResponse.json(
|
||||
{ error: convErr?.message ?? 'Failed to create conversation' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
conversationId = newConv.id as string
|
||||
}
|
||||
|
||||
// Compute the user message to send to Anthropic. On the first turn (no
|
||||
// user_message provided), we run the intent's capture + promptTemplate
|
||||
// pipeline so the prompt is anchored on the page context the user
|
||||
// clicked from.
|
||||
let effectiveUserMessage = body.user_message ?? ''
|
||||
// When the caller didn't supply a user_message, we synthesize one from the
|
||||
// intent's promptTemplate. Mark that synthetic turn hidden so the UI
|
||||
// doesn't render the template scaffolding as a user bubble on resume. The
|
||||
// client can also explicitly request a hidden turn (rejection correction)
|
||||
// even when it DID supply a user_message.
|
||||
let userMessageHidden = body.user_message_hidden === true
|
||||
if (!effectiveUserMessage) {
|
||||
try {
|
||||
const captured = await intent.capture(body.intent_args ?? {}, {
|
||||
supabase,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
const profileSummary = await loadProfileSummary(supabase, companyId)
|
||||
const memory = await loadRankedMemory(supabase, companyId, 30)
|
||||
effectiveUserMessage = intent.promptTemplate({
|
||||
captured,
|
||||
profileSummary,
|
||||
activeMemory: memory,
|
||||
})
|
||||
userMessageHidden = true
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? `Capture failed: ${err.message}`
|
||||
: 'Capture failed',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream — NDJSON events from the chat loop.
|
||||
const encoder = new TextEncoder()
|
||||
// Conversation id is set above; capture into a non-null local for the
|
||||
// streaming closure's first emission.
|
||||
const convId: string = conversationId
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const emit = (event: unknown): boolean => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the conversation id so the client can resume with it.
|
||||
emit({ kind: 'conversation', conversation_id: convId })
|
||||
|
||||
try {
|
||||
await runChatTurn({
|
||||
supabase,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
companyName,
|
||||
firstName,
|
||||
intent,
|
||||
conversationId: convId,
|
||||
userMessage: effectiveUserMessage,
|
||||
userMessageHidden,
|
||||
persist: true,
|
||||
emit: (event) => emit(event),
|
||||
})
|
||||
} catch (err) {
|
||||
// run-turn already emitted a friendly error before re-throwing; emit a
|
||||
// normalized one here too so this outer catch never overwrites it with a
|
||||
// raw AWS SDK string.
|
||||
emit({
|
||||
kind: 'error',
|
||||
message: friendlyModelError(err),
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadProfileSummary(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('profile_summary')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return (data?.profile_summary as string | null) ?? null
|
||||
}
|
||||
|
||||
async function loadRankedMemory(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
companyId: string,
|
||||
cap: number,
|
||||
): Promise<{ content: string; kind: string }[]> {
|
||||
const { data } = await supabase
|
||||
.from('agent_memory')
|
||||
.select('content, kind, relevance_score, last_accessed_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('relevance_score', { ascending: false })
|
||||
.order('last_accessed_at', { ascending: false, nullsFirst: false })
|
||||
.limit(cap)
|
||||
return (data ?? []).map((r: { content: string; kind: string }) => ({
|
||||
content: r.content,
|
||||
kind: r.kind,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
// PATCH /api/agent/memory/[id]
|
||||
//
|
||||
// Mutate a single memory entry. Powers the list/edit/pin/dismiss affordances
|
||||
// on /settings/agent-memory (plan §11).
|
||||
//
|
||||
// content — edit the durable text (kind never changes; that would
|
||||
// muddle the audit lineage). Append-only is preserved by
|
||||
// superseded_by chains when an upstream caller wants it; the
|
||||
// transparency UI is allowed to overwrite in place because
|
||||
// the row's `updated_at` already documents the edit.
|
||||
// is_pinned — boost into the top-N prompt block regardless of score.
|
||||
// is_active — false = dismiss (removes from ranking pool); true = restore.
|
||||
//
|
||||
// RLS scopes to user_company_ids(); we ALSO re-verify membership for the
|
||||
// row's company_id as defense in depth before mutating.
|
||||
|
||||
const PatchSchema = z
|
||||
.object({
|
||||
content: z.string().min(2).max(2000).optional(),
|
||||
is_pinned: z.boolean().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => v.content !== undefined || v.is_pinned !== undefined || v.is_active !== undefined,
|
||||
{ message: 'Nothing to update' },
|
||||
)
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const { id } = await params
|
||||
|
||||
let body: z.infer<typeof PatchSchema>
|
||||
try {
|
||||
body = PatchSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.content !== undefined) update.content = body.content
|
||||
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
|
||||
if (body.is_active !== undefined) update.is_active = body.is_active
|
||||
|
||||
// Look up the row's company_id and re-check membership before mutating.
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_memory')
|
||||
.select('company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', existing.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const getActiveCompanyIdMock = vi.fn()
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
|
||||
}))
|
||||
|
||||
const requireWritePermissionMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
getActiveCompanyIdMock.mockResolvedValue('company-1')
|
||||
requireWritePermissionMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('GET /api/agent/memory', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
expect(status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 400 when no active company', async () => {
|
||||
getActiveCompanyIdMock.mockResolvedValue(null)
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns rows for the active company', async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 'mem-1',
|
||||
kind: 'fact',
|
||||
content: 'Räkenskapsår jan–dec',
|
||||
source: 'composer',
|
||||
source_ref: null,
|
||||
relevance_score: 0.5,
|
||||
is_pinned: false,
|
||||
is_active: true,
|
||||
last_accessed_at: null,
|
||||
created_at: '2026-05-10T09:00:00Z',
|
||||
updated_at: '2026-05-10T09:00:00Z',
|
||||
},
|
||||
]
|
||||
enqueue({ data: rows })
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof rows }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].id).toBe('mem-1')
|
||||
})
|
||||
|
||||
it('does not require write permission for read', async () => {
|
||||
enqueue({ data: [] })
|
||||
await GET(createMockRequest('/api/agent/memory'))
|
||||
expect(requireWritePermissionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/agent/memory', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await POST(
|
||||
createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'hello world' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks viewers via requireWritePermission', async () => {
|
||||
const { NextResponse } = await import('next/server')
|
||||
requireWritePermissionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
})
|
||||
const response = await POST(
|
||||
createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'hello world' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects too-short content', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'x' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('inserts and returns the row on happy path', async () => {
|
||||
const inserted = {
|
||||
id: 'mem-2',
|
||||
kind: 'fact',
|
||||
content: 'En sak att komma ihåg',
|
||||
source: 'user_taught',
|
||||
source_ref: null,
|
||||
relevance_score: 1,
|
||||
is_pinned: false,
|
||||
is_active: true,
|
||||
last_accessed_at: null,
|
||||
created_at: '2026-05-18T10:00:00Z',
|
||||
updated_at: '2026-05-18T10:00:00Z',
|
||||
}
|
||||
// Defense-in-depth: body company_id membership re-check happens after
|
||||
// requireWritePermission. POST flow now is: (1) company_members lookup,
|
||||
// (2) insert.
|
||||
enqueue({ data: { role: 'member' } })
|
||||
enqueue({ data: inserted })
|
||||
const response = await POST(
|
||||
createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'En sak att komma ihåg' },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof inserted }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('mem-2')
|
||||
})
|
||||
|
||||
it('rejects when user is a viewer in the target company', async () => {
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
const response = await POST(
|
||||
createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'En sak att komma ihåg' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/agent/memory/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks viewers via requireWritePermission', async () => {
|
||||
const { NextResponse } = await import('next/server')
|
||||
requireWritePermissionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
})
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects empty patch body', async () => {
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', { method: 'PATCH', body: {} }),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when row not found / not visible via RLS', async () => {
|
||||
// Row lookup returns null → defense-in-depth 404 before update.
|
||||
enqueue({ data: null })
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-x', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-x' }),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when user has no membership in row company', async () => {
|
||||
enqueue({ data: { company_id: 'company-2' } })
|
||||
enqueue({ data: null }) // company_members lookup
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('pins a row', async () => {
|
||||
const updated = {
|
||||
id: 'mem-1',
|
||||
kind: 'fact',
|
||||
content: 'X',
|
||||
source: 'composer',
|
||||
source_ref: null,
|
||||
relevance_score: 0.5,
|
||||
is_pinned: true,
|
||||
is_active: true,
|
||||
last_accessed_at: null,
|
||||
created_at: '2026-05-10T09:00:00Z',
|
||||
updated_at: '2026-05-18T10:00:00Z',
|
||||
}
|
||||
// PATCH flow: (1) row lookup, (2) membership lookup, (3) update.
|
||||
enqueue({ data: { company_id: 'company-1' } })
|
||||
enqueue({ data: { role: 'member' } })
|
||||
enqueue({ data: updated })
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof updated }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_pinned).toBe(true)
|
||||
})
|
||||
|
||||
it('dismisses a row by setting is_active=false', async () => {
|
||||
const updated = {
|
||||
id: 'mem-1',
|
||||
kind: 'fact',
|
||||
content: 'X',
|
||||
source: 'composer',
|
||||
source_ref: null,
|
||||
relevance_score: 0.5,
|
||||
is_pinned: false,
|
||||
is_active: false,
|
||||
last_accessed_at: null,
|
||||
created_at: '2026-05-10T09:00:00Z',
|
||||
updated_at: '2026-05-18T10:00:00Z',
|
||||
}
|
||||
enqueue({ data: { company_id: 'company-1' } })
|
||||
enqueue({ data: { role: 'member' } })
|
||||
enqueue({ data: updated })
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_active: false },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof updated }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_active).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects content shorter than 2 chars', async () => {
|
||||
const response = await PATCH(
|
||||
createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { content: 'x' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'mem-1' }),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
// GET /api/agent/memory
|
||||
//
|
||||
// Lists memory entries for the active company, ordered by pin > relevance >
|
||||
// recency. Powers /settings/agent-memory (transparency UI per plan §11).
|
||||
//
|
||||
// Query params:
|
||||
// include_dismissed: 'true' includes is_active=false rows (audit view).
|
||||
// kind: filter by kind.
|
||||
// limit: 1..200, default 200 (matches the storage cap).
|
||||
//
|
||||
// POST /api/agent/memory
|
||||
//
|
||||
// Inserts a memory entry for the company. Used by Phase B's free-text seed
|
||||
// memory field ("Lägg till om ditt företag (valfritt)") to capture
|
||||
// foundational facts as `kind=fact`, `source=user_taught` with an elevated
|
||||
// relevance score so they land in the top-30 prompt block.
|
||||
//
|
||||
// Also usable from /settings/agent-memory ("Lägg till minne" affordance) and
|
||||
// post-Phase 4 explicit "Kom ihåg det här" surfaces.
|
||||
|
||||
const KIND = ['fact', 'preference', 'pattern', 'correction'] as const
|
||||
const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const
|
||||
|
||||
const BodySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
content: z.string().min(2).max(2000),
|
||||
kind: z.enum(KIND).default('fact'),
|
||||
source: z.enum(SOURCE).default('user_taught'),
|
||||
source_ref: z.string().max(200).optional(),
|
||||
// Default 1.0 keeps user-taught entries above composer-derived (0.5) by
|
||||
// default; conversation-time captures can land anywhere in [0, 1].
|
||||
relevance_score: z.number().min(0).max(1).default(1.0),
|
||||
})
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const includeDismissed = url.searchParams.get('include_dismissed') === 'true'
|
||||
const kindParam = url.searchParams.get('kind')
|
||||
const kind = KIND.includes(kindParam as (typeof KIND)[number])
|
||||
? (kindParam as (typeof KIND)[number])
|
||||
: null
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 200, 1), 200)
|
||||
|
||||
let query = supabase
|
||||
.from('agent_memory')
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (!includeDismissed) query = query.eq('is_active', true)
|
||||
if (kind) query = query.eq('kind', kind)
|
||||
|
||||
query = query
|
||||
.order('is_active', { ascending: false })
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('relevance_score', { ascending: false })
|
||||
.order('last_accessed_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
// requireWritePermission above checks the *active* company's role; if the
|
||||
// caller passes a different company_id in the body, re-check membership +
|
||||
// non-viewer role for THAT company specifically.
|
||||
const { data: bodyMembership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!bodyMembership || bodyMembership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
kind: body.kind,
|
||||
content: body.content,
|
||||
source: body.source,
|
||||
source_ref: body.source_ref ?? null,
|
||||
relevance_score: body.relevance_score,
|
||||
is_active: true,
|
||||
created_by_user_id: user.id,
|
||||
})
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { gatherComposerInputs, inputsToSourceSignals } from '@/lib/agent/composer/inputs'
|
||||
import { selectAtoms } from '@/lib/agent/composer/atom-selection'
|
||||
import { writeNarrative } from '@/lib/agent/composer/narrative'
|
||||
import { fallbackAtomSelection, fallbackNarrative } from '@/lib/agent/composer/fallback'
|
||||
import { filterRedundantQuestions } from '@/lib/agent/composer/atom-selection'
|
||||
import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
|
||||
import { OPUS_MODEL } from '@/lib/agent/composer/client'
|
||||
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
|
||||
import type { AtomSelection } from '@/lib/agent/composer/schemas'
|
||||
|
||||
const BodySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
// 10s — the user is on a wait-screen with visible progress, so we can afford
|
||||
// the longer budget. The prior 5s clipped legitimate fetches (TIC fans out to
|
||||
// ~13 Lens calls upstream) into the fallback bucket while still burning the
|
||||
// in-flight upstream calls against quota. See actions.ts:182-189 for the
|
||||
// May 2026 incident context.
|
||||
const TIC_BUDGET_MS = 10_000
|
||||
// Bedrock cold-starts can take 2-3s before the first token, plus the actual
|
||||
// Opus selection call typically lands at 10-14s. 15s was too tight and put
|
||||
// real Opus calls into the fallback bucket on the first turn of the day.
|
||||
const SELECT_BUDGET_MS = 25_000
|
||||
const NARRATIVE_BUDGET_MS = 8_000
|
||||
|
||||
// Per-step event shape streamed as NDJSON. Each line is one JSON object.
|
||||
type Step = 'tic' | 'select' | 'narrative' | 'finalize' | 'prewarm'
|
||||
type Status = 'in_progress' | 'success' | 'fallback' | 'skipped' | 'error'
|
||||
type StreamEvent =
|
||||
| { step: Step; status: Status }
|
||||
| { step: 'select'; status: 'success' | 'fallback'; selection: AtomSelection }
|
||||
| { step: 'narrative'; status: 'success' | 'fallback'; narrative: string }
|
||||
| { step: 'finalize'; status: 'success'; profile: ProfilePayload }
|
||||
| { step: 'error'; status: 'error'; message: string }
|
||||
|
||||
interface ProfilePayload {
|
||||
company_id: string
|
||||
horizontal_atoms: string[]
|
||||
vertical_atoms: string[]
|
||||
modifier_atoms: string[]
|
||||
is_multi_vertical: boolean
|
||||
profile_summary: string
|
||||
verification_questions: string[]
|
||||
uncertainty_notes: string[]
|
||||
composer_model: string
|
||||
composed_at: string
|
||||
}
|
||||
|
||||
// POST /api/agent/onboarding/stream
|
||||
//
|
||||
// Streams real-timed progress for the agent build sequence (plan §7 Phase A).
|
||||
// Each step runs on its actual latency — no artificial delays. On timeout or
|
||||
// failure, the step emits `fallback` and the pipeline continues with a
|
||||
// deterministic default so the user always reaches Phase B.
|
||||
//
|
||||
// Response: application/x-ndjson — one JSON event per line.
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
// Generous per-user rate limit — bounds reload-spam of the onboarding build
|
||||
// (each run fires 2 LLM calls). Fails open on infra error.
|
||||
const rate = await checkAgentRateLimit(supabase, user.id)
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(agentRateLimitResponseBody(rate), {
|
||||
status: 429,
|
||||
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json().catch(() => ({})))
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
const send = (event: StreamEvent) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
|
||||
} catch {
|
||||
// Stream was cancelled (user navigated away). Subsequent enqueues
|
||||
// would throw — we just stop emitting.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1 — TIC: read companies.tic_snapshot; if missing or stale,
|
||||
// live-fetch from the TIC extension (cookies forwarded from the
|
||||
// incoming request) and persist. Falls through gracefully when TIC is
|
||||
// disabled, the company has no org_number, or the request times out.
|
||||
send({ step: 'tic', status: 'in_progress' })
|
||||
const cookieHeader = request.headers.get('cookie') ?? ''
|
||||
// SSRF guard: derive origin from NEXT_PUBLIC_APP_URL (a required env
|
||||
// var per CLAUDE.md) instead of request.headers.host. On self-hosted
|
||||
// Docker the Host header can be attacker-controlled and would
|
||||
// otherwise let an attacker redirect the cookie-bearing TIC fetch to
|
||||
// a host they control.
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, '') ?? ''
|
||||
const fallbackHost = request.headers.get('host') ?? 'localhost:3000'
|
||||
const fallbackProto =
|
||||
request.headers.get('x-forwarded-proto') ??
|
||||
(fallbackHost.startsWith('localhost') ? 'http' : 'https')
|
||||
const origin = appUrl || `${fallbackProto}://${fallbackHost}`
|
||||
const ticResult = await withTimeout(
|
||||
// upgradeV1: agent build is the consumer of the v2-only sections;
|
||||
// bounded to companies actively creating an agent, so the TIC
|
||||
// budget stays safe even when upgrading pre-v2 snapshots.
|
||||
// timeoutMs: lift the internal fetch signal to match the outer
|
||||
// budget — otherwise the 5s default fires first and we get the
|
||||
// pre-fix behavior even with the longer outer budget.
|
||||
ensureTicSnapshot({
|
||||
supabase,
|
||||
companyId,
|
||||
cookieHeader,
|
||||
origin,
|
||||
upgradeV1: true,
|
||||
timeoutMs: TIC_BUDGET_MS,
|
||||
}),
|
||||
TIC_BUDGET_MS,
|
||||
).catch(() => ({ snapshot: null, source: 'fallback' as const }))
|
||||
send({
|
||||
step: 'tic',
|
||||
status: ticResult.snapshot ? 'success' : 'fallback',
|
||||
})
|
||||
|
||||
// Gather inputs once — used by select + narrative + persistence.
|
||||
const inputs = await gatherComposerInputs(supabase, companyId)
|
||||
|
||||
// Step 2 — Opus atom selection with timeout + deterministic fallback.
|
||||
send({ step: 'select', status: 'in_progress' })
|
||||
let selection: AtomSelection
|
||||
try {
|
||||
selection = await withTimeout(selectAtoms(inputs), SELECT_BUDGET_MS)
|
||||
send({ step: 'select', status: 'success', selection })
|
||||
} catch {
|
||||
selection = fallbackAtomSelection(inputs)
|
||||
send({ step: 'select', status: 'fallback', selection })
|
||||
}
|
||||
|
||||
// Filter verification questions deterministically regardless of which
|
||||
// path produced the selection. fallbackAtomSelection generates a
|
||||
// generic template that doesn't know about KÄNDA FAKTA; the Opus
|
||||
// path is also re-filtered in case the model strayed. Cheap belt-
|
||||
// and-braces — same function used for both.
|
||||
selection.verification_questions = filterRedundantQuestions(
|
||||
selection.verification_questions,
|
||||
inputs,
|
||||
selection.modifier_atoms,
|
||||
)
|
||||
|
||||
// Step 3 — Sonnet narrative with timeout + plain fallback.
|
||||
send({ step: 'narrative', status: 'in_progress' })
|
||||
let narrative: string
|
||||
try {
|
||||
narrative = await withTimeout(writeNarrative(inputs, selection), NARRATIVE_BUDGET_MS)
|
||||
send({ step: 'narrative', status: 'success', narrative })
|
||||
} catch {
|
||||
narrative = fallbackNarrative(inputs)
|
||||
send({ step: 'narrative', status: 'fallback', narrative })
|
||||
}
|
||||
|
||||
// Step 4 — Persist the profile so Phase B has a row to edit.
|
||||
const composedAt = new Date().toISOString()
|
||||
const sourceSignals = inputsToSourceSignals(inputs)
|
||||
const { error: upsertErr } = await supabase
|
||||
.from('agent_profiles')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
horizontal_atoms: selection.horizontal_atoms,
|
||||
vertical_atoms: selection.vertical_atoms,
|
||||
modifier_atoms: selection.modifier_atoms,
|
||||
profile_summary: narrative,
|
||||
source_signals: sourceSignals,
|
||||
// Persist so the Phase C intake agent can read them server-
|
||||
// side when the chat opens. Plan §7 Phase C.
|
||||
verification_questions: selection.verification_questions,
|
||||
composed_at: composedAt,
|
||||
composer_model: OPUS_MODEL,
|
||||
composer_version: 1,
|
||||
},
|
||||
{ onConflict: 'company_id' },
|
||||
)
|
||||
if (upsertErr) {
|
||||
send({ step: 'error', status: 'error', message: upsertErr.message })
|
||||
return
|
||||
}
|
||||
|
||||
send({
|
||||
step: 'finalize',
|
||||
status: 'success',
|
||||
profile: {
|
||||
company_id: companyId,
|
||||
horizontal_atoms: selection.horizontal_atoms,
|
||||
vertical_atoms: selection.vertical_atoms,
|
||||
modifier_atoms: selection.modifier_atoms,
|
||||
is_multi_vertical: selection.is_multi_vertical,
|
||||
profile_summary: narrative,
|
||||
verification_questions: selection.verification_questions,
|
||||
uncertainty_notes: selection.uncertainty_notes,
|
||||
composer_model: OPUS_MODEL,
|
||||
composed_at: composedAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Step 5 — fire-and-forget cache pre-warm. The client renders the
|
||||
// review card already; pre-warm just buys a faster first chat turn.
|
||||
send({ step: 'prewarm', status: 'in_progress' })
|
||||
const allIds = [
|
||||
...selection.horizontal_atoms,
|
||||
...selection.vertical_atoms,
|
||||
...selection.modifier_atoms,
|
||||
]
|
||||
if (allIds.length > 0) {
|
||||
const { data: rows } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, body')
|
||||
.in('id', allIds)
|
||||
const bodies = (rows ?? [])
|
||||
.map((r: { body: string | null }) => r.body ?? '')
|
||||
.filter((b: string) => b.length > 0)
|
||||
void preWarmAtomCache({ atomBodies: bodies })
|
||||
}
|
||||
send({ step: 'prewarm', status: 'success' })
|
||||
} catch (err) {
|
||||
send({
|
||||
step: 'error',
|
||||
status: 'error',
|
||||
message: err instanceof Error ? err.message : 'Composer pipeline failed',
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Run a promise against a wall-clock budget. The underlying work continues to
|
||||
// completion on the server when the budget elapses — we just stop waiting for
|
||||
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
|
||||
// warms its own cache.
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
// GET /api/agent/profile?company_id=...
|
||||
// PATCH same path
|
||||
//
|
||||
// GET returns the composed profile + the source verification_questions stored
|
||||
// alongside it (read from agent_profiles row).
|
||||
//
|
||||
// PATCH updates field_overrides (timestamped, merged with existing) and
|
||||
// optionally rewrites the atom arrays from the review UI. Does not touch
|
||||
// verified_at — that flows through /verify.
|
||||
|
||||
const AtomArrays = z.object({
|
||||
horizontal_atoms: z.array(z.string()).optional(),
|
||||
vertical_atoms: z.array(z.string()).optional(),
|
||||
modifier_atoms: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const PatchBody = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
field_overrides: z.record(z.string(), z.unknown()).optional(),
|
||||
atoms: AtomArrays.optional(),
|
||||
profile_summary: z.string().min(1).max(2000).optional(),
|
||||
// Agent personalization — name shown on the FAB and chat headers, and
|
||||
// avatar key into the static AVATAR_OPTIONS registry. Both nullable so
|
||||
// the user can clear them back to defaults.
|
||||
display_name: z.string().min(1).max(60).nullable().optional(),
|
||||
avatar_id: z.string().max(60).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const companyId =
|
||||
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
// Defense in depth alongside RLS — confirm membership before reading.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select(
|
||||
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, source_signals, field_overrides, composed_at, composer_model, composer_version, verified_at, verified_by_user_id, display_name, avatar_id',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ data: null })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let body: z.infer<typeof PatchBody>
|
||||
try {
|
||||
body = PatchBody.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
// RLS guards reads/updates by company_id; defense in depth — confirm membership.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
// Load current overrides to merge timestamp-stamped entries. Avoids round-trip
|
||||
// when caller sends only an atom-array change.
|
||||
const { data: current } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('field_overrides')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: 'agent_profile not found for this company' }, { status: 404 })
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.field_overrides && Object.keys(body.field_overrides).length > 0) {
|
||||
const merged: Record<string, { value: unknown; overridden_at: string }> = {
|
||||
...((current.field_overrides as Record<string, { value: unknown; overridden_at: string }>) ?? {}),
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
for (const [k, v] of Object.entries(body.field_overrides)) {
|
||||
merged[k] = { value: v, overridden_at: now }
|
||||
}
|
||||
update.field_overrides = merged
|
||||
}
|
||||
if (body.atoms?.horizontal_atoms) update.horizontal_atoms = body.atoms.horizontal_atoms
|
||||
if (body.atoms?.vertical_atoms) update.vertical_atoms = body.atoms.vertical_atoms
|
||||
if (body.atoms?.modifier_atoms) update.modifier_atoms = body.atoms.modifier_atoms
|
||||
if (body.profile_summary) update.profile_summary = body.profile_summary
|
||||
if (body.display_name !== undefined) update.display_name = body.display_name
|
||||
if (body.avatar_id !== undefined) update.avatar_id = body.avatar_id
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_profiles')
|
||||
.update(update)
|
||||
.eq('company_id', companyId)
|
||||
.select(
|
||||
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, field_overrides',
|
||||
)
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
// POST /api/agent/profile/verify
|
||||
//
|
||||
// Stamps verified_at + verified_by_user_id on agent_profiles when the user
|
||||
// clicks "Det här ser rätt ut — kör" in Phase B. Idempotent: re-verifying
|
||||
// updates the timestamp; this is desirable for "Bygg om" rebuild flows.
|
||||
|
||||
const BodySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json().catch(() => ({})))
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
// Defense in depth alongside RLS — confirm membership for the target
|
||||
// company; a non-viewer role is required to stamp verified_at.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership || membership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_profiles')
|
||||
.update({
|
||||
verified_at: new Date().toISOString(),
|
||||
verified_by_user_id: user.id,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select('company_id, verified_at, verified_by_user_id')
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
// GET /api/agent/skills
|
||||
//
|
||||
// Read-only transparency surface for the in-app bookkeeping assistant's domain
|
||||
// knowledge ("atoms"). Powers /settings/agent-skills — the companion to
|
||||
// /settings/agent-memory. Memory is what the assistant *learned* about this
|
||||
// company (user-editable); skills are the Swedish-accounting expertise it
|
||||
// *ships* with — authored in .claude/skills/**/SKILL.md, seeded into
|
||||
// agent_atom_registry, read-only for users, curated via mcp_exposed.
|
||||
//
|
||||
// Two shapes off one route:
|
||||
// (no params) → metadata for every active + exposed atom, each flagged
|
||||
// active-for-this-company. Bodies omitted so the list payload
|
||||
// stays small.
|
||||
// ?slug=<id> → the full SKILL.md body for one atom, loaded on expand.
|
||||
//
|
||||
// "Active for this company": horizontal atoms are regulatory and shared by
|
||||
// every Swedish company, so always active. Vertical/modifier atoms are active
|
||||
// only when the composer selected them into this company's agent_profile —
|
||||
// others are shown dormant so the user sees both the full library and what's
|
||||
// tuned for them. The profile arrays store full ids ("vertical/konsult-it"),
|
||||
// matched directly against agent_atom_registry.id (see lib/agent/chat/system-prompt.ts).
|
||||
|
||||
interface AtomMeta {
|
||||
id: string
|
||||
tier: 'horizontal' | 'vertical' | 'modifier'
|
||||
title: string
|
||||
description: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')
|
||||
|
||||
// Detail: one atom's body, fetched lazily when the user expands a card.
|
||||
if (slug) {
|
||||
const { data, error } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, title, body, is_active, mcp_exposed')
|
||||
.eq('id', slug)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data || !data.is_active || !data.mcp_exposed) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ data: { id: data.id, title: data.title, body: data.body ?? '' } })
|
||||
}
|
||||
|
||||
// List: metadata for every visible atom + which are active for this company.
|
||||
const { data: atoms, error } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, tier, title, description')
|
||||
.eq('is_active', true)
|
||||
.eq('mcp_exposed', true)
|
||||
.is('parent_atom_id', null) // show top-level skills only; reference children are internal
|
||||
.order('tier', { ascending: true })
|
||||
.order('title', { ascending: true })
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('vertical_atoms, modifier_atoms')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const verticalActive = new Set((profile?.vertical_atoms as string[] | null) ?? [])
|
||||
const modifierActive = new Set((profile?.modifier_atoms as string[] | null) ?? [])
|
||||
|
||||
const result: AtomMeta[] = (atoms ?? []).map((a) => {
|
||||
const tier = a.tier as AtomMeta['tier']
|
||||
const active =
|
||||
tier === 'horizontal'
|
||||
? true
|
||||
: tier === 'vertical'
|
||||
? verticalActive.has(a.id)
|
||||
: modifierActive.has(a.id)
|
||||
return { id: a.id, tier, title: a.title, description: a.description, active }
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
// GET /api/documents/:id/extraction-status
|
||||
//
|
||||
// Light-weight polling endpoint for the AI document-extraction pipeline.
|
||||
// Returns the minimal fields needed to drive an "extracting…" UI without
|
||||
// touching storage (no signed URL creation per poll).
|
||||
//
|
||||
// Derived status:
|
||||
// running — extracted_at IS NULL (pipeline hasn't stamped yet)
|
||||
// succeeded — extracted_at IS NOT NULL AND extracted_data IS NOT NULL
|
||||
// unsupported — extraction_model = 'skipped:*' (HEIC, ZIP, …)
|
||||
// failed — extracted_at IS NOT NULL AND extracted_data IS NULL AND
|
||||
// extraction_model = 'failed:*'
|
||||
// disabled — the document-extraction extension isn't enabled (column
|
||||
// stays untouched indefinitely). Client times out and shows
|
||||
// a quiet fallback. We don't distinguish this from running
|
||||
// server-side — the client decides based on elapsed time.
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { id } = await params
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, extracted_at, extracted_data, extraction_model')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
const extractedAt = data.extracted_at as string | null
|
||||
const extractedData = data.extracted_data as Record<string, unknown> | null
|
||||
const model = data.extraction_model as string | null
|
||||
|
||||
let status: 'running' | 'succeeded' | 'failed' | 'unsupported'
|
||||
if (!extractedAt) {
|
||||
status = 'running'
|
||||
} else if (extractedData) {
|
||||
status = 'succeeded'
|
||||
} else if (model?.startsWith('skipped:')) {
|
||||
status = 'unsupported'
|
||||
} else {
|
||||
status = 'failed'
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: data.id,
|
||||
status,
|
||||
extracted_at: extractedAt,
|
||||
extraction_model: model,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const requireCompanyIdMock = vi.fn()
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: (...args: unknown[]) => requireCompanyIdMock(...args),
|
||||
}))
|
||||
|
||||
const requireWritePermissionMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const mappingMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
||||
buildMappingResultFromCategory: (...args: unknown[]) => mappingMock(...args),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
requireWritePermissionMock.mockResolvedValue({ ok: true })
|
||||
requireCompanyIdMock.mockResolvedValue('company-1')
|
||||
mappingMock.mockReturnValue({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/pending-operations/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks viewers', async () => {
|
||||
const { NextResponse } = await import('next/server')
|
||||
requireWritePermissionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
})
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects empty body', async () => {
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', body: {} }),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when op not visible', async () => {
|
||||
enqueue({ data: null })
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-x', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-x' }),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when op is no longer pending', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'committed',
|
||||
params: { transaction_id: 'tx-1', category: 'expense_other' },
|
||||
preview_data: {},
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 400 when operation_type is not editable', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'create_invoice',
|
||||
status: 'pending',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('re-derives accounts and returns updated preview on category change', async () => {
|
||||
// Sequence:
|
||||
// 1. pending_operations lookup
|
||||
// 2. transactions lookup
|
||||
// 3. company_settings lookup
|
||||
// 4. pending_operations update → returns updated row
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
params: {
|
||||
transaction_id: 'tx-1',
|
||||
category: 'expense_other',
|
||||
vat_treatment: null,
|
||||
},
|
||||
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
|
||||
title: 'Kategorisera: X',
|
||||
},
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: -500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-10',
|
||||
},
|
||||
})
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
params: { transaction_id: 'tx-1', category: 'expense_software', vat_treatment: null },
|
||||
preview_data: {
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
amount: 500,
|
||||
currency: 'SEK',
|
||||
vat_lines: [],
|
||||
category: 'expense_software',
|
||||
},
|
||||
title: 'Kategorisera: X',
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { preview_data: { category: string; debit_account: string } }
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.preview_data.category).toBe('expense_software')
|
||||
expect(body.data.preview_data.debit_account).toBe('5410')
|
||||
expect(mappingMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 400 when mapping yields no accounts', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
params: { transaction_id: 'tx-1', category: 'expense_other' },
|
||||
preview_data: {},
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
mappingMock.mockReturnValueOnce({
|
||||
debit_account: null,
|
||||
credit_account: null,
|
||||
vat_lines: [],
|
||||
})
|
||||
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'private' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
@@ -141,6 +142,38 @@ describe('POST /api/pending-operations/:id/commit', () => {
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns the structured ACCOUNTS_NOT_IN_CHART envelope when the chart lacks accounts', async () => {
|
||||
// The booking is valid but posts to reverse-charge accounts (2614/2645)
|
||||
// not active in the chart. The engine throws AccountsNotInChartError; the
|
||||
// dispatcher must release the op back to 'pending' (retryable, see the
|
||||
// 6th enqueued response) and the route must return the structured error
|
||||
// with code + account_numbers so the chat can offer activation — NOT a
|
||||
// raw error string.
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
|
||||
const settings = makeCompanySettings()
|
||||
mockCreateJournalEntry.mockRejectedValueOnce(new AccountsNotInChartError(['2645', '2614']))
|
||||
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // route fetch op
|
||||
{ data: { id: 'op-1' } }, // CAS claim (pending -> committing)
|
||||
{ data: tx }, // fetch transaction
|
||||
{ data: settings }, // fetch company settings
|
||||
{ data: [{ id: 'fp-1' }] }, // fiscal period exists
|
||||
{ data: null, error: null }, // dispatcher releases op back to 'pending'
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; account_numbers: string[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
// Numeric sort puts 2614 before 2645 regardless of input order.
|
||||
expect(body.error.account_numbers).toEqual(['2614', '2645'])
|
||||
})
|
||||
|
||||
it('returns 409 when transaction already categorized', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'existing-je' })
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { bookkeepingErrorResponse, AccountsNotInChartError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -49,6 +49,15 @@ export async function POST(
|
||||
if (result.status === 'committed') {
|
||||
return NextResponse.json({ data: result.data })
|
||||
}
|
||||
// Recoverable accounts-not-in-chart: return the structured envelope (code +
|
||||
// account_numbers) so the client can offer activation and retry the still-
|
||||
// pending op, instead of leaking the raw error string into the chat.
|
||||
if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) {
|
||||
const structured = bookkeepingErrorResponse(
|
||||
new AccountsNotInChartError(result.account_numbers)
|
||||
)
|
||||
if (structured) return structured
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: result.http_status ?? 500 }
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
const RejectBodySchema = z.object({
|
||||
rejection_category: z
|
||||
.enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other'])
|
||||
.optional(),
|
||||
rejection_reason: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/pending-operations/:id/reject
|
||||
*
|
||||
* Reject a pending operation. Marks it as rejected without executing.
|
||||
* Reject a pending operation. Optionally accepts a JSON body with
|
||||
* `rejection_category` (fixed enum) and `rejection_reason` (free text). Both
|
||||
* are stored on the row so agents can fetch them via gnubok_get_recent_rejections
|
||||
* and learn from "no". The body is optional — bodyless POSTs from older
|
||||
* clients still mark the op rejected with NULL category/reason.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -25,6 +37,30 @@ export async function POST(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Body is optional — accept empty/missing body without rejecting the request.
|
||||
// Old clients posted no body; the UI dialog will now post a body, but we
|
||||
// keep accepting both shapes to avoid coupling the API to the UI version.
|
||||
let rejectionCategory: string | undefined
|
||||
let rejectionReason: string | undefined
|
||||
const contentLength = request.headers.get('content-length')
|
||||
if (contentLength && contentLength !== '0') {
|
||||
try {
|
||||
const raw = await request.json()
|
||||
const parsed = RejectBodySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
rejectionCategory = parsed.data.rejection_category
|
||||
rejectionReason = parsed.data.rejection_reason?.trim() || undefined
|
||||
} catch {
|
||||
// Body present but unparseable — fail closed.
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: op, error: fetchError } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('id, status')
|
||||
@@ -48,6 +84,8 @@ export async function POST(
|
||||
.update({
|
||||
status: 'rejected',
|
||||
resolved_at: new Date().toISOString(),
|
||||
...(rejectionCategory ? { rejection_category: rejectionCategory } : {}),
|
||||
...(rejectionReason ? { rejection_reason: rejectionReason } : {}),
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
// PATCH /api/pending-operations/[id]
|
||||
//
|
||||
// Edit-before-approve. Today only supports staged categorize_transaction
|
||||
// operations — the user can pick a different category (and/or VAT treatment)
|
||||
// before clicking Godkänn. We re-derive the booking via the same mapping
|
||||
// engine the commit path uses so the preview the user approves equals the
|
||||
// preview that gets posted.
|
||||
//
|
||||
// Other operation types return 400. As specialized editors land (e.g. edit
|
||||
// invoice line items before send) they extend this dispatcher.
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const CATEGORIES = [
|
||||
'income_services', 'income_products', 'income_other',
|
||||
'expense_equipment', 'expense_software', 'expense_travel', 'expense_office',
|
||||
'expense_marketing', 'expense_professional_services', 'expense_education',
|
||||
'expense_representation', 'expense_consumables', 'expense_vehicle',
|
||||
'expense_telecom', 'expense_bank_fees', 'expense_card_fees',
|
||||
'expense_currency_exchange', 'expense_other', 'private', 'uncategorized',
|
||||
] as const satisfies readonly TransactionCategory[]
|
||||
|
||||
const VAT_TREATMENTS = [
|
||||
'standard_25', 'reduced_12', 'reduced_6',
|
||||
'reverse_charge', 'export', 'exempt',
|
||||
] as const satisfies readonly VatTreatment[]
|
||||
|
||||
const PatchSchema = z
|
||||
.object({
|
||||
category: z.enum(CATEGORIES).optional(),
|
||||
vat_treatment: z.enum(VAT_TREATMENTS).nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => v.category !== undefined || v.vat_treatment !== undefined,
|
||||
{ message: 'Nothing to update' },
|
||||
)
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { id } = await params
|
||||
|
||||
let body: z.infer<typeof PatchSchema>
|
||||
try {
|
||||
body = PatchSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: op } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('id, company_id, operation_type, status, params, preview_data, title')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
|
||||
|
||||
if (op.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: `Operation already ${op.status} — cannot edit.` },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
if (op.operation_type !== 'categorize_transaction') {
|
||||
return NextResponse.json(
|
||||
{ error: `Editing ${op.operation_type} is not supported.` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const oldParams = (op.params as Record<string, unknown>) ?? {}
|
||||
const newCategory =
|
||||
body.category ?? (oldParams.category as TransactionCategory | undefined)
|
||||
const newVatTreatment =
|
||||
body.vat_treatment !== undefined
|
||||
? (body.vat_treatment ?? undefined)
|
||||
: (oldParams.vat_treatment as VatTreatment | undefined)
|
||||
|
||||
if (!newCategory) {
|
||||
return NextResponse.json({ error: 'category is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const txId = oldParams.transaction_id as string | undefined
|
||||
if (!txId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Operation has no transaction_id; cannot re-derive.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// Re-derive the preview using the same mapping engine the commit path uses.
|
||||
const { data: tx } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!tx) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma')
|
||||
|
||||
const isBusiness = newCategory !== 'private'
|
||||
const mapping = buildMappingResultFromCategory(
|
||||
newCategory,
|
||||
tx as Transaction,
|
||||
isBusiness,
|
||||
entityType,
|
||||
newVatTreatment,
|
||||
)
|
||||
|
||||
if (!mapping.debit_account || !mapping.credit_account) {
|
||||
return NextResponse.json(
|
||||
{ error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const oldPreview = (op.preview_data as Record<string, unknown>) ?? {}
|
||||
const newPreview = {
|
||||
...oldPreview,
|
||||
debit_account: mapping.debit_account,
|
||||
credit_account: mapping.credit_account,
|
||||
amount: Math.abs((tx as Transaction).amount),
|
||||
currency: (tx as Transaction).currency,
|
||||
vat_lines: (mapping.vat_lines ?? []).map((v) => ({
|
||||
account: v.account_number,
|
||||
amount: v.debit_amount || v.credit_amount,
|
||||
})),
|
||||
category: newCategory,
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...oldParams,
|
||||
category: newCategory,
|
||||
vat_treatment: newVatTreatment ?? null,
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('pending_operations')
|
||||
.update({ params: newParams, preview_data: newPreview })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, params, preview_data, title, status')
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
}
|
||||
@@ -197,6 +197,72 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('flags an inbox underlag matched to the transaction as booked', async () => {
|
||||
// A document was attached to this transaction in the inbox
|
||||
// (matched_transaction_id) but not booked from there. Booking the
|
||||
// transaction here — no inbox_item_id in the body — must still stamp the
|
||||
// matched inbox item with the new journal entry and link its document.
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'GitHub',
|
||||
journal_entry_id: null,
|
||||
document_id: null, // ensure document_attachments is touched ONLY by the inbox propagation
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
|
||||
// Inbox propagation: one matched item with a document
|
||||
enqueue({ data: [{ id: 'inbox-1', document_id: 'doc-1' }], error: null })
|
||||
enqueue({ data: null, error: null }) // document_attachments update
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items update
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
// The propagation looked up matched inbox items and linked the document.
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
|
||||
})
|
||||
|
||||
it('does not touch the inbox when no underlag is matched to the transaction', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'GitHub',
|
||||
journal_entry_id: null,
|
||||
document_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update
|
||||
enqueue({ data: [], error: null }) // inbox propagation: no matched items
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// No matched underlag → no document/inbox writes from the propagation.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
|
||||
})
|
||||
|
||||
it('returns success with error when journal entry creation fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
|
||||
@@ -638,8 +638,24 @@ export const POST = withRouteContext(
|
||||
.eq('id', inboxItem.document_id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
// Reflect the booking back onto the inbox row so it stops appearing as
|
||||
// unmatched. Categorizing here puts the underlag on a verifikation,
|
||||
// which is the inbox's "booked" state. Without this the inbox keeps
|
||||
// offering "Matcha mot transaktion" for an underlag that's already on a
|
||||
// posted entry — while the transactions view (which reads the
|
||||
// doc↔verifikat link) already shows it as attached. Mirrors the
|
||||
// backfill that /attach-document does for the manual paperclip path.
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: id,
|
||||
created_journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', body.inbox_item_id)
|
||||
.eq('company_id', companyId)
|
||||
} catch (inboxErr) {
|
||||
txLog.warn('failed to link inbox document (non-critical)', inboxErr as Error)
|
||||
txLog.warn('failed to sync inbox item after booking (non-critical)', inboxErr as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,6 +703,46 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
|
||||
}
|
||||
|
||||
// Flag any inbox underlag already matched to this transaction as booked.
|
||||
// The block above only fires when the caller passes an explicit
|
||||
// inbox_item_id (booking straight from the inbox flow). Booking the same
|
||||
// transaction from anywhere else — the /transactions list, quick review —
|
||||
// would otherwise leave an attached underlag stuck as "Kopplad" in the
|
||||
// inbox forever. Here we resolve it by the link itself (matched_transaction
|
||||
// _id) so the inbox reflects the booking regardless of entry point. Mirrors
|
||||
// the propagation in lib/pending-operations/commit.ts. Runs post-CAS so we
|
||||
// never stamp the inbox with a journal entry that lost the race.
|
||||
if (journalEntryId) {
|
||||
try {
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('matched_transaction_id', id)
|
||||
.is('created_journal_entry_id', null)
|
||||
|
||||
for (const inbox of (matchedInboxItems ?? []) as Array<{
|
||||
id: string
|
||||
document_id: string | null
|
||||
}>) {
|
||||
if (inbox.document_id) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', inbox.document_id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ created_journal_entry_id: journalEntryId })
|
||||
.eq('id', inbox.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
} catch (inboxErr) {
|
||||
txLog.warn('failed to flag matched inbox items after booking (non-critical)', inboxErr as Error)
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockGetBestInvoiceMatch = vi.fn()
|
||||
vi.mock('@/lib/invoices/invoice-matching', () => ({
|
||||
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/batch-match-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const response = await POST()
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('calls the invoice matcher with companyId (not user.id) and records the match', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500 })
|
||||
enqueue({ data: [tx], error: null }) // fetch uncategorized income transactions
|
||||
enqueue({ data: null, error: null }) // update transaction with potential_invoice_id
|
||||
|
||||
mockGetBestInvoiceMatch.mockResolvedValue({ invoice: { id: 'inv-1' }, confidence: 0.9 })
|
||||
|
||||
const response = await POST()
|
||||
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual({ processed: 1, matched: 1 })
|
||||
|
||||
// Regression guard: the matcher's 2nd arg is companyId. The bug passed user.id
|
||||
// here, which never equals any invoices.company_id, so it silently matched zero.
|
||||
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
0.5
|
||||
)
|
||||
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('returns matched: 0 when no invoice meets the confidence threshold', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500 })
|
||||
enqueue({ data: [tx], error: null })
|
||||
|
||||
mockGetBestInvoiceMatch.mockResolvedValue(null)
|
||||
|
||||
const response = await POST()
|
||||
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual({ processed: 1, matched: 0 })
|
||||
})
|
||||
|
||||
it('returns 500 when the transaction fetch fails', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const response = await POST()
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Failed to fetch transactions')
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ export async function POST() {
|
||||
try {
|
||||
const bestMatch = await getBestInvoiceMatch(
|
||||
supabase,
|
||||
user.id,
|
||||
companyId,
|
||||
tx as Transaction,
|
||||
0.50
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function GET(request: Request) {
|
||||
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, reference, journal_entry_id, reconciliation_method')
|
||||
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
|
||||
|
||||
@@ -250,6 +250,25 @@ h1, h2, h3 {
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Three-dot "typing" indicator used by the agent chat pre-token bubble.
|
||||
Each dot rides the same wave but with staggered animation-delay so the
|
||||
bounce reads left-to-right. */
|
||||
@keyframes typingDot {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||
30% { transform: translateY(-3px); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-typing-dot {
|
||||
animation: typingDot 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-typing-dot {
|
||||
animation: gentlePulse 1.6s ease-in-out infinite;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered entrance animation */
|
||||
.stagger-enter > * {
|
||||
animation: slideUp var(--duration-slow) var(--ease-out) both;
|
||||
|
||||
Reference in New Issue
Block a user