f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
/**
|
|
* v1 REST error envelope.
|
|
*
|
|
* Wraps the existing structured-error machinery (lib/errors/get-structured-error)
|
|
* into the v1-specific shape that agents consume:
|
|
*
|
|
* {
|
|
* error: {
|
|
* code: machine-readable, stable forever
|
|
* message: Swedish prose
|
|
* message_en: English prose (agents prefer this)
|
|
* details: structured context (pgCode, field issues, period_id...)
|
|
* recovery_hint: natural-language next step the agent can act on
|
|
* docs_url: canonical error-doc URL
|
|
* valid_alternatives: hints like { unlock_endpoint, next_open_period, ...}
|
|
* request_id: correlation id, echoed in X-Request-Id header
|
|
* }
|
|
* }
|
|
*
|
|
* The first three fields exist on the legacy `getStructuredError` output.
|
|
* `recovery_hint`, `docs_url`, `valid_alternatives` are additive: derived from
|
|
* the registry's `remediation` block (when present) plus a per-code doc-URL
|
|
* derivation rule.
|
|
*/
|
|
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
errorResponse as legacyErrorResponse,
|
|
errorResponseFromCode as legacyErrorResponseFromCode,
|
|
} from '@/lib/errors/get-structured-error'
|
|
import type { Logger } from '@/lib/logger'
|
|
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
|
|
|
|
const DOCS_BASE = process.env.NEXT_PUBLIC_APP_URL
|
|
? `${process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '')}/docs/api/errors`
|
|
: '/docs/api/errors'
|
|
|
|
export interface V1ErrorBody {
|
|
error: {
|
|
code: string
|
|
message: string
|
|
message_en?: string
|
|
details?: unknown
|
|
recovery_hint?: string
|
|
docs_url?: string
|
|
valid_alternatives?: Record<string, unknown>
|
|
request_id?: string
|
|
}
|
|
}
|
|
|
|
export interface V1ErrorContext {
|
|
requestId: string
|
|
/** Extra structured context for the agent (period_id, customer_id, ...). */
|
|
details?: unknown
|
|
/** Override the http status from the registry entry. */
|
|
status?: number
|
|
/** Agent-actionable next-step suggestions: { unlock_endpoint, next_open_period }. */
|
|
validAlternatives?: Record<string, unknown>
|
|
/**
|
|
* Seconds to advertise in `Retry-After`. Set on retryable throttles so an
|
|
* unattended client can pace itself instead of backing off blindly.
|
|
*/
|
|
retryAfterSeconds?: number
|
|
}
|
|
|
|
function docsUrlFor(code: string): string {
|
|
return `${DOCS_BASE}/${code}`
|
|
}
|
|
|
|
/**
|
|
* Transform a legacy error envelope from `errorResponse()` into the v1 shape.
|
|
*
|
|
* The legacy shape is:
|
|
* { error: { code, message, message_en?, remediation?, requestId?, details? } }
|
|
*
|
|
* v1 needs:
|
|
* { error: { code, message, message_en?, details?, recovery_hint?, docs_url, valid_alternatives?, request_id? } }
|
|
*
|
|
* The remediation.description becomes recovery_hint; docs_url is derived from
|
|
* the code; valid_alternatives is passed through unchanged.
|
|
*/
|
|
async function rewriteEnvelope(
|
|
legacyResponse: NextResponse,
|
|
ctx: V1ErrorContext,
|
|
): Promise<NextResponse> {
|
|
const status = ctx.status ?? legacyResponse.status
|
|
const body = (await legacyResponse.json().catch(() => null)) as
|
|
| { error: { code: string; message: string; message_en?: string; remediation?: { description?: string }; details?: unknown } }
|
|
| null
|
|
|
|
if (!body?.error) {
|
|
// Should never happen: legacyErrorResponse always returns the envelope.
|
|
const fallback: V1ErrorBody = {
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
|
|
message_en: 'Internal server error.',
|
|
docs_url: docsUrlFor('INTERNAL_ERROR'),
|
|
request_id: ctx.requestId,
|
|
},
|
|
}
|
|
return finalize(NextResponse.json(fallback, { status }), ctx)
|
|
}
|
|
|
|
const { code, message, message_en, remediation, details } = body.error
|
|
|
|
const v1Body: V1ErrorBody = {
|
|
error: {
|
|
code,
|
|
message,
|
|
...(message_en ? { message_en } : {}),
|
|
...(details !== undefined ? { details } : {}),
|
|
...(remediation?.description ? { recovery_hint: remediation.description } : {}),
|
|
docs_url: docsUrlFor(code),
|
|
...(ctx.validAlternatives ? { valid_alternatives: ctx.validAlternatives } : {}),
|
|
request_id: ctx.requestId,
|
|
},
|
|
}
|
|
|
|
return finalize(NextResponse.json(v1Body, { status }), ctx)
|
|
}
|
|
|
|
function finalize(res: NextResponse, ctx: V1ErrorContext): NextResponse {
|
|
res.headers.set('X-Request-Id', ctx.requestId)
|
|
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
|
|
// The published skill tells agents to honor Retry-After on a 429. Until
|
|
// this landed, /api/v1 never sent one, so that instruction pointed at a
|
|
// header that did not exist.
|
|
if (ctx.retryAfterSeconds !== undefined) {
|
|
res.headers.set('Retry-After', String(ctx.retryAfterSeconds))
|
|
}
|
|
return res
|
|
}
|
|
|
|
/**
|
|
* v1 error response from a thrown value. Dispatches through the legacy
|
|
* machinery for code resolution, then rewrites into the v1 shape.
|
|
*
|
|
* Always logs the underlying error; never throws.
|
|
*/
|
|
export async function v1ErrorResponse(
|
|
err: unknown,
|
|
log: Logger,
|
|
ctx: V1ErrorContext,
|
|
): Promise<NextResponse> {
|
|
const legacy = legacyErrorResponse(err, log, {
|
|
requestId: ctx.requestId,
|
|
details: ctx.details,
|
|
status: ctx.status,
|
|
})
|
|
return rewriteEnvelope(legacy, ctx)
|
|
}
|
|
|
|
/**
|
|
* v1 error response from a known code (no thrown value involved).
|
|
*
|
|
* Use this when the route already knows the failure mode:
|
|
*
|
|
* return v1ErrorResponseFromCode('PERIOD_LOCKED', log, {
|
|
* requestId: ctx.requestId,
|
|
* details: { period_id, locked_at },
|
|
* validAlternatives: { unlock_endpoint: '/v1/.../fiscal-periods/:id:unlock' },
|
|
* })
|
|
*/
|
|
export async function v1ErrorResponseFromCode(
|
|
code: string,
|
|
log: Logger,
|
|
ctx: V1ErrorContext & { reason?: string },
|
|
): Promise<NextResponse> {
|
|
const legacy = legacyErrorResponseFromCode(code, log, {
|
|
requestId: ctx.requestId,
|
|
details: ctx.details,
|
|
status: ctx.status,
|
|
reason: ctx.reason,
|
|
})
|
|
return rewriteEnvelope(legacy, ctx)
|
|
}
|
|
|
|
/** Minimal slice of the v1 route context the validation helpers need. */
|
|
export interface V1ValidationContext {
|
|
requestId: string
|
|
log: Logger
|
|
}
|
|
|
|
/** Structural view of a ZodError: enough to render the v1 issues list. */
|
|
export interface V1ZodErrorLike {
|
|
issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; message: string }>
|
|
}
|
|
|
|
/**
|
|
* Map Zod issues into the `{ field, message }` list the v1 envelope carries
|
|
* under `details.issues`.
|
|
*/
|
|
export function v1ZodIssues(err: V1ZodErrorLike): Array<{ field: string; message: string }> {
|
|
return err.issues.map((i) => ({
|
|
field: i.path.join('.'),
|
|
message: i.message,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* VALIDATION_ERROR envelope for a failed Zod parse:
|
|
*
|
|
* const parsed = Schema.safeParse(rawBody)
|
|
* if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
*/
|
|
export async function v1ValidationError(
|
|
ctx: V1ValidationContext,
|
|
err: V1ZodErrorLike,
|
|
): Promise<NextResponse> {
|
|
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: {
|
|
issues: v1ZodIssues(err),
|
|
},
|
|
})
|
|
}
|