* 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>
166 lines
6.2 KiB
TypeScript
166 lines
6.2 KiB
TypeScript
/**
|
||
* 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 },
|
||
)
|
||
},
|
||
)
|