Files
accounted/lib/api/v1/errors.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

176 lines
5.7 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 { getErrorEntry } from '@/lib/errors/structured-errors'
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>
}
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)
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)
}
/**
* Quick check: does this code map to a registered entry? Used by callers that
* want to validate a code before throwing it (e.g. registry-driven dispatch).
*/
export function isRegisteredV1Code(code: string): boolean {
return getErrorEntry(code) !== undefined
}