Files
accounted/app/api/reports/behandlingshistorik/route.ts
T
4be51aae67 feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) (#1787)
* feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16)

Adds the per-räkenskapsår processing history as a first-class report in
Rapporter (Export & arkiv), with CSV/XLSX export. Until now the
behandlingshistorik only existed as raw audit_log JSON inside the
Säkerhetsbackup ZIP; revisorer ask for a readable per-year document.

- lib/reports/behandlingshistorik.ts: read model over journal_entries
  (committed_at = registreringsdatum, the complete source of bokföringsposter),
  the trigger-written audit_log (storno, deletions, diffs, kontoplan, settings,
  period lock/unlock/close, API keys, dimensions, accruals), the rättelse log,
  company_migration_resets, sie_imports and bank_file_imports. Field-level
  diffs with Swedish labels; company_settings restricted to processing-relevant
  keys (p. 9.16 second paragraph); kontoplan seeding and bulk underlag
  deletions collapse into one summary row; actor labels for users, API keys,
  MCP, agent, cron and system; fiscal-year mode unions audit rows touching the
  year's entries regardless of timestamp (bokslut/storno land after period_end),
  date-range mode narrows by registration time.
- GET /api/reports/behandlingshistorik?period_id&from_date&to_date&category&format
  (json|csv|xlsx), withRouteContext + Zod, e-mail labels via service-role
  profiles lookup scoped to the ids in the result, app version stamped.
- Report catalog row + focused view (category filter, export menu), sv/en.
- Tests: 30 read-model tests, 10 route tests; smoke-tested read-only on prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi

* fix(reports): keep behandlingshistorik queries statically resolvable for the schema guard

tests/schema/no-phantom-columns.test.ts counts `.or()` calls with non-literal
arguments as unresolvable and holds a ceiling (379); the report added two.
The audit_log table/action filter is now a string literal in the call (pinned
to AUDITED_TABLES / GLOBAL_ACTIONS by a unit test), and the migration-reset
lookup is two plain `.eq()` queries instead of an interpolated `.or()`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:45:39 +02:00

104 lines
3.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { BehandlingshistorikQuerySchema } from '@/lib/api/schemas'
import { contentDisposition } from '@/lib/api/content-disposition'
import { privateNoStore } from '@/lib/api/private-no-store'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
buildBehandlingshistorikExport,
generateBehandlingshistorik,
resolveUserLabelsFromProfiles,
} from '@/lib/reports/behandlingshistorik'
/**
* GET /api/reports/behandlingshistorik
*
* Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) for one fiscal
* period, optionally narrowed to a date sub-range inside it.
*
* Query: period_id (required), from_date / to_date (optional, inside the
* period), category (optional), format=json|csv|xlsx (default json).
*
* Read-only: the report is a view over journal_entries, the trigger-written
* audit_log, the rättelse log and the import tables. Actor e-mails are
* resolved through a service-role lookup on `profiles` (self-only RLS),
* restricted to the user ids that appear in the result.
*/
/** Running build identifier, stamped on the report (p. 9.16: program version). */
function currentAppVersion(): string | null {
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_BUILD_ID || ''
return sha ? sha.slice(0, 12) : null
}
export const GET = withRouteContext('report.behandlingshistorik', async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const query = validateQuery(request, BehandlingshistorikQuerySchema, {
log,
operation: 'report.behandlingshistorik',
})
if (!query.success) return query.response
const { period_id: periodId, from_date, to_date, format, category } = query.data
// Validate the optional sub-range against the period bounds (same contract
// as the other fiscal-range reports). Unknown period: 404.
if (from_date || to_date) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.maybeSingle()
if (!period) {
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
}
const { searchParams } = new URL(request.url)
const parsed = parseReportDateRange(searchParams, period as { period_start: string; period_end: string })
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
}
try {
const serviceClient = createServiceClient()
const report = await generateBehandlingshistorik(
supabase,
companyId,
{
periodId,
fromDate: from_date,
toDate: to_date,
categories: category ? [category] : undefined,
},
{
resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids),
appVersion: currentAppVersion(),
},
)
if (!report) {
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
}
if (format === 'json') {
return privateNoStore(NextResponse.json({ data: report }))
}
const file = buildBehandlingshistorikExport(report, format)
return new NextResponse(new Uint8Array(file.buffer), {
headers: {
'Content-Type': file.contentType,
'Content-Disposition': contentDisposition('attachment', file.filename),
'Cache-Control': 'private, no-store',
},
})
} catch (err) {
// Raw message stays server-side: it can carry table names / SQL.
log.error('behandlingshistorik generation failed', err as Error, { periodId })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId })
}
})