* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement The final rung of the dimensions ladder (dev_docs/dimensions_implementation_plan.md §7 row 10): - custom dimensions: POST /api/dimensions creates registry dims (next free SIE number >= 20 when omitted; explicit numbers allowed — SIE import already mints reserved ones); register gets a 'Ny dimension' dialog with a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 — this exposes it) - account_dimension_rules (migration 20260703120000): one rule per (account, dimension) — required / default / fixed, per-rule is_active, company-scoped RLS, composite FK to the registry, value-presence CHECK - enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical; deliberately NO settings toggle — a rule that exists but is ignored is worse than either extreme): default/fixed apply onto line bags at draft creation (fixed overwrites, default fills); required asserts at commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every account + dimension; the bulk-book route runs the same policy before its RPC; storno/correction paths never pass through commitEntry so history always reverses regardless of policy; rule fetches fail open incl. thrown exceptions - chart of accounts: per-account Dimensionsregler section in EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch), gated on the existing dimensions toggle, quiet when empty - pickers: LineDimensionFields is registry-driven (one combobox per active dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount lights up custom dims with zero changes - agent briefing: per-dimension required_on_accounts/default_on_accounts so agents self-correct instead of bouncing off the policy error - rules CRUD API with existence/active/company validation and qualified DTO ids; firm_id FK deferred until the firms table lands (per plan) 39 new tests (pure-fn rules, engine enforcement, both new API surfaces, pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration replayed on a fresh container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: renumber migration to 20260703200000 — version collision with prod The concurrent session shipped pending_operations_add_link_document_to_voucher as 20260703120000 today; the Supabase preview branch (cloned from prod) rejected the duplicate version key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: review round — auto-pick retry on collision, fail-open warnings, query schema - POST /api/dimensions retries once past a concurrent number claim when the number was auto-picked (explicit choices still 409) - every fail-open skip of the dimension-rules policy now logs a structured warning (engine draft/commit paths + bulk-book) — deliberate fail-open, but observable - GET /api/dimensions/rules validates its query through ListDimensionRulesQuerySchema instead of an inline regex Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
/**
|
|
* /api/dimensions/rules/[id] — mutate one account dimension rule (PR10).
|
|
*
|
|
* PATCH { rule_type?, value_id?, is_active? } — value presence is
|
|
* re-validated against the EFFECTIVE rule_type (required ⇔ no value).
|
|
* DELETE — removes the rule; enforcement stops immediately. Pausing without
|
|
* losing the configuration is is_active: false.
|
|
*/
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { UpdateAccountDimensionRuleSchema } from '@/lib/api/schemas'
|
|
import { errorResponse } from '@/lib/errors/get-structured-error'
|
|
import { RULE_SELECT, toRuleDto, type RawRule } from '../dto'
|
|
|
|
ensureInitialized()
|
|
|
|
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'dimension.rules.update',
|
|
async (request, ctx, { params }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const validation = await validateBody(request, UpdateAccountDimensionRuleSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
const { data: existing, error: existingError } = await supabase
|
|
.from('account_dimension_rules')
|
|
.select('id, rule_type, value_id, dimension_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (existingError) return errorResponse(existingError, log, { requestId })
|
|
if (!existing) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_RULE_NOT_FOUND', message: 'Regeln finns inte.' } },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
const effectiveType = body.rule_type ?? (existing.rule_type as string)
|
|
const effectiveValueId =
|
|
body.value_id !== undefined ? body.value_id : (existing.value_id as string | null)
|
|
|
|
if (effectiveType === 'required' && effectiveValueId) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'VALIDATION_FAILED', message: 'En obligatorisk regel har inget värde — ta bort värdet eller byt regeltyp.' } },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
if (effectiveType !== 'required' && !effectiveValueId) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'VALIDATION_FAILED', message: 'Välj vilket värde regeln ska använda.' } },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
if (body.value_id) {
|
|
const { data: value, error: valueError } = await supabase
|
|
.from('dimension_values')
|
|
.select('id, is_active')
|
|
.eq('id', body.value_id)
|
|
.eq('company_id', companyId)
|
|
.eq('dimension_id', existing.dimension_id)
|
|
.maybeSingle()
|
|
if (valueError) return errorResponse(valueError, log, { requestId })
|
|
if (!value) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_VALUE_NOT_FOUND', message: 'Värdet finns inte under regelns dimension.' } },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
if (!value.is_active) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_VALUE_ARCHIVED', message: 'Värdet är arkiverat — återaktivera det innan det används i en regel.' } },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
}
|
|
|
|
const updates: Record<string, unknown> = {}
|
|
if (body.rule_type !== undefined) updates.rule_type = body.rule_type
|
|
if (body.value_id !== undefined) updates.value_id = body.value_id
|
|
if (body.is_active !== undefined) updates.is_active = body.is_active
|
|
if (Object.keys(updates).length === 0) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'VALIDATION_FAILED', message: 'Ingen ändring angiven.' } },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const { data: rule, error: updateError } = await supabase
|
|
.from('account_dimension_rules')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.select(RULE_SELECT)
|
|
.single()
|
|
|
|
if (updateError) {
|
|
log.error('dimension rule update failed', updateError)
|
|
return errorResponse(updateError, log, { requestId })
|
|
}
|
|
|
|
return NextResponse.json({ data: { rule: toRuleDto(rule as unknown as RawRule) } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'dimension.rules.delete',
|
|
async (_request, ctx, { params }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const { error, count } = await supabase
|
|
.from('account_dimension_rules')
|
|
.delete({ count: 'exact' })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (error) {
|
|
log.error('dimension rule delete failed', error)
|
|
return errorResponse(error, log, { requestId })
|
|
}
|
|
if (!count) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_RULE_NOT_FOUND', message: 'Regeln finns inte.' } },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data: { deleted: true } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|