Files
accounted/app/api/v1/operations/[id]/route.ts
T
Jakob WennbergandClaude Opus 4.8 fce6faff2c fix(api): stabilize report pagination + declare real { data, meta } envelope on v1 single/write endpoints (#811)
* fix(reports): stabilize fetchAllRows paging to stop doubled/dropped balances (#790, #791)

PostgREST `.range()` paging is only correct when the underlying query has a
stable TOTAL order. Several aggregating report queries (general ledger, trial
balance, grundbok, supplier/AR ledgers, etc.) paginated without `.order()`, so
on datasets larger than one 1000-row page Postgres could return rows in a
different order between requests — silently DUPLICATING or SKIPPING rows on a
page boundary and doubling or dropping financial totals.

- fetch-all.ts: document the ordering invariant and add an optional
  `dedupeBy` defense-in-depth that drops cross-page duplicates and warns when
  it fires (surfaces a missing `.order()` in logs instead of corrupting money).
- Add a stable `.order()` (line PK or account_number) to every paginated query
  in lib/reports/ and the account-balances route; pass `dedupeBy` on the
  money-aggregating line queries.
- Add fetch-all unit tests and update report test fixtures to carry row ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): declare the real { data, meta } envelope on v1 single/write/204 endpoints (#794)

The OpenAPI generator derives each endpoint's documented body purely from its
registered `response.success` Zod schema, and that schema is never validated at
runtime — so a route could advertise a shape its handler never sends. #802
fixed this for list endpoints; the same drift was latent on single-resource and
write endpoints, which declared the bare resource schema instead of the
`{ data, meta }` envelope the handlers actually return.

- registry.ts: extend `ResponseMetaSchema` with the optional `audit` block and
  `partial_expansions` list that writes/expansions emit; add the `NoBodyResponse`
  sentinel so 204 DELETE handlers document a bare 204 instead of a phantom 200.
- Wrap every single/write endpoint's `response.success` in `dataEnvelope(...)`
  (or `NoBodyResponse` for 204s) across the v1 routes.
- Add a response-envelope contract test that fails CI if any JSON endpoint
  forgets to wrap its schema, with binary downloads and 204s as the only
  exemptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): extend paging dedupeBy to rc-basis-gaps and opening-balances

Address PR review: these two money-aggregating line queries already had the
stable `.order('id')` (so paging was correct) but didn't carry `id` in the
select, so they couldn't use the `dedupeBy` defense-in-depth that general-ledger
and trial-balance got. Select `id` and pass `dedupeBy: r => r.id` so the whole
report layer applies the ordering invariant consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:50 +02:00

166 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GET /api/v1/operations/{id}
*
* Polling endpoint for async operations. Returns the current snapshot of
* the operation row including status, progress (if the work is in-flight),
* result (on success), and error (on failure).
*
* The operation_id is global (cross-company in the URL) but every read is
* scoped to the caller's company in `getOperation()` — so two companies'
* UUIDs can never collide into the wrong tenant. The wrapper has already
* validated company membership.
*
* Webhook alternative (Phase 6): subscribe to `operation.completed` instead
* of polling.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { getOperation } from '@/lib/api/v1/operations'
const OperationStatus = z.enum(['queued', 'running', 'succeeded', 'failed', 'cancelled'])
const OperationDetail = z.object({
operation_id: z.string().uuid(),
type: z.string(),
status: OperationStatus,
progress: z.record(z.string(), z.unknown()).optional(),
result: z.unknown().nullable(),
error: z
.object({ code: z.string().optional(), message: z.string().optional(), details: z.unknown().optional() })
.nullable(),
started_at: z.string().nullable(),
completed_at: z.string().nullable(),
poll_url: z.string(),
webhook_event: z.literal('operation.completed'),
})
registerEndpoint({
operation: 'operations.get',
method: 'GET',
path: '/api/v1/operations/:id',
summary: 'Poll a long-running operation by id.',
description:
'Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import).',
useWhen:
'You started an async operation and need to know whether it has finished. Poll every 5–30 seconds; switch to the `operation.completed` webhook for production integrations.',
doNotUseFor:
'Fetching the resource the operation produced — once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1).',
pitfalls: [
'Terminal statuses (`succeeded`, `failed`, `cancelled`) are final; the row never transitions out of them.',
'progress is free-form jsonb; agents should treat it as opaque except for the documented fields `phase` (string), `current` / `total` (numbers for percent calculation).',
'started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached.',
],
example: {
response: {
data: {
operation_id: '0e9c-…',
type: 'fiscal_periods.year_end',
status: 'succeeded',
progress: { phase: 'committed', current: 142, total: 142 },
result: { journal_entries_created: 4, opening_balances_set: 138 },
error: null,
started_at: '2026-05-12T10:01:23Z',
completed_at: '2026-05-12T10:01:48Z',
poll_url: '/api/v1/operations/0e9c-…',
webhook_event: 'operation.completed',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'operations:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(OperationDetail) },
})
export const GET = withApiV1<{ params: Promise<{ id: string }> }>(
'operations.get',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Operation id must be a UUID.' },
})
}
const operationId = idParse.data
// The operations URL has no /companies/:companyId prefix, so the wrapper
// can't resolve ctx.companyId from a path segment. We fetch by id alone
// (service-role bypasses RLS), then verify the operation's company is one
// this caller belongs to. Two-step lookup keeps the resource id global
// while still hard-scoping reads to the caller's tenancies.
const { data: opRow, error: opErr } = await ctx.supabase
.from('operations')
.select('company_id')
.eq('id', operationId)
.maybeSingle()
if (opErr) {
ctx.log.error('operations.get fetch failed', opErr as Error, { operationId })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId })
}
if (!opRow) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'operation' },
})
}
const opCompanyId = (opRow as { company_id: string }).company_id
const { data: membership } = await ctx.supabase
.from('company_members')
.select('company_id')
.eq('user_id', ctx.userId)
.eq('company_id', opCompanyId)
.maybeSingle()
if (!membership) {
// Enumeration hardening — wrong id and cross-tenant id are
// indistinguishable from outside.
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'operation' },
})
}
const row = await getOperation(ctx.supabase, {
id: operationId,
companyId: opCompanyId,
})
if (!row) {
// Race between the membership read and the operation read — extremely
// unlikely but defended.
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'operation' },
})
}
return ok(
{
operation_id: row.id,
type: row.operation_type,
status: row.status,
progress: row.progress,
result: row.result,
error: row.error,
started_at: row.started_at,
completed_at: row.completed_at,
poll_url: `/api/v1/operations/${row.id}`,
webhook_event: 'operation.completed',
},
{ requestId: ctx.requestId },
)
},
)