diff --git a/.env.example b/.env.example index 59ad76b..9d321a7 100644 --- a/.env.example +++ b/.env.example @@ -202,3 +202,61 @@ NOCODB_API_TOKEN= # Test-only (tests/p0 real-infra suite; not needed to run the app) # --------------------------------------------------------------------------- # ST0RE_TEST_TENANT_ID= + +# --------------------------------------------------------------------------- +# Document workbench (upload -> DOC0 -> INF0 -> validated export -> credits) +# Wired in code today: app/api/0pt/documents/*, app/api/0pt/credits/*. +# Every value is optional to boot and mandatory to USE: a missing integration +# makes its route answer 503 "not configured" instead of fabricating success. +# --------------------------------------------------------------------------- +# DOC0 document service. URL must be absolute HTTPS; token is a service PAT. +DOC0_URL=https://doc0.siax.io +DOC0_SERVICE_TOKEN= +# Maximum accepted upload size (bytes). Default 25 MiB. +OPT_MAX_UPLOAD_BYTES=26214400 + +# INF0 AI gateway (model calls). No provider key is ever placed in this repo. +# Live path today: the LiteLLM gateway (OpenAI-compatible). The native +# /v1/inf0/* surface is contract-only; INF0_URL is used as a fallback. +INF0_GATEWAY_URL=https://ai-gateway.siax.io/v1 +INF0_GATEWAY_LITELLM_KEY= +INF0_GATEWAY_PROVIDER_KIND=external +INF0_URL=https://api.inf0.siax.io +INF0_API_TOKEN= + +# PAY0 (optional; Stripe is used directly for credit checkout today). +PAY0_URL=https://pay0.siax.io +PAY0_API_TOKEN= + +# N0D / ST0RE (used by the broader pipeline; not required by the routes above). +N0D_URL=https://api.n0d.siax.io +N0D_API_TOKEN= +ST0RE_URL=https://api.st0re.siax.io +ST0RE_API_TOKEN= + +# OpenFGA project authorization. Without all four values, document routes fail +# closed with PROJECT_AUTHORIZATION_NOT_CONFIGURED. +OPENFGA_API_URL=https://fga.siax.io +OPENFGA_STORE_ID= +OPENFGA_MODEL_ID= +OPENFGA_AUTH_TOKEN= + +# Transitional, server-owned project grants used only when OpenFGA is not +# configured. JSON array; actorId "*" means every verified actor in the tenant. +# OPT_PROJECT_GRANTS=[{"tenantId":"379976258701823235","projectId":"prj_demo","actorId":"*"}] +OPT_PROJECT_GRANTS= + +# Model policy for the document workbench. Without this, structuring answers +# MODEL_POLICY_NOT_CONFIGURED. +OPT_ALLOWED_MODELS= +OPT_ALLOWED_DATA_CLASSES=none,low +OPT_MODEL_DESTINATION=external +OPT_EXTERNAL_TRANSFER_ALLOWED=false +OPT_MODEL_MAX_INPUT_TOKENS=60000 +OPT_MODEL_MAX_OUTPUT_TOKENS=4000 + +# Server-owned, versioned credit price book (JSON, one line). No price is ever +# read from a browser. Without it, credit packs/checkout/structuring answer +# PRICE_BOOK_NOT_CONFIGURED. +# {"version":"2026-11-01","currency":"SEK","packs":[{"id":"starter","credits":100,"amountMinor":9900}],"metering":{"pagesPerCredit":5,"inputTokensPerCredit":20000,"outputTokensPerCredit":4000}} +OPT_CREDIT_PRICE_BOOK= diff --git a/.gitignore b/.gitignore index 08a2ba4..85cc7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ services/scrapers/output/ # local-only environment overrides (all real values live in Infisical) .env.local .env.production + +# parallel git worktrees (policy: hidden inside the repo, never committed) +/.worktrees/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5c6b74a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +# Build output and local worktrees are not source. +.next/ +out/ +build/ +dist/ +node_modules/ +.worktrees/ +coverage/ +*.min.js +*.min.css +pnpm-lock.yaml diff --git a/.siax/des1gn-consumer.yaml b/.siax/des1gn-consumer.yaml index 87b56e9..33ec842 100644 --- a/.siax/des1gn-consumer.yaml +++ b/.siax/des1gn-consumer.yaml @@ -4,9 +4,9 @@ schemaVersion: siax.des1gn-consumer/v1 repo: 0pt generatedAt: 2026-09-23T21:12:27.075Z consumes: - @siax/0pt-decision: 'härlett ur package.json (deklarerad workspace:*)' - @siax/config: 'härlett ur package.json (deklarerad ^0.2.1)' - @siax/tokens: 'härlett ur package.json (deklarerad ^0.1.2)' - @siax/ui: 'härlett ur package.json (deklarerad ^0.4.1)' + '@siax/0pt-decision': 'härlett ur package.json (deklarerad workspace:*)' + '@siax/config': 'härlett ur package.json (deklarerad ^0.2.1)' + '@siax/tokens': 'härlett ur package.json (deklarerad ^0.1.2)' + '@siax/ui': 'härlett ur package.json (deklarerad ^0.4.1)' localPrimitives: [] notes: GENERATED stub - tomt ar den arliga baseline-formen; konsumerade kanoniska DES1GN-paket (pinned) listas under consumes, kvarvarande lokala primitives motiveras i notes. diff --git a/Dockerfile b/Dockerfile index 67a8905..0e85169 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,12 @@ RUN corepack enable && for i in 1 2 3 4 5 6 7 8; do corepack prepare pnpm@9 --ac # (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens # manifest saknas på disk. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +# Every workspace importer in pnpm-lock.yaml must have its manifest on disk, +# otherwise --frozen-lockfile refuses to install. Keep this list in sync with +# `packages/*/package.json`. COPY packages/schemas/package.json ./packages/schemas/package.json +COPY packages/decision/package.json ./packages/decision/package.json +COPY packages/siax-access/package.json ./packages/siax-access/package.json # --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript # så fort byggmiljön råkar ha NODE_ENV=production satt. diff --git a/app/api/0pt/credits/balance/route.ts b/app/api/0pt/credits/balance/route.ts new file mode 100644 index 0000000..9cf6f3e --- /dev/null +++ b/app/api/0pt/credits/balance/route.ts @@ -0,0 +1,37 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger'; +import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db'; +import { resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** GET /api/0pt/credits/balance — the authenticated tenant's credit balance. */ +export async function GET(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + + const url = repositoryDatabaseUrl(); + if (!url) { + return NextResponse.json( + { + error: 'CREDITS_NOT_CONFIGURED', + message: 'Credits are not available until the database is configured', + }, + { status: 503 }, + ); + } + + try { + const ledger = new PostgresCreditLedger(getRepositorySql(url)); + const balance = await ledger.balance(verified.scope.tenantId); + return NextResponse.json({ tenant_id: verified.scope.tenantId, ...balance }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Credit balance unavailable'; + const notConfigured = /RLS-enforced|isolation migration/i.test(message); + return NextResponse.json( + { error: notConfigured ? 'CREDITS_NOT_CONFIGURED' : 'CREDITS_UNAVAILABLE', message }, + { status: 503 }, + ); + } +} diff --git a/app/api/0pt/credits/checkout/route.ts b/app/api/0pt/credits/checkout/route.ts new file mode 100644 index 0000000..47c9097 --- /dev/null +++ b/app/api/0pt/credits/checkout/route.ts @@ -0,0 +1,146 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { getStripe } from '@/lib/stripe'; +import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store'; +import { findPack, readCreditPriceBook } from '@/src/document-workbench/price-book'; +import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db'; +import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function appBaseUrl(request: NextRequest): string { + const configured = process.env.NEXT_PUBLIC_APP_URL; + if (configured && /^https:\/\//.test(configured)) return configured.replace(/\/+$/, ''); + return request.nextUrl.origin; +} + +/** + * POST /api/0pt/credits/checkout { pack_id } + * + * Creates a Stripe Checkout Session and stores the server-owned price snapshot + * BEFORE any credit exists. Credits are granted only when the Stripe webhook + * re-reads the session and its payment intent and the ledger accepts the + * idempotent verified-payment command. + */ +export async function POST(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + const { tenantId } = verified.scope; + + const book = readCreditPriceBook(); + if (!book) { + return NextResponse.json( + { error: 'PRICE_BOOK_NOT_CONFIGURED', message: 'Credit purchases are not available yet' }, + { status: 503 }, + ); + } + const stripe = getStripe(); + if (!stripe) { + return NextResponse.json( + { error: 'STRIPE_NOT_CONFIGURED', message: 'Payments are not available yet' }, + { status: 503 }, + ); + } + const databaseUrl = repositoryDatabaseUrl(); + if (!databaseUrl) { + return NextResponse.json( + { + error: 'CREDITS_NOT_CONFIGURED', + message: 'Credits are not available until the database is configured', + }, + { status: 503 }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 }); + } + const packId = (body as { pack_id?: unknown } | null)?.pack_id; + if (typeof packId !== 'string' || !isValidScopeId(packId)) { + return NextResponse.json( + { error: 'INVALID_PACK', message: 'A valid pack_id is required' }, + { status: 400 }, + ); + } + const pack = findPack(book, packId); + if (!pack) { + return NextResponse.json({ error: 'UNKNOWN_PACK', message: 'Unknown credit pack' }, { status: 404 }); + } + + const purchaseId = crypto.randomUUID(); + const base = appBaseUrl(request); + const metadata = { + kind: '0pt_credit_purchase', + tenant_id: tenantId, + purchase_id: purchaseId, + price_book_version: book.version, + }; + + let session; + try { + session = await stripe.checkout.sessions.create({ + mode: 'payment', + client_reference_id: tenantId, + line_items: [ + { + quantity: 1, + price_data: { + currency: book.currency.toLowerCase(), + unit_amount: pack.amountMinor, + product_data: { name: `${pack.credits} 0pt credits` }, + }, + }, + ], + metadata, + payment_intent_data: { metadata }, + success_url: `${base}/portal/krediter?status=success&purchase=${encodeURIComponent(purchaseId)}`, + cancel_url: `${base}/portal/krediter?status=cancelled`, + }); + } catch { + return NextResponse.json( + { error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider could not start a checkout' }, + { status: 502 }, + ); + } + + try { + await new PostgresCreditPurchaseStore(getRepositorySql(databaseUrl)).create({ + tenantId, + purchaseId, + checkoutSessionId: session.id, + amountMinor: pack.amountMinor, + currency: book.currency, + credits: pack.credits, + }); + } catch (error: unknown) { + // Never leave a payable session with no server-side snapshot behind it. + try { + await stripe.checkout.sessions.expire(session.id); + } catch { + /* best effort; the webhook will refuse the unmatched session */ + } + const message = error instanceof Error ? error.message : 'Credit purchase could not be stored'; + return NextResponse.json({ error: 'CREDITS_NOT_CONFIGURED', message }, { status: 503 }); + } + + if (!session.url) { + return NextResponse.json( + { error: 'CHECKOUT_UNAVAILABLE', message: 'The payment provider returned no checkout URL' }, + { status: 502 }, + ); + } + + return NextResponse.json( + { + purchase_id: purchaseId, + checkout_url: session.url, + credits: pack.credits, + amount_minor: pack.amountMinor, + currency: book.currency, + }, + { status: 201 }, + ); +} diff --git a/app/api/0pt/credits/packs/route.ts b/app/api/0pt/credits/packs/route.ts new file mode 100644 index 0000000..3250635 --- /dev/null +++ b/app/api/0pt/credits/packs/route.ts @@ -0,0 +1,36 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { readCreditPriceBook } from '@/src/document-workbench/price-book'; +import { resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/0pt/credits/packs — sellable credit packs from the server-owned + * price book. No price is ever accepted from the client. + */ +export async function GET(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + + const book = readCreditPriceBook(); + if (!book) { + return NextResponse.json( + { + error: 'PRICE_BOOK_NOT_CONFIGURED', + message: 'Credit packs are not available until the server price book is configured', + }, + { status: 503 }, + ); + } + + return NextResponse.json({ + version: book.version, + currency: book.currency, + packs: book.packs.map((pack) => ({ + id: pack.id, + credits: pack.credits, + amount_minor: pack.amountMinor, + })), + }); +} diff --git a/app/api/0pt/documents/models/route.ts b/app/api/0pt/documents/models/route.ts new file mode 100644 index 0000000..736645a --- /dev/null +++ b/app/api/0pt/documents/models/route.ts @@ -0,0 +1,56 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { readCreditPriceBook } from '@/src/document-workbench/price-book'; +import { resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function csv(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +/** + * GET /api/0pt/documents/models — the server-approved model routes and limits + * the customer may choose from. Never a free-form model list. + */ +export async function GET(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + + const models = csv(process.env.OPT_ALLOWED_MODELS); + const dataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES); + const destination = process.env.OPT_MODEL_DESTINATION; + const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0'); + const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0'); + if ( + models.length === 0 || + dataClasses.length === 0 || + (destination !== 'internal' && destination !== 'external') || + !Number.isSafeInteger(maxInputTokens) || + maxInputTokens <= 0 || + !Number.isSafeInteger(maxOutputTokens) || + maxOutputTokens <= 0 + ) { + return NextResponse.json( + { + error: 'MODEL_POLICY_NOT_CONFIGURED', + message: 'Document structuring is not available until the model policy is configured', + }, + { status: 503 }, + ); + } + + const book = readCreditPriceBook(); + return NextResponse.json({ + models, + data_classes: dataClasses, + destination, + external_transfer_allowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true', + max_input_tokens: maxInputTokens, + max_output_tokens: maxOutputTokens, + price_book_version: book?.version ?? null, + }); +} diff --git a/app/api/0pt/documents/structure/route.ts b/app/api/0pt/documents/structure/route.ts new file mode 100644 index 0000000..e823d41 --- /dev/null +++ b/app/api/0pt/documents/structure/route.ts @@ -0,0 +1,419 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-workbench/doc0-source-client'; +import { Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client'; +import { createInf0Gateway } from '@/src/document-workbench/inf0-gateway'; +import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger'; +import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book'; +import { structureDocument } from '@/src/document-workbench/structure-document'; +import { exportValidatedOutput } from '@/src/document-workbench/export-validated-output'; +import type { + OutputFieldContract, + OutputFieldType, +} from '@/src/document-workbench/structured-output-validation'; +import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config'; +import { createProjectAuthorizer } from '@/src/document-workbench/project-access'; +import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db'; +import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const FIELD_TYPES: readonly OutputFieldType[] = ['string', 'integer', 'boolean', 'decimal', 'date']; +const DATA_CLASSES = ['none', 'low', 'medium', 'high', 'restricted'] as const; + +function csv(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +function modelPolicy() { + const allowedModelIds = csv(process.env.OPT_ALLOWED_MODELS); + const allowedDataClasses = csv(process.env.OPT_ALLOWED_DATA_CLASSES); + const destination = process.env.OPT_MODEL_DESTINATION; + const maxInputTokens = Number(process.env.OPT_MODEL_MAX_INPUT_TOKENS ?? '0'); + const maxOutputTokens = Number(process.env.OPT_MODEL_MAX_OUTPUT_TOKENS ?? '0'); + if ( + allowedModelIds.length === 0 || + allowedDataClasses.length === 0 || + (destination !== 'internal' && destination !== 'external') || + !Number.isSafeInteger(maxInputTokens) || + maxInputTokens <= 0 || + !Number.isSafeInteger(maxOutputTokens) || + maxOutputTokens <= 0 + ) { + return undefined; + } + return { + allowedModelIds, + allowedDataClasses, + destination: destination as 'internal' | 'external', + externalTransferAllowed: process.env.OPT_EXTERNAL_TRANSFER_ALLOWED === 'true', + maxInputTokens, + maxOutputTokens, + }; +} + +function parseFields(raw: unknown): OutputFieldContract[] | undefined { + if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) return undefined; + const fields: OutputFieldContract[] = []; + const seen = new Set(); + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const field = item as Record; + if ( + typeof field.name !== 'string' || + !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(field.name) || + seen.has(field.name) || + typeof field.type !== 'string' || + !FIELD_TYPES.includes(field.type as OutputFieldType) + ) { + return undefined; + } + seen.add(field.name); + fields.push({ name: field.name, type: field.type as OutputFieldType, required: true }); + } + return fields; +} + +interface StructureBody { + project_id?: unknown; + document_id?: unknown; + version_id?: unknown; + checksum_sha256?: unknown; + pipeline_stage?: unknown; + instruction?: unknown; + fields?: unknown; + model_id?: unknown; + data_class?: unknown; + format?: unknown; +} + +/** + * POST /api/0pt/documents/structure + * + * Upload → DOC0 segments → INF0 model → mechanically validated, evidence-bound + * output (optionally exported). Credits are reserved before the model runs and + * settled to the measured usage afterwards. + * + * Nothing is trusted from the client except the requested target fields and + * instruction. Source text is re-read from DOC0 and every returned value must + * point at an exact span in that text. + */ +export async function POST(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + const { tenantId, actorId } = verified.scope; + + const runtimeConfig = readDocumentWorkbenchRuntime(); + const gateway = createInf0Gateway(); + if (!runtimeConfig.doc0 || !gateway) { + return NextResponse.json( + { + error: 'WORKBENCH_NOT_CONFIGURED', + message: 'Document structuring is not available until DOC0 and INF0 are configured', + }, + { status: 503 }, + ); + } + const policy = modelPolicy(); + if (!policy) { + return NextResponse.json( + { + error: 'MODEL_POLICY_NOT_CONFIGURED', + message: 'Document structuring is not available until the model policy is configured', + }, + { status: 503 }, + ); + } + const priceBook = readCreditPriceBook(); + const databaseUrl = repositoryDatabaseUrl(); + if (!priceBook || !databaseUrl) { + return NextResponse.json( + { + error: 'CREDITS_NOT_CONFIGURED', + message: 'Document structuring is not available until credits are configured', + }, + { status: 503 }, + ); + } + + let body: StructureBody; + try { + body = (await request.json()) as StructureBody; + } catch { + return NextResponse.json({ error: 'INVALID_BODY', message: 'JSON body required' }, { status: 400 }); + } + + const projectId = body.project_id; + const documentId = body.document_id; + const versionId = body.version_id; + const checksum = body.checksum_sha256; + const pipelineStage = body.pipeline_stage; + const instruction = body.instruction; + const modelId = body.model_id; + const dataClass = body.data_class ?? 'low'; + const format = body.format ?? 'json'; + + if ( + !isValidScopeId(projectId) || + !isValidScopeId(documentId) || + !isValidScopeId(versionId) || + typeof checksum !== 'string' || + !/^[0-9a-f]{64}$/.test(checksum) || + pipelineStage !== 'parsed' || + typeof instruction !== 'string' || + !instruction.trim() || + instruction.length > 4000 || + typeof modelId !== 'string' || + !policy.allowedModelIds.includes(modelId) || + typeof dataClass !== 'string' || + !(DATA_CLASSES as readonly string[]).includes(dataClass) || + (format !== 'json' && format !== 'csv') + ) { + return NextResponse.json( + { error: 'INVALID_REQUEST', message: 'Invalid document structure request' }, + { status: 400 }, + ); + } + const fields = parseFields(body.fields); + if (!fields) { + return NextResponse.json( + { error: 'INVALID_FIELDS', message: 'A valid field contract is required' }, + { status: 400 }, + ); + } + + const authorize = createProjectAuthorizer(); + if (!(await authorize({ tenantId, projectId, actorId }))) { + return NextResponse.json( + { error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' }, + { status: 403 }, + ); + } + + const correlationId = request.headers.get('x-request-id') ?? crypto.randomUUID(); + const context = { + tenantId, + projectId, + actorId, + capability: '0pt.document.structure.v1', + correlationId, + idempotencyKey: correlationId, + }; + + let sources; + try { + sources = await new Doc0SourceClient({ + baseUrl: runtimeConfig.doc0.baseUrl, + serviceToken: runtimeConfig.doc0.serviceToken, + }).read({ + tenantId, + projectId, + actorId, + requestId: correlationId, + receipt: { + documentId, + versionId, + checksumSha256: checksum, + pipelineStage: 'parsed', + }, + }); + } catch (error: unknown) { + if (error instanceof Doc0SourceUnavailableError) { + return NextResponse.json( + { + error: 'DOC0_SOURCE_UNAVAILABLE', + message: + 'The extracted source could not be verified (version, checksum, page cap or tenant). Review or reconciliation is required.', + }, + { status: 422 }, + ); + } + return NextResponse.json( + { error: 'DOC0_SOURCE_UNAVAILABLE', message: 'The extracted source is unavailable' }, + { status: 502 }, + ); + } + + const sourceChars = sources.sources.reduce((total, source) => total + source.text.length, 0); + const estimatedInputTokens = Math.ceil(JSON.stringify(sources.sources).length / 4) + 1024; + if (estimatedInputTokens > policy.maxInputTokens) { + return NextResponse.json( + { + error: 'SOURCE_TOO_LARGE', + message: 'The document is larger than the configured model input limit', + }, + { status: 413 }, + ); + } + + const maximumCredits = creditsForUsage(priceBook, { + pages: Math.max(1, Math.ceil(sourceChars / 1800)), + inputTokens: estimatedInputTokens, + outputTokens: policy.maxOutputTokens, + }); + const reservationId = crypto.randomUUID(); + const ledger = new PostgresCreditLedger(getRepositorySql(databaseUrl)); + try { + await ledger.apply({ + tenantId, + idempotencyKey: `reserve:${reservationId}`, + kind: 'reserve', + reference: reservationId, + amount: maximumCredits, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Credit reservation failed'; + if (/Insufficient available credits/i.test(message)) { + return NextResponse.json( + { + error: 'INSUFFICIENT_CREDITS', + message: 'Not enough credits for this job', + required_credits: maximumCredits, + }, + { status: 402 }, + ); + } + return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 }); + } + + try { + const result = await structureDocument({ + gateway, + request: { + context, + admission: { + tenantId, + projectId, + jobId: `doc:${documentId}:${correlationId}`, + modelId, + dataClass, + maxInputTokens: policy.maxInputTokens, + maxOutputTokens: policy.maxOutputTokens, + maximumCredits, + }, + policy: { + tenantId, + projectId, + allowedModelIds: policy.allowedModelIds, + allowedDataClasses: policy.allowedDataClasses, + destination: policy.destination, + externalTransferAllowed: policy.externalTransferAllowed, + maxInputTokens: policy.maxInputTokens, + maxOutputTokens: policy.maxOutputTokens, + }, + reservation: { + tenantId, + projectId, + jobId: `doc:${documentId}:${correlationId}`, + status: 'reserved', + credits: maximumCredits, + }, + }, + countTokens: async (messages) => Math.ceil(JSON.stringify(messages).length / 4), + instructions: instruction, + fields, + sources: sources.sources, + }); + + const charged = Math.min( + maximumCredits, + creditsForUsage(priceBook, { + pages: Math.max(1, Math.ceil(sourceChars / 1800)), + inputTokens: result.model.promptTokens, + outputTokens: result.model.completionTokens, + }), + ); + let balance; + try { + balance = ( + await ledger.apply({ + tenantId, + idempotencyKey: `settle:${reservationId}`, + kind: 'settle', + reference: reservationId, + amount: charged, + }) + ).balance; + } catch { + // The model result is real; keep the reservation for reconciliation + // rather than reporting a balance we could not write. + balance = undefined; + } + + if (result.status !== 'mechanically_validated') { + return NextResponse.json( + { + status: 'needs_review', + issues: result.issues, + model: result.model, + credits: { reserved: maximumCredits, charged, balance }, + }, + { status: 200 }, + ); + } + + const exported = + format === 'json' + ? exportValidatedOutput({ + format: 'json', + fields, + sources: sources.sources, + value: result.value, + evidence: result.evidence, + }) + : exportValidatedOutput({ + format: 'csv', + fields, + sources: sources.sources, + value: result.value, + evidence: result.evidence, + }); + + return NextResponse.json( + { + status: 'mechanically_validated', + value: result.value, + evidence: result.evidence, + model: result.model, + credits: { reserved: maximumCredits, charged, balance }, + export: + exported.status === 'ready' + ? { media_type: exported.mediaType, content: exported.content } + : undefined, + }, + { status: 200 }, + ); + } catch (error: unknown) { + if (error instanceof Inf0OutcomeUnknownError) { + // Never release a reservation whose model call may have completed. + return NextResponse.json( + { + status: 'outcome_unknown', + message: + 'The model call may have completed. The credit reservation is kept until its usage is reconciled.', + reservation_id: reservationId, + }, + { status: 202 }, + ); + } + try { + await ledger.apply({ + tenantId, + idempotencyKey: `release:${reservationId}`, + kind: 'release', + reference: reservationId, + amount: 0, + }); + } catch { + /* the reservation stays for reconciliation */ + } + const message = error instanceof Error ? error.message : 'Structuring failed'; + return NextResponse.json( + { error: 'STRUCTURE_FAILED', message, reservation_id: reservationId }, + { status: 502 }, + ); + } +} diff --git a/app/api/0pt/documents/upload/route.ts b/app/api/0pt/documents/upload/route.ts new file mode 100644 index 0000000..ddb5c3e --- /dev/null +++ b/app/api/0pt/documents/upload/route.ts @@ -0,0 +1,232 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { Doc0UploadClient } from '@/src/document-workbench/doc0-upload-client'; +import { + DocumentUploadCoordinator, + type CoordinatedUpload, +} from '@/src/document-workbench/upload-coordinator'; +import { PostgresUploadAttemptStore } from '@/src/document-workbench/upload-attempt-store'; +import { + createProjectAuthorizer, + projectAuthorizationConfigured, +} from '@/src/document-workbench/project-access'; +import { readDocumentWorkbenchRuntime } from '@/src/document-workbench/runtime-config'; +import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db'; +import { isValidScopeId, resolveVerifiedScope } from '@/src/http/verified-scope'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const CLASSIFICATIONS = [ + 'public', + 'internal', + 'confidential', + 'restricted', + 'personal-data', + 'financial', + 'legal', +] as const; + +function isClassification(value: string): value is (typeof CLASSIFICATIONS)[number] { + return (CLASSIFICATIONS as readonly string[]).includes(value); +} + +function idempotencyKey(request: NextRequest, form: FormData): string | undefined { + const fromHeader = request.headers.get('idempotency-key'); + const fromBody = form.get('idempotency_key'); + const value = fromHeader ?? (typeof fromBody === 'string' ? fromBody : undefined); + return value && /^[\x21-\x7e]{1,256}$/.test(value) ? value : undefined; +} + +/** + * POST /api/0pt/documents/upload — customer document upload. + * + * multipart/form-data: + * file (required) the original bytes; never trusted metadata + * project_id (required) verified project the file belongs to + * idempotency_key (required) or `Idempotency-Key` header + * classification optional, defaults to `restricted` (conservative) + * + * The tenant and actor come ONLY from middleware's verified headers. The route + * fails closed when project authorization or DOC0 is not configured. + */ +export async function POST(request: NextRequest) { + const verified = resolveVerifiedScope(request); + if ('response' in verified) return verified.response; + const { tenantId, actorId } = verified.scope; + + const runtimeConfig = readDocumentWorkbenchRuntime(); + if (!runtimeConfig.doc0) { + return NextResponse.json( + { + error: 'DOC0_NOT_CONFIGURED', + message: 'Document upload is not available until DOC0 is configured', + }, + { status: 503 }, + ); + } + if (!projectAuthorizationConfigured()) { + return NextResponse.json( + { + error: 'PROJECT_AUTHORIZATION_NOT_CONFIGURED', + message: 'Document upload is not available until project authorization is configured', + }, + { status: 503 }, + ); + } + const databaseUrl = repositoryDatabaseUrl(); + if (!databaseUrl) { + return NextResponse.json( + { + error: 'UPLOAD_JOURNAL_NOT_CONFIGURED', + message: 'Document upload is not available until its durable journal is configured', + }, + { status: 503 }, + ); + } + + const declaredLength = Number(request.headers.get('content-length') ?? '0'); + if (Number.isFinite(declaredLength) && declaredLength > runtimeConfig.doc0.maxUploadBytes + 128 * 1024) { + return NextResponse.json( + { error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' }, + { status: 413 }, + ); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json( + { error: 'INVALID_UPLOAD', message: 'A multipart form with a file is required' }, + { status: 400 }, + ); + } + + const file = form.get('file'); + const projectId = form.get('project_id'); + const key = idempotencyKey(request, form); + const classificationRaw = form.get('classification'); + const classification = + typeof classificationRaw === 'string' && classificationRaw.trim() + ? classificationRaw.trim() + : 'restricted'; + + if (!(file instanceof File) || file.size <= 0) { + return NextResponse.json( + { error: 'INVALID_UPLOAD', message: 'A non-empty file is required' }, + { status: 400 }, + ); + } + if (!isValidScopeId(projectId)) { + return NextResponse.json( + { error: 'INVALID_PROJECT', message: 'A valid project_id is required' }, + { status: 400 }, + ); + } + if (!key) { + return NextResponse.json( + { + error: 'IDEMPOTENCY_KEY_REQUIRED', + message: 'An idempotency key is required for document uploads', + }, + { status: 400 }, + ); + } + if (!isClassification(classification)) { + return NextResponse.json( + { error: 'INVALID_CLASSIFICATION', message: 'Unknown classification' }, + { status: 400 }, + ); + } + if (file.size > runtimeConfig.doc0.maxUploadBytes) { + return NextResponse.json( + { error: 'PAYLOAD_TOO_LARGE', message: 'The uploaded file is too large' }, + { status: 413 }, + ); + } + + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + return NextResponse.json( + { error: 'INVALID_UPLOAD', message: 'The uploaded file could not be read' }, + { status: 400 }, + ); + } + if (bytes.byteLength === 0 || bytes.byteLength !== file.size) { + return NextResponse.json( + { error: 'INVALID_UPLOAD', message: 'The uploaded file is incomplete' }, + { status: 400 }, + ); + } + + const authorize = createProjectAuthorizer(); + const sql = getRepositorySql(databaseUrl); + const coordinator = new DocumentUploadCoordinator({ + attempts: new PostgresUploadAttemptStore(sql), + doc0: new Doc0UploadClient({ + baseUrl: runtimeConfig.doc0.baseUrl, + serviceToken: runtimeConfig.doc0.serviceToken, + maxBytes: runtimeConfig.doc0.maxUploadBytes, + }), + authorize: async (scope) => authorize(scope), + maxBytes: runtimeConfig.doc0.maxUploadBytes, + }); + + let outcome: CoordinatedUpload; + try { + outcome = await coordinator.execute({ + tenantId, + projectId, + actorId, + requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(), + idempotencyKey: key, + filename: file.name || 'source', + mimeType: file.type || 'application/octet-stream', + classification, + bytes, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Upload failed'; + if (/not authorized/i.test(message)) { + return NextResponse.json( + { error: 'OPT_ACCESS_DENIED', message: 'Project access is not authorized' }, + { status: 403 }, + ); + } + if (/cannot be reused/i.test(message)) { + return NextResponse.json( + { + error: 'IDEMPOTENCY_CONFLICT', + message: 'The idempotency key was already used for different content', + }, + { status: 409 }, + ); + } + return NextResponse.json({ error: 'UPLOAD_FAILED', message }, { status: 400 }); + } + + if (outcome.status === 'receipt_verified') { + return NextResponse.json( + { + status: 'receipt_verified', + document_id: outcome.receipt.documentId, + version_id: outcome.receipt.versionId, + checksum_sha256: outcome.receipt.checksumSha256, + pipeline_stage: outcome.receipt.pipelineStage, + }, + { status: 201 }, + ); + } + + return NextResponse.json( + { + status: 'outcome_unknown', + message: + 'The upload may have been accepted. Its journal record is reported below; it does not prove storage or extraction.', + observation: outcome.observation ?? { status: 'reconciliation_required' }, + }, + { status: 202 }, + ); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index ea531a1..4b687a7 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,38 +1,97 @@ -import { NextRequest, NextResponse } from "next/server"; -import type Stripe from "stripe"; -import { getStripe } from "@/lib/stripe"; -import { getOrder, setStatusIfCurrent } from "@/lib/orders-store"; -import { isSiaxError } from "../../siax-error-mapping"; +import { NextRequest, NextResponse } from 'next/server'; +import type Stripe from 'stripe'; +import { getStripe } from '@/lib/stripe'; +import { getOrder, setStatusIfCurrent } from '@/lib/orders-store'; +import { isSiaxError } from '../../siax-error-mapping'; +import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger'; +import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store'; +import { + StripeCreditVerificationError, + verifyStripeCreditPurchase, +} from '@/src/document-workbench/stripe-credit-verification'; +import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db'; + +export const runtime = 'nodejs'; + +/** + * Credit purchases are granted ONLY from Stripe's own re-read of the session + * and payment intent. The webhook body is a trigger, never proof of price or + * payment. The ledger command is idempotent, so duplicate deliveries are safe. + */ +async function handleCreditPurchase( + stripe: NonNullable>, + session: Stripe.Checkout.Session, +) { + const tenantId = session.metadata?.tenant_id; + const purchaseId = session.metadata?.purchase_id; + if (!tenantId || !purchaseId) { + return NextResponse.json({ error: 'unmatched_session' }, { status: 422 }); + } + const url = repositoryDatabaseUrl(); + if (!url) { + return NextResponse.json({ received: false, reason: 'credits_not_configured' }, { status: 503 }); + } + try { + const store = new PostgresCreditPurchaseStore(getRepositorySql(url)); + const snapshot = await store.get(tenantId, purchaseId); + if (!snapshot) { + // Do not acknowledge a paid purchase we cannot bind to a server snapshot. + return NextResponse.json({ received: false, reason: 'purchase_not_found' }, { status: 503 }); + } + if (snapshot.checkoutSessionId !== session.id) { + return NextResponse.json({ error: 'session_mismatch' }, { status: 422 }); + } + const command = await verifyStripeCreditPurchase(stripe, snapshot, { tenantId }); + const result = await new PostgresCreditLedger(getRepositorySql(url)).apply(command); + return NextResponse.json({ received: true, credited: !result.replay, balance: result.balance }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Credit reconciliation failed'; + if (error instanceof StripeCreditVerificationError) { + const terminal = ['mismatch', 'not_paid', 'refunded', 'unsupported'].includes(error.reason); + return NextResponse.json( + { received: false, reason: error.reason, message }, + { status: terminal ? 422 : 503 }, + ); + } + return NextResponse.json({ received: false, reason: 'ledger_unavailable', message }, { status: 503 }); + } +} // POST /api/webhooks/stripe (docs/04) — verify signature, advance order status. export async function POST(req: NextRequest) { const stripe = getStripe(); const whSecret = process.env.STRIPE_WEBHOOK_SECRET; - const sig = req.headers.get("stripe-signature"); + const sig = req.headers.get('stripe-signature'); if (!stripe || !whSecret) { // Never acknowledge an event that was not signature-verified and processed. // A non-2xx response keeps Stripe's delivery retry/reconciliation path open. - return NextResponse.json({ received: false, reason: "not_configured" }, { status: 503 }); + return NextResponse.json({ received: false, reason: 'not_configured' }, { status: 503 }); } if (!sig) { - return NextResponse.json({ error: "missing_signature" }, { status: 400 }); + return NextResponse.json({ error: 'missing_signature' }, { status: 400 }); } const body = await req.text(); let event; try { event = stripe.webhooks.constructEvent(body, sig, whSecret); } catch { - return NextResponse.json({ error: "bad_signature" }, { status: 400 }); + return NextResponse.json({ error: 'bad_signature' }, { status: 400 }); } - if (event.type === "checkout.session.completed") { + if ( + event.type === 'checkout.session.completed' || + event.type === 'checkout.session.async_payment_succeeded' + ) { const s = event.data.object as Stripe.Checkout.Session; const oid = s.metadata?.order_id; const kind = s.metadata?.kind; - if (!oid || (kind !== "deposit" && kind !== "balance")) { - return NextResponse.json({ error: "unmatched_session" }, { status: 422 }); + if (kind === '0pt_credit_purchase') { + return handleCreditPurchase(stripe, s); } - if (s.payment_status !== "paid") { - return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 }); + if (!oid || (kind !== 'deposit' && kind !== 'balance')) { + return NextResponse.json({ error: 'unmatched_session' }, { status: 422 }); + } + if (s.payment_status !== 'paid') { + return NextResponse.json({ error: 'payment_not_confirmed' }, { status: 503 }); } // 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500, // and never a silent received:true for a paid event that was NOT processed — @@ -41,47 +100,52 @@ export async function POST(req: NextRequest) { try { order = await getOrder(oid); } catch (err: unknown) { - if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 }); + if (isSiaxError(err)) + return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 }); throw err; } - if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 }); + if (!order) return NextResponse.json({ error: 'order_not_found' }, { status: 503 }); if (!order.tenant_id || !order.buyer_subject) { // A legacy anonymous draft is not a payable order. Do not silently // acknowledge a paid event until an audited owner-remediation path exists. - return NextResponse.json({ error: "order_owner_missing" }, { status: 503 }); + return NextResponse.json({ error: 'order_owner_missing' }, { status: 503 }); } - const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore; - if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") { - return NextResponse.json({ error: "payment_mismatch" }, { status: 422 }); + const expectedAmount = kind === 'balance' ? order.balance_ore : order.deposit_ore; + if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== 'sek') { + return NextResponse.json({ error: 'payment_mismatch' }, { status: 422 }); } - if (kind === "balance") { - if (order.status !== "accepted" && order.status !== "delivered") { - return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + if (kind === 'balance') { + if (order.status !== 'accepted' && order.status !== 'delivered') { + return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 }); } let delivered = false; try { - delivered = order.status === "accepted" && !!(await setStatusIfCurrent(oid, "accepted", "delivered")); + delivered = order.status === 'accepted' && !!(await setStatusIfCurrent(oid, 'accepted', 'delivered')); } catch (err: unknown) { - if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 }); + if (isSiaxError(err)) + return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 }); throw err; } - if (order.status === "accepted" && !delivered) { - return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + if (order.status === 'accepted' && !delivered) { + return NextResponse.json({ error: 'order_update_failed' }, { status: 503 }); } } else { - const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status); - if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) { - return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + const alreadyAdvanced = ['in_production', 'review', 'revision', 'accepted', 'delivered'].includes( + order.status, + ); + if (order.status !== 'draft' && order.status !== 'deposit_pending' && !alreadyAdvanced) { + return NextResponse.json({ error: 'invalid_order_state' }, { status: 409 }); } let advanced = true; try { - advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, "in_production")); + advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, 'in_production')); } catch (err: unknown) { - if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 }); + if (isSiaxError(err)) + return NextResponse.json({ received: false, reason: 'store_unavailable' }, { status: 503 }); throw err; } if (!advanced) { - return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + return NextResponse.json({ error: 'order_update_failed' }, { status: 503 }); } } } diff --git a/app/portal/dokument/page.tsx b/app/portal/dokument/page.tsx new file mode 100644 index 0000000..8ca20d2 --- /dev/null +++ b/app/portal/dokument/page.tsx @@ -0,0 +1,499 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useT } from '@/components/locale-provider'; +import { PortalShell } from '@/components/portal-shell'; + +/** + * /portal/dokument — the customer's document workspace. + * + * Three honest steps: + * 1. Upload the original file (real bytes, multipart) into DOC0. + * 2. Describe the structure you want and the fields to extract. + * 3. Review the evidence-bound output and download JSON/CSV. + * + * Nothing on this page fabricates success: a 503 means the capability is not + * configured, and an "outcome unknown" upload is shown as exactly that. + */ + +type Receipt = { + document_id: string; + version_id: string; + checksum_sha256: string; + pipeline_stage: string; +}; + +type FieldRow = { name: string; type: 'string' | 'integer' | 'boolean' | 'decimal' | 'date' }; + +type Evidence = { + field: string; + sourceId: string; + start: number; + end: number; + transform: string; +}; + +type StructureResponse = { + status: 'mechanically_validated' | 'needs_review' | 'outcome_unknown'; + value?: Record; + evidence?: Evidence[]; + issues?: { field: string; code: string; row?: number }[]; + model?: { + modelExecuted: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costUsd?: number; + }; + credits?: { reserved: number; charged: number; balance?: { available: number; reserved: number } }; + export?: { media_type: string; content: string }; + message?: string; + error?: string; + reservation_id?: string; +}; + +type ModelsResponse = { + models: string[]; + data_classes: string[]; + destination: string; + external_transfer_allowed: boolean; + max_input_tokens: number; + max_output_tokens: number; + price_book_version: string | null; +}; + +const TYPES: FieldRow['type'][] = ['string', 'integer', 'decimal', 'date', 'boolean']; + +export default function DocumentsPage() { + const t = useT(); + const [projectId, setProjectId] = useState(''); + const [file, setFile] = useState(null); + const [receipt, setReceipt] = useState(null); + const [uploadNote, setUploadNote] = useState(null); + const [uploading, setUploading] = useState(false); + const [unknownOutcome, setUnknownOutcome] = useState(null); + const inputRef = useRef(null); + + const [instruction, setInstruction] = useState(''); + const [fields, setFields] = useState([{ name: 'organization', type: 'string' }]); + const [models, setModels] = useState(null); + const [modelsError, setModelsError] = useState(null); + const [modelId, setModelId] = useState(''); + const [format, setFormat] = useState<'json' | 'csv'>('json'); + const [structuring, setStructuring] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const stored = window.localStorage.getItem('0pt.project_id'); + if (stored) setProjectId(stored); + fetch('/api/0pt/documents/models', { cache: 'no-store' }) + .then(async (r) => { + const body = (await r.json()) as ModelsResponse & { message?: string }; + if (!r.ok) { + setModelsError(body.message ?? 'Modellpolicyn är inte konfigurerad'); + return; + } + setModels(body); + setModelId((current) => current || body.models[0] || ''); + }) + .catch(() => setModelsError('Kunde inte läsa modellpolicyn')); + }, []); + + const upload = useCallback(async () => { + if (!file || uploading) return; + if (!projectId.trim()) { + setError(t({ en: 'A project id is required', sv: 'Ett projekt-id krävs' })); + return; + } + setUploading(true); + setError(null); + setUploadNote(null); + setUnknownOutcome(null); + setReceipt(null); + window.localStorage.setItem('0pt.project_id', projectId.trim()); + const form = new FormData(); + form.set('file', file); + form.set('project_id', projectId.trim()); + form.set('idempotency_key', crypto.randomUUID()); + form.set('classification', 'restricted'); + try { + const response = await fetch('/api/0pt/documents/upload', { + method: 'POST', + body: form, + headers: { 'x-request-id': crypto.randomUUID() }, + }); + const body = (await response.json().catch(() => ({}))) as Record & { + status?: string; + message?: string; + error?: string; + }; + if (response.status === 201) { + setReceipt({ + document_id: String(body.document_id), + version_id: String(body.version_id), + checksum_sha256: String(body.checksum_sha256), + pipeline_stage: String(body.pipeline_stage), + }); + return; + } + if (response.status === 202) { + setUnknownOutcome(body.observation ?? body); + setUploadNote( + t({ + en: 'The upload may have been accepted. It is not confirmed.', + sv: 'Uppladdningen kan ha tagits emot. Den är inte bekräftad.', + }), + ); + return; + } + setUploadNote( + body.message ?? body.error ?? t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' }), + ); + } catch { + setUploadNote(t({ en: 'Upload failed', sv: 'Uppladdningen misslyckades' })); + } finally { + setUploading(false); + } + }, [file, projectId, t, uploading]); + + const structure = useCallback(async () => { + if (!receipt || structuring) return; + if (!instruction.trim()) { + setError(t({ en: 'Describe the structure you want', sv: 'Beskriv strukturen du vill ha' })); + return; + } + if (!modelId) { + setError(t({ en: 'No approved model is configured', sv: 'Ingen godkänd modell är konfigurerad' })); + return; + } + setStructuring(true); + setError(null); + setResult(null); + try { + const response = await fetch('/api/0pt/documents/structure', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-request-id': crypto.randomUUID() }, + body: JSON.stringify({ + project_id: projectId.trim(), + document_id: receipt.document_id, + version_id: receipt.version_id, + checksum_sha256: receipt.checksum_sha256, + pipeline_stage: receipt.pipeline_stage, + instruction, + fields, + model_id: modelId, + data_class: 'low', + format, + }), + }); + const body = (await response.json().catch(() => ({}))) as StructureResponse; + if (response.ok || response.status === 202) { + setResult(body); + } else { + setError( + body.message ?? body.error ?? t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' }), + ); + } + } catch { + setError(t({ en: 'Structuring failed', sv: 'Struktureringen misslyckades' })); + } finally { + setStructuring(false); + } + }, [receipt, structuring, instruction, modelId, projectId, fields, format, t]); + + function download() { + if (!result?.export) return; + const blob = new Blob([result.export.content], { type: result.export.media_type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `0pt-${receipt?.document_id ?? 'result'}.${format}`; + anchor.click(); + URL.revokeObjectURL(url); + } + + function addField() { + if (fields.length >= 100) return; + setFields((current) => [...current, { name: `field_${current.length + 1}`, type: 'string' }]); + } + + function updateField(index: number, patch: Partial) { + setFields((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row))); + } + + function removeField(index: number) { + setFields((current) => current.filter((_, i) => i !== index)); + } + + const card = 'border border-border bg-background p-6'; + const label = 'font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground'; + const input = + 'mt-2 w-full border border-border bg-background px-3 py-2 font-mono text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + + return ( + +
+

+ {t({ + en: 'Upload a file, describe how it should be structured, and get evidence-bound data you can download.', + sv: 'Ladda upp en fil, beskriv hur den ska struktureras och få evidensbunden data som du kan ladda ner.', + })} +

+ + {/* 1 · upload */} +
+
+

+ {t({ en: '1 · Source', sv: '1 · Underlag' })} +

+ {receipt ? ( + + {t({ en: 'uploaded', sv: 'uppladdad' })} + + ) : null} +
+ + + +
inputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + inputRef.current?.click(); + } + }} + className="mt-4 flex cursor-pointer flex-col items-center justify-center border border-dashed border-border px-6 py-10 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {t({ en: 'choose a file', sv: 'välj en fil' })} + + {file + ? file.name + : t({ en: 'PDF, Word, Excel, CSV, images…', sv: 'PDF, Word, Excel, CSV, bilder…' })} + + setFile(event.target.files?.[0] ?? null)} + /> +
+ + + + {receipt ? ( +
+
+
document
+
{receipt.document_id}
+
+
+
version
+
{receipt.version_id}
+
+
+
sha-256
+
{receipt.checksum_sha256}
+
+
+ ) : null} + {uploadNote ?

{uploadNote}

: null} + {unknownOutcome ? ( +
+              {JSON.stringify(unknownOutcome, null, 2)}
+            
+ ) : null} +
+ + {/* 2 · structure */} +
+

+ {t({ en: '2 · Desired structure', sv: '2 · Önskad struktur' })} +

+ +