ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
7.4 KiB
TypeScript
185 lines
7.4 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('agent.composer.tic-fetch')
|
|
|
|
// Live-fetch the TIC company profile via the existing extension HTTP route
|
|
// and cache it on `companies.tic_snapshot`. Used by the agent onboarding
|
|
// stream (Phase A step 1) and the /onboarding/agent server component so the
|
|
// review card has SNI, verksamhetsbeskrivning, address, and recent financials
|
|
// without requiring the user to have visited the TIC workspace beforehand.
|
|
//
|
|
// Why HTTP self-fetch rather than a direct import: core-build CI forbids
|
|
// imports from @/extensions/ in lib/agent/*. Going through the extension's
|
|
// public HTTP surface keeps the boundary intact and works the same in dev
|
|
// and on Vercel. The TIC handler already accepts cookie-auth, so we just
|
|
// forward the user's session cookie.
|
|
//
|
|
// Stale-cache policy: anything cached within the last 7 days is reused
|
|
// verbatim. TIC data is slow-changing (sniCodes, registration, address
|
|
// rarely flip) so this avoids re-hitting TIC on every page load.
|
|
//
|
|
// Rate budget: the /profile endpoint fans out to ~13 TIC (Lens) calls and
|
|
// the account has a ~3000/mo ceiling. So we DON'T eagerly re-fetch every
|
|
// pre-v2 (v1) snapshot: that would blow the budget across the customer
|
|
// base. Instead, v1 snapshots upgrade to v2 lazily: only when a caller
|
|
// that actually consumes the v2 sections passes `upgradeV1: true` (today
|
|
// just the agent-onboarding paths, a deliberate once-per-company action).
|
|
|
|
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
// Default fetch timeout. The agent-onboarding callers override with a longer
|
|
// budget (10s) since the user is on a wait-screen with visible progress and
|
|
// the prior 5s default killed every signup-time fetch in May (~530 wasted
|
|
// upstream Lens calls: the abort fired client-side but the upstream calls
|
|
// kept running and counted against quota). Other callers (background jobs,
|
|
// dev tooling) stay on the conservative default.
|
|
const FETCH_TIMEOUT_MS = 5_000
|
|
|
|
export interface TicSnapshotResult {
|
|
snapshot: Record<string, unknown> | null
|
|
source: 'cached' | 'fetched' | 'fallback'
|
|
}
|
|
|
|
// A snapshot written before the TIC v2 migration lacks the v2-only
|
|
// `statuses` section. v2 always includes the key (possibly an empty
|
|
// array), so its absence is a reliable "this is a v1 snapshot" signal.
|
|
function isV1Snapshot(snapshot: Record<string, unknown> | null): boolean {
|
|
return snapshot != null && !('statuses' in snapshot)
|
|
}
|
|
|
|
export async function ensureTicSnapshot(opts: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
cookieHeader: string
|
|
// Origin to use for the internal self-fetch. The caller derives this from
|
|
// the incoming request's host header so dev (localhost:3000), preview
|
|
// (vercel.app), and production all reach their own instance of the TIC
|
|
// route. Falls back to NEXT_PUBLIC_APP_URL when not supplied: fine for
|
|
// background jobs but wrong for request-scoped paths because that env var
|
|
// is the production canonical URL even in dev.
|
|
origin?: string
|
|
// When true, a cached snapshot still inside the 7-day window is
|
|
// re-fetched if it's a pre-v2 (v1) shape. Gated to deliberate, bounded
|
|
// callers (agent onboarding) so the v1→v2 upgrade doesn't fan out across
|
|
// every company and exhaust the monthly TIC budget.
|
|
upgradeV1?: boolean
|
|
// Override the default 5s fetch timeout. Use when the caller has a UI
|
|
// affordance for waiting (agent onboarding wait-screen) so legitimate
|
|
// fetches don't get aborted before the ~13-call Lens fan-out completes:
|
|
// which was the root cause of the May 2026 quota-burn incident.
|
|
timeoutMs?: number
|
|
}): Promise<TicSnapshotResult> {
|
|
const {
|
|
supabase,
|
|
companyId,
|
|
cookieHeader,
|
|
origin,
|
|
upgradeV1 = false,
|
|
timeoutMs = FETCH_TIMEOUT_MS,
|
|
} = opts
|
|
|
|
const { data: companyRow } = await supabase
|
|
.from('companies')
|
|
.select('org_number, tic_snapshot, tic_snapshot_fetched_at')
|
|
.eq('id', companyId)
|
|
.single()
|
|
|
|
if (!companyRow) return { snapshot: null, source: 'fallback' }
|
|
|
|
const cachedSnapshot = companyRow.tic_snapshot as Record<string, unknown> | null
|
|
const needsV2Upgrade = upgradeV1 && isV1Snapshot(cachedSnapshot)
|
|
|
|
// Fresh cache hit: nothing to do. (Unless the caller needs v2 fields and
|
|
// the cache is still v1, in which case we fall through to a refetch.)
|
|
if (
|
|
cachedSnapshot &&
|
|
!isStale(companyRow.tic_snapshot_fetched_at as string | null) &&
|
|
!needsV2Upgrade
|
|
) {
|
|
return { snapshot: cachedSnapshot, source: 'cached' }
|
|
}
|
|
|
|
// Org number drifts: some onboarding flows persist it on company_settings
|
|
// only (TicWorkspace reads from there). Prefer companies.org_number but
|
|
// fall back to company_settings.org_number so existing companies aren't
|
|
// permanently blocked from TIC enrichment.
|
|
let orgNumber = (companyRow.org_number as string | null) ?? null
|
|
if (!orgNumber) {
|
|
const { data: settingsRow } = await supabase
|
|
.from('company_settings')
|
|
.select('org_number')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
orgNumber = (settingsRow?.org_number as string | null) ?? null
|
|
}
|
|
if (!orgNumber) {
|
|
return { snapshot: (companyRow.tic_snapshot as Record<string, unknown> | null) ?? null, source: 'fallback' }
|
|
}
|
|
|
|
const profile = await fetchTicProfile(orgNumber, cookieHeader, origin, timeoutMs)
|
|
if (!profile) {
|
|
// Fall through with whatever (possibly stale) snapshot we already have.
|
|
return {
|
|
snapshot: (companyRow.tic_snapshot as Record<string, unknown> | null) ?? null,
|
|
source: 'fallback',
|
|
}
|
|
}
|
|
|
|
// Persist. Best-effort: if the update fails, we still return the profile
|
|
// we just fetched so the current request can use it.
|
|
const { error } = await supabase
|
|
.from('companies')
|
|
.update({
|
|
tic_snapshot: profile,
|
|
tic_snapshot_fetched_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', companyId)
|
|
if (error) {
|
|
// Stale data is fine for the current request, but a silent write
|
|
// failure means the next caller re-fetches TIC unnecessarily and the
|
|
// monthly TIC budget bleeds. Surface it via the structured logger.
|
|
log.warn('tic snapshot persist failed', { error: error.message, companyId })
|
|
}
|
|
|
|
return { snapshot: profile, source: 'fetched' }
|
|
}
|
|
|
|
function isStale(fetchedAt: string | null): boolean {
|
|
if (!fetchedAt) return true
|
|
const ts = Date.parse(fetchedAt)
|
|
if (Number.isNaN(ts)) return true
|
|
return Date.now() - ts > STALE_AFTER_MS
|
|
}
|
|
|
|
async function fetchTicProfile(
|
|
orgNumber: string,
|
|
cookieHeader: string,
|
|
origin: string | undefined,
|
|
timeoutMs: number,
|
|
): Promise<Record<string, unknown> | null> {
|
|
const baseUrl = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
const url = `${baseUrl}/api/extensions/ext/tic/profile?org_number=${encodeURIComponent(orgNumber)}`
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
})
|
|
if (!res.ok) {
|
|
log.warn('tic profile non-ok', { url, status: res.status })
|
|
return null
|
|
}
|
|
const body = (await res.json()) as { data?: Record<string, unknown> }
|
|
return body.data ?? null
|
|
} catch (err) {
|
|
// Network error, timeout, TIC extension disabled, TIC API misconfigured.
|
|
// Any of these is a normal fallback: return null so the caller can
|
|
// degrade gracefully.
|
|
log.warn('tic profile fetch failed', {
|
|
url,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return null
|
|
}
|
|
}
|