Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma strängvärde byggs med konkatenering i stället för att matcha mönstret.
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
/**
|
|
* /api/dimensions/rules — per-account dimension policy (dimensions PR10).
|
|
*
|
|
* GET ?account_number=4010 (optional) → every rule (or the account's).
|
|
* POST → create a rule. 'required' blocks posting on the account without a
|
|
* value for the dimension (enforced at commitEntry + the bulk-book route);
|
|
* 'default' pre-fills at draft creation; 'fixed' always applies.
|
|
*
|
|
* Opt-in by construction: zero rules = the engine behaves exactly as before.
|
|
* There is deliberately NO settings toggle for enforcement — a rule that
|
|
* exists but is ignored would be worse than either extreme; pausing a single
|
|
* rule is what is_active is for.
|
|
*/
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody, validateQuery } from '@/lib/api/validate'
|
|
import { CreateAccountDimensionRuleSchema, ListDimensionRulesQuerySchema } from '@/lib/api/schemas'
|
|
import { errorResponse } from '@/lib/errors/get-structured-error'
|
|
import { RULE_SELECT, toRuleDto, type RawRule } from './dto'
|
|
|
|
ensureInitialized()
|
|
|
|
|
|
export const GET = withRouteContext(
|
|
'dimension.rules.list',
|
|
async (request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const queryValidation = validateQuery(request, ListDimensionRulesQuerySchema, {
|
|
log,
|
|
operation: 'dimension.rules.list',
|
|
})
|
|
if (!queryValidation.success) return queryValidation.response
|
|
const { account_number: accountNumber } = queryValidation.data
|
|
|
|
let query = supabase
|
|
.from('account_dimension_rules')
|
|
.select(RULE_SELECT)
|
|
.eq('company_id', companyId)
|
|
.order('account_number', { ascending: true })
|
|
|
|
if (accountNumber) {
|
|
query = query.eq('account_number', accountNumber)
|
|
}
|
|
|
|
const { data, error } = await query
|
|
if (error) {
|
|
log.error('dimension rule list failed', error)
|
|
return errorResponse(error, log, { requestId })
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: { rules: ((data ?? []) as unknown as RawRule[]).map(toRuleDto) },
|
|
})
|
|
},
|
|
)
|
|
|
|
export const POST = withRouteContext(
|
|
'dimension.rules.create',
|
|
async (request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const validation = await validateBody(request, CreateAccountDimensionRuleSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
// The dimension must belong to this company (RLS backstops; this gives a
|
|
// clean Swedish 400 instead of an FK error).
|
|
const { data: dimension, error: dimensionError } = await supabase
|
|
.from('dimensions')
|
|
.select('id, is_active')
|
|
.eq('id', body.dimension_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (dimensionError) return errorResponse(dimensionError, log, { requestId })
|
|
if (!dimension) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_NOT_FOUND', message: 'Dimensionen finns inte i registret.' } },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
// default/fixed: the value must belong to the SAME dimension + company
|
|
// and be active — a rule pointing at a foreign or archived value would
|
|
// make every booking on the account fail registry validation.
|
|
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', body.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 den valda dimensionen.' } },
|
|
{ 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 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// The account must exist and be active in the company chart — a rule on
|
|
// a nonexistent account can never fire and only confuses.
|
|
const { data: account, error: accountError } = await supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number')
|
|
.eq('company_id', companyId)
|
|
.eq('account_number', body.account_number)
|
|
.eq('is_active', true)
|
|
.maybeSingle()
|
|
if (accountError) return errorResponse(accountError, log, { requestId })
|
|
if (!account) {
|
|
return NextResponse.json(
|
|
{ error: { code: 'ACCOUNT_NOT_FOUND', message: `Konto ${body.account_number} finns inte som aktivt konto i kontoplanen.` } },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
const { data: rule, error: insertError } = await supabase
|
|
.from('account_dimension_rules')
|
|
.insert({
|
|
company_id: companyId,
|
|
account_number: body.account_number,
|
|
dimension_id: body.dimension_id,
|
|
rule_type: body.rule_type,
|
|
value_id: body.value_id ?? null,
|
|
is_active: body.is_active ?? true,
|
|
})
|
|
.select(RULE_SELECT)
|
|
.single()
|
|
|
|
if (insertError) {
|
|
if (insertError.code === '23505') {
|
|
return NextResponse.json(
|
|
{ error: { code: 'DIMENSION_RULE_EXISTS', message: `Konto ${body.account_number} har redan en regel för den dimensionen.` } },
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
log.error('dimension rule create failed', insertError)
|
|
return errorResponse(insertError, log, { requestId })
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ data: { rule: toRuleDto(rule as unknown as RawRule) } },
|
|
{ status: 201 },
|
|
)
|
|
},
|
|
{ requireWrite: true },
|
|
)
|