Files
accounted/app/api/dimensions/import-existing/route.ts
T
Jakob WennbergandClaude Sonnet 5 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

179 lines
6.9 KiB
TypeScript

/**
* POST /api/dimensions/import-existing: one-shot scan that backfills the
* dimension_values registry from codes already present on journal lines.
*
* The settings toggle-on flow runs this ("Importera befintliga koder",
* dev_docs/dimensions_implementation_plan.md §4): every {sie_dim_no: code}
* entry found in journal_entry_lines.dimensions that lacks a registry row gets
* one created with is_active = false and name = code: referential validity
* holds retroactively without polluting pickers. Dimensions missing from the
* registry entirely (e.g. a custom dim number written via the v1 API) are
* created too, so no line code is skipped. Idempotent: re-running creates
* nothing new.
*
* Response: 200 { created: number } (count of dimension_values created).
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
interface LineRow {
id: string
dimensions: Record<string, string> | null
}
export const POST = withRouteContext(
'dimension.import_existing',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (ensureError) {
log.error('ensure_company_dimensions failed', ensureError)
return errorResponse(ensureError, log, { requestId })
}
try {
// 1. Collect every {dim_no: code} pair used on this company's lines.
// Inner-join scoping: journal_entry_lines has no company_id column.
const lines = await fetchAllRows<LineRow>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('id, dimensions, journal_entries!inner(company_id)')
.eq('journal_entries.company_id', companyId)
.neq('dimensions', '{}')
.order('id', { ascending: true })
.range(from, to),
)
const codesByDimNo = new Map<number, Set<string>>()
for (const line of lines) {
if (!line.dimensions) continue
for (const [key, code] of Object.entries(line.dimensions)) {
const dimNo = Number(key)
if (!Number.isInteger(dimNo) || dimNo < 1) continue
if (typeof code !== 'string' || code.length === 0) continue
// Sanitize exactly like the PR1 substrate backfill
// (left(regexp_replace(code, '["{}]', '', 'g'), 40)) so candidates
// satisfy the dimension_values code CHECK; codes that sanitize to
// empty are dropped, and the Set de-duplicates post-sanitization.
const sanitized = code.replace(/["{}]/g, '').slice(0, 40)
if (sanitized.length === 0) continue
const set = codesByDimNo.get(dimNo) ?? new Set<string>()
set.add(sanitized)
codesByDimNo.set(dimNo, set)
}
}
if (codesByDimNo.size === 0) {
return NextResponse.json({ created: 0 })
}
// 2. Registry state: dim_no → dimension_id, plus existing value codes.
const { data: dims, error: dimsError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
if (dimsError) throw dimsError
const dimIdByNo = new Map<number, string>(
((dims ?? []) as { id: string; sie_dim_no: number }[]).map((d) => [d.sie_dim_no, d.id]),
)
// 3. Create registry rows for dim numbers seen on lines but missing from
// the registry (written via raw v1 JSON / MCP before the register
// existed). is_system=false; the user can rename them afterwards.
const missingDimNos = [...codesByDimNo.keys()].filter((n) => !dimIdByNo.has(n))
if (missingDimNos.length > 0) {
// ignoreDuplicates: a concurrent creator (double-click, MCP) must not
// abort the whole batch with a 23505: the survivor's row wins.
const { data: createdDims, error: createDimsError } = await supabase
.from('dimensions')
.upsert(
missingDimNos.map((n) => ({
company_id: companyId,
sie_dim_no: n,
name: `Dimension ${n}`,
is_system: false,
})),
{ onConflict: 'company_id,sie_dim_no', ignoreDuplicates: true },
)
.select('id, sie_dim_no')
if (createDimsError) throw createDimsError
for (const d of (createdDims ?? []) as { id: string; sie_dim_no: number }[]) {
dimIdByNo.set(d.sie_dim_no, d.id)
}
}
const { data: existingValues, error: valuesError } = await supabase
.from('dimension_values')
.select('dimension_id, code')
.eq('company_id', companyId)
if (valuesError) throw valuesError
const existing = new Set(
((existingValues ?? []) as { dimension_id: string; code: string }[]).map(
(v) => `${v.dimension_id}${v.code}`,
),
)
// 4. Insert the missing values. name = code (the line map carries no
// display name), is_active = false so pickers stay clean until the
// user promotes the codes they still use.
const inserts: Array<{
company_id: string
dimension_id: string
code: string
name: string
is_active: boolean
}> = []
for (const [dimNo, codes] of codesByDimNo) {
const dimensionId = dimIdByNo.get(dimNo)
if (!dimensionId) continue
for (const code of codes) {
if (existing.has(`${dimensionId}${code}`)) continue
inserts.push({
company_id: companyId,
dimension_id: dimensionId,
code,
name: code,
is_active: false,
})
}
}
// Upsert with ignoreDuplicates so one duplicate row (raced insert, or a
// code the existing-values snapshot missed) skips instead of aborting
// the whole batch with a 23505. `created` counts the rows actually
// inserted: the returned set excludes ignored duplicates.
let created = 0
if (inserts.length > 0) {
const { data: insertedValues, error: insertError } = await supabase
.from('dimension_values')
.upsert(inserts, {
onConflict: 'company_id,dimension_id,code',
ignoreDuplicates: true,
})
.select('id')
if (insertError) throw insertError
created = (insertedValues ?? []).length
}
return NextResponse.json({ created })
} catch (err) {
log.error('dimension import-existing failed', err as Error)
return errorResponseFromCode('DIMENSION_IMPORT_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : String(err) },
})
}
},
{ requireWrite: true },
)