Audited every endpoint, param, header, request/response field, error code, and webhook event in the public API docs against the v1 implementation and fixed the drift; addressed two rounds of CodeRabbit review. - Error envelope, idempotency, dry-run, and reversal-field corrections. - Registered the missing articles/dimensions/inbox-items reference resources. - Cookbook fixes: removed nonexistent endpoints, corrected params/fields, fixed the test-key vs live-key quickstart flow and the year-end lock/close sequence. - Webhooks/changelog: retry window ~87h (incl. route metadata), shipped-vs- coming-soon, counts, API-key format, previous_attributes. - export-docs-to-website.mts absolutises app-served links for the website. The gnubok-website side is on branch docs/api-correctness (already deployed). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
166 lines
6.3 KiB
TypeScript
166 lines
6.3 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 until a terminal status. (The 202 response advertises `operation.completed` as the eventual push signal, but that webhook event is not deliverable yet — polling is the only supported completion signal today.)',
|
|
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 },
|
|
)
|
|
},
|
|
)
|