Files
accounted/app/api/v1/operations/[id]/route.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

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 },
)
},
)