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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
d3409183c0
commit
4be51aae67
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
// withRouteContext handlers take (request, routeContext); this route has no dynamic params.
|
||||
const routeCtx = createMockRouteParams({})
|
||||
const call = (url: string) => GET(createMockRequest(url), routeCtx)
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const serviceFrom = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => ({ from: serviceFrom }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/behandlingshistorik', () => ({
|
||||
generateBehandlingshistorik: vi.fn(),
|
||||
buildBehandlingshistorikExport: vi.fn(),
|
||||
resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateBehandlingshistorik,
|
||||
buildBehandlingshistorikExport,
|
||||
resolveUserLabelsFromProfiles,
|
||||
} from '@/lib/reports/behandlingshistorik'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockGenerate = vi.mocked(generateBehandlingshistorik)
|
||||
const mockExport = vi.mocked(buildBehandlingshistorikExport)
|
||||
const mockResolve = vi.mocked(resolveUserLabelsFromProfiles)
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function unauthed() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
}
|
||||
|
||||
const sampleReport = {
|
||||
company: { name: 'Testbolaget AB', org_number: '556000-0001' },
|
||||
period: { id: 'period-1', name: 'RÅ 2026', start: '2026-01-01', end: '2026-12-31' },
|
||||
range: { from: '2026-01-01', to: '2026-12-31' },
|
||||
mode: 'fiscal_year' as const,
|
||||
generated_at: '2026-08-21T12:00:00.000Z',
|
||||
app_version: 'abc1234',
|
||||
total_events: 1,
|
||||
by_category: { verifikation: 1, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 },
|
||||
events: [
|
||||
{
|
||||
id: 'entry:1',
|
||||
occurred_at: '2026-03-10T09:30:00.000Z',
|
||||
category: 'verifikation' as const,
|
||||
code: 'journal_entry.committed',
|
||||
event: 'Verifikation bokförd',
|
||||
object: 'A12',
|
||||
actor: { type: 'user' as const, user_id: 'user-1', label: 'anna@example.se' },
|
||||
details: ['Datum: 2026-03-09'],
|
||||
source: 'journal_entries' as const,
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
authed()
|
||||
})
|
||||
|
||||
describe('GET /api/reports/behandlingshistorik', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
unauthed()
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1')
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when period_id is missing', async () => {
|
||||
const res = await call('/api/reports/behandlingshistorik')
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 on an unknown format', async () => {
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the period does not belong to the company', async () => {
|
||||
mockGenerate.mockResolvedValue(null)
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=nope')
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when the date sub-range falls outside the period', async () => {
|
||||
enqueue({ data: { period_start: '2026-01-01', period_end: '2026-12-31' } })
|
||||
const res = await call(
|
||||
'/api/reports/behandlingshistorik?period_id=period-1&from_date=2025-06-01&to_date=2026-02-01',
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 for a sub-range on an unknown period', async () => {
|
||||
enqueue({ data: null })
|
||||
const res = await call(
|
||||
'/api/reports/behandlingshistorik?period_id=nope&from_date=2026-02-01',
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns the report as JSON and resolves actor labels through the service client', async () => {
|
||||
mockGenerate.mockResolvedValue(sampleReport)
|
||||
const res = await call(
|
||||
'/api/reports/behandlingshistorik?period_id=period-1&category=verifikation',
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
const body = await res.json()
|
||||
expect(body.data.total_events).toBe(1)
|
||||
expect(body.data.events[0].object).toBe('A12')
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledTimes(1)
|
||||
const [, companyId, params, options] = mockGenerate.mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(params).toEqual({ periodId: 'period-1', fromDate: undefined, toDate: undefined, categories: ['verifikation'] })
|
||||
// The injected resolver goes through resolveUserLabelsFromProfiles with the service client.
|
||||
await options!.resolveUserLabels!(['user-1'])
|
||||
expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ from: serviceFrom }), ['user-1'])
|
||||
})
|
||||
|
||||
it('passes a validated sub-range through to the generator', async () => {
|
||||
enqueue({ data: { period_start: '2026-01-01', period_end: '2026-12-31' } })
|
||||
mockGenerate.mockResolvedValue({ ...sampleReport, mode: 'date_range', range: { from: '2026-03-01', to: '2026-03-31' } })
|
||||
const res = await call(
|
||||
'/api/reports/behandlingshistorik?period_id=period-1&from_date=2026-03-01&to_date=2026-03-31',
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const [, , params] = mockGenerate.mock.calls[0]
|
||||
expect(params).toMatchObject({ periodId: 'period-1', fromDate: '2026-03-01', toDate: '2026-03-31' })
|
||||
})
|
||||
|
||||
it('streams CSV with an attachment filename', async () => {
|
||||
mockGenerate.mockResolvedValue(sampleReport)
|
||||
mockExport.mockReturnValue({
|
||||
buffer: Buffer.from('Tidpunkt,Kategori\n', 'utf-8'),
|
||||
contentType: 'text/csv; charset=utf-8',
|
||||
filename: 'behandlingshistorik-testbolaget-ab-20261231.csv',
|
||||
})
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=csv')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('attachment')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('behandlingshistorik-testbolaget-ab-20261231.csv')
|
||||
expect(mockExport).toHaveBeenCalledWith(sampleReport, 'csv')
|
||||
const text = await res.text()
|
||||
expect(text).toContain('Tidpunkt,Kategori')
|
||||
})
|
||||
|
||||
it('maps generator failures to the report error envelope', async () => {
|
||||
mockGenerate.mockRejectedValue(new Error('relation audit_log does not exist'))
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1')
|
||||
expect(res.status).toBe(500)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
|
||||
expect(JSON.stringify(body)).not.toContain('audit_log')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user