524d9978f1
* fix(migration): resumable underlag import without inline extraction, same-origin MCP storage URLs The Fortnox underlag import ran every file's AI extraction inline inside one request and hit the hosted 300 s function limit after ~17 of 113 files (twice on 2026-08-21); the UI showed the generic "underlagen kunde inte importeras" although the files it did reach were linked. The import now works in time-budgeted slices with a stable cursor (the UI loops until the server reports the end and shows "x av y") and opts out of extraction (extractionOwner 'none', stamped skipped:opted_out): every file is linked to its posted verifikat on arrival, so the booking is already known. MCP signed Storage URLs (upload_url, signed_url, download_url) are served through a same-origin proxy, /api/storage/[...path], because Claude Desktop's sandbox only reaches the MCP host and blocked the PUT to <project>.supabase.co. The signed token stays the only credential; the proxy forwards only signed documents-bucket paths to our own Storage host and is a no-op rewrite when NEXT_PUBLIC_APP_URL is unset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(mcp): keep the storage-proxy note out of the size-capped tool descriptions The per-tool 280-char cap and the tools/list payload ceiling both tripped on the two sentences added to gnubok_create_document_upload and gnubok_get_document_content; the why now lives in a code comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(review): id cursor, stall = error, capped upload body, encoded dot segments Review follow-ups on #1783: - the import cursor is the last handled provider attachment id, not an index, so a file Fortnox adds or removes mid-sweep shifts nothing - a partial answer whose cursor does not advance (or the round guard) is reported as ARCIM_DOCUMENT_IMPORT_STALLED instead of "complete"; the slices already landed stay reported and the retry button resumes - the storage proxy reads the PUT body as a capped stream instead of buffering an unbounded payload before measuring it - object paths are rejected when any segment decodes to "." or ".." (or holds a separator), and the URL fetch() would actually request is re-checked against the allowlist after normalisation - download_url description no longer claims a direct Storage URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
export const ARCIM_DOCUMENT_IMPORT_ENDPOINT =
|
|
'/api/extensions/ext/arcim-migration/import-documents'
|
|
|
|
export const PROVIDER_DOCUMENT_SCOPES_REQUIRED =
|
|
'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
|
|
|
|
/**
|
|
* The connect request does not ask Fortnox for Arkiv and Koppla fil at all,
|
|
* so no reconnect can grant them: the error offers no action, only the truth.
|
|
*/
|
|
export const PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE =
|
|
'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE'
|
|
|
|
export const ARCIM_DOCUMENT_OAUTH_RESUME_KEY =
|
|
'arcim-document-oauth-resume'
|
|
|
|
export type ArcimDocumentOAuthResumeAction = 'discover' | 'import'
|
|
|
|
export interface ArcimDocumentOAuthResume {
|
|
action: ArcimDocumentOAuthResumeAction
|
|
}
|
|
|
|
export function parseArcimDocumentOAuthResume(
|
|
value: string | null,
|
|
): ArcimDocumentOAuthResume | null {
|
|
if (value !== 'discover' && value !== 'import') return null
|
|
return { action: value }
|
|
}
|
|
|
|
/** Poll a provider popup so closing it cannot leave the UI reconnecting forever. */
|
|
export function watchArcimOAuthPopup(
|
|
popup: { closed: boolean },
|
|
onClosed: () => void,
|
|
intervalMs: number = 500,
|
|
graceMs: number = 500,
|
|
): () => void {
|
|
let active = true
|
|
let graceTimeout: ReturnType<typeof setTimeout> | null = null
|
|
const interval = globalThis.setInterval(() => {
|
|
if (!popup.closed || !active) return
|
|
globalThis.clearInterval(interval)
|
|
graceTimeout = globalThis.setTimeout(() => {
|
|
if (!active) return
|
|
active = false
|
|
onClosed()
|
|
}, graceMs)
|
|
}, intervalMs)
|
|
|
|
return () => {
|
|
if (!active) return
|
|
active = false
|
|
globalThis.clearInterval(interval)
|
|
if (graceTimeout) globalThis.clearTimeout(graceTimeout)
|
|
}
|
|
}
|
|
|
|
export interface ArcimDocumentImportResult {
|
|
provider: string
|
|
scanned: number
|
|
linked: number
|
|
skipped: number
|
|
unmatched: number
|
|
failed: number
|
|
dryRun: boolean
|
|
unmatchedSamples: { uploadId: string; voucher: string; date: string }[]
|
|
/** Attachments in the provider's whole list (not just this call). */
|
|
total: number
|
|
/** The server stopped at its time budget; continue after nextCursor. */
|
|
partial: boolean
|
|
/** Provider id of the last handled attachment, or null when complete. */
|
|
nextCursor: string | null
|
|
}
|
|
|
|
const MAX_UNMATCHED_SAMPLES = 20
|
|
|
|
/**
|
|
* Fold one server call into the running totals of a multi-call import. The
|
|
* route processes a slice per call (hosted function limit), so the numbers
|
|
* the user sees must be the sum of every slice, not the last one.
|
|
*/
|
|
export function mergeArcimDocumentImportResults(
|
|
accumulated: ArcimDocumentImportResult | null,
|
|
next: ArcimDocumentImportResult,
|
|
): ArcimDocumentImportResult {
|
|
if (!accumulated) return next
|
|
return {
|
|
provider: next.provider,
|
|
dryRun: next.dryRun,
|
|
scanned: accumulated.scanned + next.scanned,
|
|
linked: accumulated.linked + next.linked,
|
|
skipped: accumulated.skipped + next.skipped,
|
|
unmatched: accumulated.unmatched + next.unmatched,
|
|
failed: accumulated.failed + next.failed,
|
|
unmatchedSamples: [...accumulated.unmatchedSamples, ...next.unmatchedSamples].slice(
|
|
0,
|
|
MAX_UNMATCHED_SAMPLES,
|
|
),
|
|
total: next.total,
|
|
partial: next.partial,
|
|
nextCursor: next.nextCursor,
|
|
}
|
|
}
|
|
|
|
export interface ArcimDocumentImportProblem {
|
|
code: string | null
|
|
requestId: string | null
|
|
reconnectRequired: boolean
|
|
message?: string
|
|
/** What the source system (Fortnox/Bokio) itself answered, when known. */
|
|
providerMessage?: string
|
|
}
|
|
|
|
export function documentOAuthProblemFromReason(
|
|
reason: string,
|
|
): ArcimDocumentImportProblem {
|
|
const normalized = reason.toLowerCase()
|
|
const scopeFailure =
|
|
normalized.includes('invalid_scope') ||
|
|
normalized.includes('scope') ||
|
|
normalized.includes('behörighet')
|
|
const consentDenied =
|
|
normalized.includes('access_denied') ||
|
|
normalized.includes('denied') ||
|
|
normalized.includes('nekad') ||
|
|
normalized.includes('avbröt')
|
|
|
|
return {
|
|
code: scopeFailure ? PROVIDER_DOCUMENT_SCOPES_REQUIRED : null,
|
|
requestId: null,
|
|
reconnectRequired: scopeFailure || consentDenied,
|
|
message: reason,
|
|
}
|
|
}
|
|
|
|
export type ArcimDocumentImportPhase =
|
|
| 'hidden'
|
|
| 'discovering'
|
|
| 'offered'
|
|
| 'empty'
|
|
| 'dismissed'
|
|
| 'discovery-error'
|
|
| 'importing'
|
|
| 'complete'
|
|
| 'import-error'
|
|
| 'reconnecting'
|
|
|
|
export interface ArcimDocumentImportState {
|
|
phase: ArcimDocumentImportPhase
|
|
found: number
|
|
result: ArcimDocumentImportResult | null
|
|
problem: ArcimDocumentImportProblem | null
|
|
}
|
|
|
|
export const INITIAL_ARCIM_DOCUMENT_IMPORT_STATE: ArcimDocumentImportState = {
|
|
phase: 'hidden',
|
|
found: 0,
|
|
result: null,
|
|
problem: null,
|
|
}
|
|
|
|
export type ArcimDocumentImportAction =
|
|
| { type: 'reset' }
|
|
| {
|
|
type: 'discovery-started'
|
|
provider: string | null
|
|
migrationSucceeded: boolean
|
|
}
|
|
| { type: 'discovery-succeeded'; result: ArcimDocumentImportResult }
|
|
| { type: 'discovery-failed'; problem: ArcimDocumentImportProblem }
|
|
| { type: 'dismissed' }
|
|
| { type: 'import-started' }
|
|
| { type: 'import-progress'; result: ArcimDocumentImportResult }
|
|
| { type: 'import-succeeded'; result: ArcimDocumentImportResult }
|
|
| { type: 'import-failed'; problem: ArcimDocumentImportProblem }
|
|
| { type: 'reconnect-started' }
|
|
|
|
/**
|
|
* The completed preview is the authoritative provider source. The selected
|
|
* provider is only a fallback for older flows that do not retain preview data.
|
|
*/
|
|
export function resolveArcimDocumentFollowUpProvider(
|
|
previewProvider: string | null | undefined,
|
|
selectedProvider: string | null | undefined,
|
|
): 'fortnox' | null {
|
|
const provider = previewProvider ?? selectedProvider
|
|
return provider === 'fortnox' ? provider : null
|
|
}
|
|
|
|
/**
|
|
* Keeps the optional document step separate from the completed migration.
|
|
* A discovery or import failure can therefore never replace the migration's
|
|
* own success result with a failure state.
|
|
*/
|
|
export function arcimDocumentImportReducer(
|
|
state: ArcimDocumentImportState,
|
|
action: ArcimDocumentImportAction,
|
|
): ArcimDocumentImportState {
|
|
switch (action.type) {
|
|
case 'reset':
|
|
return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE
|
|
case 'discovery-started':
|
|
if (action.provider !== 'fortnox' || !action.migrationSucceeded) {
|
|
return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE
|
|
}
|
|
return { phase: 'discovering', found: 0, result: null, problem: null }
|
|
case 'discovery-succeeded':
|
|
if (action.result.provider !== 'fortnox') {
|
|
return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE
|
|
}
|
|
if (action.result.scanned <= 0) {
|
|
return {
|
|
phase: 'empty',
|
|
found: 0,
|
|
result: action.result,
|
|
problem: null,
|
|
}
|
|
}
|
|
return {
|
|
phase: 'offered',
|
|
found: action.result.scanned,
|
|
result: action.result,
|
|
problem: null,
|
|
}
|
|
case 'discovery-failed':
|
|
return {
|
|
phase: 'discovery-error',
|
|
found: 0,
|
|
result: null,
|
|
problem: action.problem,
|
|
}
|
|
case 'dismissed':
|
|
return { ...state, phase: 'dismissed', problem: null }
|
|
case 'import-started':
|
|
return { ...state, phase: 'importing', problem: null }
|
|
case 'import-progress':
|
|
// Running totals after each server slice; the phase stays 'importing'
|
|
// so the UI keeps its spinner and shows "x av y".
|
|
return { ...state, phase: 'importing', result: action.result, problem: null }
|
|
case 'import-succeeded':
|
|
return {
|
|
phase: 'complete',
|
|
found: state.found || action.result.total || action.result.scanned,
|
|
result: action.result,
|
|
problem: null,
|
|
}
|
|
case 'import-failed':
|
|
return { ...state, phase: 'import-error', problem: action.problem }
|
|
case 'reconnect-started':
|
|
return { ...state, phase: 'reconnecting' }
|
|
}
|
|
}
|
|
|
|
export class ArcimDocumentImportRequestError extends Error {
|
|
constructor(readonly problem: ArcimDocumentImportProblem) {
|
|
super(problem.code ?? 'ARCIM_DOCUMENT_IMPORT_FAILED')
|
|
this.name = 'ArcimDocumentImportRequestError'
|
|
}
|
|
}
|
|
|
|
function problemFromPayload(payload: unknown): ArcimDocumentImportProblem {
|
|
const error = (payload as { error?: unknown } | null)?.error
|
|
const structured =
|
|
error && typeof error === 'object'
|
|
? (error as { code?: unknown; requestId?: unknown; details?: unknown })
|
|
: null
|
|
const code = typeof structured?.code === 'string' ? structured.code : null
|
|
const requestId =
|
|
typeof structured?.requestId === 'string' ? structured.requestId : null
|
|
const details =
|
|
structured?.details && typeof structured.details === 'object'
|
|
? (structured.details as { providerMessage?: unknown })
|
|
: null
|
|
const providerMessage =
|
|
typeof details?.providerMessage === 'string' && details.providerMessage.trim()
|
|
? details.providerMessage.trim()
|
|
: null
|
|
|
|
return {
|
|
code,
|
|
requestId,
|
|
reconnectRequired: code === PROVIDER_DOCUMENT_SCOPES_REQUIRED,
|
|
...(providerMessage ? { providerMessage } : {}),
|
|
}
|
|
}
|
|
|
|
type WireDocumentImportResult = Omit<
|
|
ArcimDocumentImportResult,
|
|
'total' | 'partial' | 'nextCursor'
|
|
> &
|
|
Partial<Pick<ArcimDocumentImportResult, 'total' | 'partial' | 'nextCursor'>>
|
|
|
|
function isDocumentImportResult(value: unknown): value is WireDocumentImportResult {
|
|
if (!value || typeof value !== 'object') return false
|
|
const result = value as Partial<ArcimDocumentImportResult>
|
|
return (
|
|
typeof result.provider === 'string' &&
|
|
typeof result.scanned === 'number' &&
|
|
typeof result.linked === 'number' &&
|
|
typeof result.skipped === 'number' &&
|
|
typeof result.unmatched === 'number' &&
|
|
typeof result.failed === 'number' &&
|
|
typeof result.dryRun === 'boolean' &&
|
|
Array.isArray(result.unmatchedSamples)
|
|
)
|
|
}
|
|
|
|
/** Older servers answer without the resume fields: a single complete slice. */
|
|
function normalizeDocumentImportResult(
|
|
result: WireDocumentImportResult,
|
|
): ArcimDocumentImportResult {
|
|
return {
|
|
...result,
|
|
total: typeof result.total === 'number' ? result.total : result.scanned,
|
|
partial: result.partial === true,
|
|
nextCursor:
|
|
typeof result.nextCursor === 'string' && result.nextCursor.length > 0 && result.partial === true
|
|
? result.nextCursor
|
|
: null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call the existing POST route for both discovery and the actual import.
|
|
* `cursor` resumes a partial import; omit it for discovery and the first slice.
|
|
*/
|
|
export async function requestArcimDocumentImport(
|
|
consentId: string,
|
|
dryRun: boolean,
|
|
fetcher: typeof fetch = fetch,
|
|
cursor?: string,
|
|
): Promise<ArcimDocumentImportResult> {
|
|
const response = await fetcher(ARCIM_DOCUMENT_IMPORT_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(
|
|
cursor === undefined ? { consentId, dryRun } : { consentId, dryRun, cursor },
|
|
),
|
|
})
|
|
const payload = await response.json().catch(() => null)
|
|
|
|
if (!response.ok) {
|
|
throw new ArcimDocumentImportRequestError(problemFromPayload(payload))
|
|
}
|
|
|
|
const result = (payload as { result?: unknown } | null)?.result
|
|
if (!isDocumentImportResult(result)) {
|
|
throw new ArcimDocumentImportRequestError({
|
|
code: null,
|
|
requestId: null,
|
|
reconnectRequired: false,
|
|
})
|
|
}
|
|
|
|
return normalizeDocumentImportResult(result)
|
|
}
|
|
|
|
/** Far above any real list: a guard against a server that never completes. */
|
|
const MAX_IMPORT_ROUNDS = 500
|
|
|
|
export const ARCIM_DOCUMENT_IMPORT_STALLED = 'ARCIM_DOCUMENT_IMPORT_STALLED'
|
|
|
|
/**
|
|
* Run the real import to completion: the route works through one time-budgeted
|
|
* slice per call and hands back a cursor, so this loops until the server says
|
|
* it reached the end, reporting running totals after every slice. A thrown
|
|
* request error aborts the loop; the slices already archived stay linked and
|
|
* the next attempt skips them by hash. A server that keeps answering "partial"
|
|
* without advancing (or past the round guard) is reported as a failure, never
|
|
* as a completed import: the retry button resumes safely.
|
|
*/
|
|
export async function runArcimDocumentImportToCompletion(
|
|
consentId: string,
|
|
options: {
|
|
fetcher?: typeof fetch
|
|
onProgress?: (accumulated: ArcimDocumentImportResult) => void
|
|
} = {},
|
|
): Promise<ArcimDocumentImportResult> {
|
|
const fetcher = options.fetcher ?? fetch
|
|
let accumulated: ArcimDocumentImportResult | null = null
|
|
let cursor: string | undefined
|
|
|
|
for (let round = 0; round < MAX_IMPORT_ROUNDS; round++) {
|
|
const slice = await requestArcimDocumentImport(consentId, false, fetcher, cursor)
|
|
accumulated = mergeArcimDocumentImportResults(accumulated, slice)
|
|
if (!slice.partial) {
|
|
return { ...accumulated, partial: false, nextCursor: null }
|
|
}
|
|
if (slice.nextCursor === null || (cursor !== undefined && slice.nextCursor <= cursor)) {
|
|
break
|
|
}
|
|
cursor = slice.nextCursor
|
|
options.onProgress?.(accumulated)
|
|
}
|
|
|
|
throw new ArcimDocumentImportRequestError({
|
|
code: ARCIM_DOCUMENT_IMPORT_STALLED,
|
|
requestId: null,
|
|
reconnectRequired: false,
|
|
})
|
|
}
|