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>
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
/**
|
|
* POST /api/v1/companies/{companyId}/dimensions/{id}/values
|
|
*
|
|
* Create a dimension value (SIE #OBJEKT). Idempotent (mandatory
|
|
* Idempotency-Key), dry-runnable. Codes follow the strict Fortnox format
|
|
* (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values; the DB CHECK is
|
|
* looser by design so legacy free-text codes survive imports. `code` is
|
|
* immutable after creation.
|
|
*/
|
|
import { z } from 'zod'
|
|
import { created } from '@/lib/api/v1/response'
|
|
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
|
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
|
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
|
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
|
|
import { readV1JsonBody } from '@/lib/api/v1/body'
|
|
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
|
|
|
|
const DimensionValueCreated = z.object({
|
|
id: z.string().uuid().nullable(),
|
|
dimension_id: z.string().uuid(),
|
|
code: z.string(),
|
|
name: z.string(),
|
|
is_active: z.boolean(),
|
|
start_date: z.string().nullable(),
|
|
end_date: z.string().nullable(),
|
|
created_at: z.string().nullable(),
|
|
})
|
|
|
|
registerEndpoint({
|
|
operation: 'dimensions.values.create',
|
|
method: 'POST',
|
|
path: '/api/v1/companies/:companyId/dimensions/:id/values',
|
|
summary: 'Create a dimension value (kostnadsställe/projekt code).',
|
|
description:
|
|
'Registers a new value (SIE #OBJEKT) under a dimension: e.g. a new project code under dimension 6. Requires Idempotency-Key (UUID). Supports ?dry_run=true to validate the code format without committing. The `:id` path segment is the dimension row id (from GET …/dimensions), not the sie_dim_no. Duplicate codes within the dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE.',
|
|
useWhen:
|
|
'A voucher or invoice references a cost centre / project code that does not exist yet and the user has confirmed it should be created.',
|
|
doNotUseFor:
|
|
'Renaming or archiving an existing value (dashboard register in v1). Tagging lines: pass the dimensions map on the journal-entry line instead.',
|
|
pitfalls: [
|
|
'Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.',
|
|
'The :id segment is the dimension UUID, not the SIE dimension number.',
|
|
'Codes are limited to the strict Fortnox charset (A-Ö, digits, _, +, -; max 20 chars) even though historical imported codes may be looser.',
|
|
'code is immutable after creation: there is no rename in v1; create the correct code and archive the wrong one.',
|
|
],
|
|
example: {
|
|
request: { code: 'P001', name: 'Villa Almgren tak' },
|
|
response: {
|
|
data: {
|
|
id: '0e9c…',
|
|
dimension_id: 'a8f1…',
|
|
code: 'P001',
|
|
name: 'Villa Almgren tak',
|
|
is_active: true,
|
|
start_date: null,
|
|
end_date: null,
|
|
created_at: '2026-07-02T12:00:00Z',
|
|
},
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
|
},
|
|
},
|
|
scope: 'bookkeeping:write',
|
|
risk: 'low',
|
|
idempotent: true,
|
|
reversible: true,
|
|
dryRunSupported: true,
|
|
request: { body: CreateDimensionValueSchema },
|
|
response: { success: dataEnvelope(DimensionValueCreated) },
|
|
})
|
|
|
|
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
|
'dimensions.values.create',
|
|
async (request, ctx, { params }) => {
|
|
const { id } = await params
|
|
|
|
if (!z.string().uuid().safeParse(id).success) {
|
|
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { field: 'id', message: 'Dimension id must be a UUID.' },
|
|
})
|
|
}
|
|
|
|
const rawBodyResult = await readV1JsonBody(request, ctx)
|
|
if (!rawBodyResult.ok) return rawBodyResult.response
|
|
const rawBody = rawBodyResult.body
|
|
|
|
const parsed = CreateDimensionValueSchema.safeParse(rawBody)
|
|
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
const body = parsed.data
|
|
|
|
// The dimension must exist and belong to the company in the URL.
|
|
const { data: dimension, error: dimError } = await ctx.supabase
|
|
.from('dimensions')
|
|
.select('id')
|
|
.eq('id', id)
|
|
.eq('company_id', ctx.companyId!)
|
|
.maybeSingle()
|
|
if (dimError) {
|
|
return v1ErrorResponse(dimError, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
if (!dimension) {
|
|
return v1ErrorResponseFromCode('DIMENSION_NOT_FOUND', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { dimension_id: id },
|
|
})
|
|
}
|
|
|
|
if (ctx.dryRun) {
|
|
return dryRunPreview(
|
|
{
|
|
id: null,
|
|
dimension_id: id,
|
|
code: body.code,
|
|
name: body.name,
|
|
is_active: true,
|
|
start_date: body.start_date ?? null,
|
|
end_date: body.end_date ?? null,
|
|
created_at: null,
|
|
},
|
|
{ requestId: ctx.requestId, log: ctx.log },
|
|
)
|
|
}
|
|
|
|
const { data, error } = await ctx.supabase
|
|
.from('dimension_values')
|
|
.insert({
|
|
company_id: ctx.companyId!,
|
|
dimension_id: id,
|
|
code: body.code,
|
|
name: body.name,
|
|
start_date: body.start_date ?? null,
|
|
end_date: body.end_date ?? null,
|
|
})
|
|
.select('id, dimension_id, code, name, is_active, start_date, end_date, created_at')
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === '23505') {
|
|
return v1ErrorResponseFromCode('DIMENSION_VALUE_DUPLICATE_CODE', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { code: body.code },
|
|
})
|
|
}
|
|
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
return created(data, { requestId: ctx.requestId })
|
|
},
|
|
{ requireIdempotencyKey: true },
|
|
)
|