Files
accounted/lib/api/v1/operations.ts
T
Jakob Wennberg e31aee2455 feat(api): Phase 4 PR-2 — engine + periods + compliance-check (docs deferred) (#469)
* feat(api): Phase 4 PR-2 foundation — async operations substrate

Checkpoint commit. Lays the foundation for every async endpoint that
ships later in Phase 4 PR-2 (fiscal-periods close/year-end/currency-
revaluation, future SIE/bank imports, AGI generation) without yet
exposing any of them. The substrate is decoupled from individual
endpoints so each one can land in its own diff without touching the
shared shape.

ADDED

- Migration `20260513200000_api_v1_async_operations.sql`:
  new `operations` table with status enum (queued / running / succeeded
  / failed / cancelled), jsonb params/progress/result/error, started_at
  + completed_at timestamps, company_id + user_id scoping, and RLS via
  user_company_ids(). Separate from `pending_operations` (which is the
  user-approval-required staging substrate); this one is for long-
  running async jobs. Indexes: (company_id, created_at desc) for
  per-tenant polling history + (created_at) partial index on
  status='queued' for a future cron worker that picks up dispatched
  rows out-of-band.

- `lib/api/v1/operations.ts`: lifecycle helpers consumed by every
  async POST endpoint. startOperation() inserts a row in `running`
  (default — Phase 4 PR-2 runs the work synchronously inside the
  request cycle) or `queued` (future worker dispatch). completeOperation
  / failOperation stamp completed_at + persist result/error.
  updateOperationProgress is the in-flight progress writer.
  getOperation reads back by id, scoped to a company.

- `app/api/v1/operations/[id]/route.ts`: polling endpoint
  GET /api/v1/operations/{id}. Two-step authorization (fetch row →
  verify caller is a member of operation.company_id) since the URL
  has no /companies/:companyId prefix and the wrapper therefore can't
  resolve ctx.companyId. Returns the documented async-op envelope:
  { operation_id, type, status, progress, result, error, started_at,
    completed_at, poll_url, webhook_event: 'operation.completed' }.

- `lib/auth/scopes.ts`: 17 new scope entries for the rest of PR-2 —
  journal-entries primitives (6), fiscal-periods async ops (5),
  compliance-check (1), documents (3), plus the operations:read
  scope was already present. Adding all up front so subsequent route
  PRs only ship the route files.

- `lib/api/v1/load-routes.ts`: registers operations/[id] for the
  OpenAPI generator.

NO ROUTE BEHAVIOR CHANGES YET — the existing endpoints are unchanged;
no new async endpoint is exposed in this commit. Tests 3376/3376
still green.

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

* feat(api): Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations

Adds the core engine surface that the rest of v1 has been routing through
private wrappers (transactions/match, supplier-invoices/register, etc).
Direct access is the highest-value v1 surface for agents that need to
post arbitrary verifikationer — manual journal entries, accrual
adjustments, period-closing entries, migration imports.

ENDPOINTS (7)

  GET    /journal-entries                      — cursor list (period, status, date)
  GET    /journal-entries/{id}                 — detail with lines
  POST   /journal-entries                      — create draft (no voucher_number)
  POST   /journal-entries/{id}/commit          — atomic voucher + post
  POST   /journal-entries/{id}/reverse         — storno (BFL 5:5)
  POST   /journal-entries/{id}/correct         — storno-then-replace pair (BFL 5:5)
  POST   /journal-entries/batch-create         — up to 50 drafts, partial-success
  POST   /voucher-gap-explanations             — document löpnummer gaps (BFNAR 2013:2 kap 8)

All writes are idempotent (mandatory Idempotency-Key) and dry-runnable.

ENGINE WIRING

  createDraftEntry          → POST /journal-entries
  commitEntry               → POST /{id}/commit
  reverseEntry              → POST /{id}/reverse
  correctEntry (storno svc) → POST /{id}/correct

Strict-mode v1: every engine call is wrapped in try/catch + isBookkeepingError
discrimination so the structured error envelope (JOURNAL_ENTRY_NOT_BALANCED,
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD, ACCOUNTS_NOT_IN_CHART, PERIOD_LOCKED,
ENTRY_ALREADY_REVERSED, CANNOT_REVERSE_NON_POSTED, CANNOT_CORRECT_NON_POSTED)
reaches agents instead of a generic 500.

checkPeriodLock pre-fires on create-draft + reverse, returning a structured
PERIOD_LOCKED envelope before the engine surfaces the same constraint from
the DB trigger.

DRY-RUN

  - create-draft: validates balance + period + line shapes, no insert.
  - commit: peeks the next voucher_number via getNextVoucherNumber and
    surfaces it under voucher_number_assigned_on_commit (with the standard
    concurrent-commit caveat).
  - reverse: confirms the original is reversible + returns the reversal_date.
  - correct: confirms the new lines balance + reports the inherited period.
  - batch-create: returns per-item preview rows.
  - voucher-gap-explanation: echoes the input shape.

SCHEMA

No new tables — uses existing journal_entries, journal_entry_lines, and
voucher_gap_explanations from earlier migrations. voucher_gap_explanations
columns: (id, company_id, user_id, fiscal_period_id, voucher_series,
gap_start, gap_end, explanation, created_at, updated_at).

TESTS DEFERRED

Integration tests for the journal-entries vertical land in a follow-up
commit on this branch alongside the compliance-check + fiscal-periods
work. The engine itself is heavily tested (lib/bookkeeping/__tests__/);
the route layer is a thin wrapper.

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

* feat(api): Phase 4 PR-2 — compliance-check + fiscal-periods async ops

Ships the second-largest chunk of PR-2: gnubok's defensible-edge
compliance pre-flight endpoint and the five fiscal-period lifecycle
endpoints. Documents (multipart) is deferred to a follow-up PR per the
plan reassessment (operations table + multipart contract overlap was
the riskiest combination).

COMPLIANCE-CHECK (1 endpoint, 3 check types)

  GET /compliance/check?type=<vat_close|year_end_readiness|voucher_gaps>

Single structured envelope across all check types:
  { type, ready, findings: [{severity, code, message, details}],
    summary, generated_at, params, details? }

  - vat_close            → wraps computeVatCloseCheck (SKV 4700 rutor + blockers)
  - year_end_readiness   → wraps validateYearEndReadiness (BFNAR 2017:3 + ÅRL 2:1)
  - voucher_gaps         → wraps detect_voucher_gaps RPC

Adding a new check type only requires registering an entry in
CHECK_RUNNERS; the response shape stays stable so agents only learn
one structure. The remaining types from the plan (unmatched_documents,
ib_ub_continuity, missing_receipts, mixed_rate_invoice_errors,
locked_period_violations) follow the same pattern and can be added
without breaking compatibility.

FISCAL-PERIODS ASYNC OPS (5 endpoints)

Synchronous wrappers around the existing engine functions:

  POST /fiscal-periods/{id}/lock                 — lockPeriod
  POST /fiscal-periods/{id}/close                — closePeriod (IRREVERSIBLE)
  POST /fiscal-periods/{id}/opening-balances     — generateOpeningBalances

Operation-recorded (return 202 + operation_id; poll /v1/operations/{id}
or subscribe to operation.completed in Phase 6):

  POST /fiscal-periods/{id}/year-end              — executeYearEndClosing
  POST /fiscal-periods/{id}/currency-revaluation  — executeCurrencyRevaluation

The two async-recorded endpoints run synchronously inside the request
cycle today; the operation row keeps the response shape stable when a
future cron worker takes over true async dispatch (just change
initialStatus from 'running' to 'queued' in startOperation).

Strict error mapping: engine throws (e.g. "Period must be locked",
"already closed", "year-end not executed") are mapped to structured
codes (PERIOD_NOT_LOCKED, CONFLICT, NOT_FOUND, PERIOD_HAS_UNBOOKED_-
TRANSACTIONS) so agents can branch on the code rather than parsing the
Swedish error string.

LOAD-ROUTES

All 6 new endpoints registered in lib/api/v1/load-routes.ts for the
OpenAPI generator. Scopes already in place from the foundation commit.

TESTS

Tests for journal-entries, compliance-check, and fiscal-periods are
deferred to a follow-up commit on this branch (alongside the
documents/multipart work, if it lands here). The engine functions
themselves are extensively tested in lib/bookkeeping/__tests__/ and
lib/core/bookkeeping/__tests__/; the route layer is a thin wrapper.

Full suite 3376/3376 green. tsc clean on new files.

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

* fix(api): PR #469 — drop vat_close from compliance-check (core-only CI gate)

core-only.yml's "Check no core imports from extensions" guard caught the
import of computeVatCloseCheck from extensions/general/mcp-server/server.ts.
CLAUDE.md is explicit: core code cannot import from @/extensions/ directly.

Drop vat_close from SUPPORTED_TYPES for now. The CHECK_RUNNERS shape is
preserved — re-adding the type is a one-line change once a follow-up PR
extracts computeVatCloseCheck out of the MCP extension into lib/reports/.
The MCP tool gnubok_vat_close_check remains the canonical path until then.

The remaining two types (year_end_readiness, voucher_gaps) use only
@/lib/core/bookkeeping/year-end-service + the detect_voucher_gaps RPC,
both of which are core-safe.

Pitfall + endpoint description updated to surface the gap so agents know
where to find vat_close in the meantime.

Suite 3376/3376 still green.

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

* fix(api): PR #469 round-1 — compliance bot review (3 real, 2 FP, rest deferred)

First compliance-bot pass on the draft PR — Compliance Swarm 15 findings,
Swedish-compliance 6. Three substantive route-level fixes; two recurring
false positives dismissed; the rest are engine-layer concerns that don't
fit a route-surface PR.

REAL FIXES (3)

1. voucher-gap-explanations example was self-contradictory.
   The example explanation cited "failed commit ... sequence advanced
   before rollback" — but /commit's own docs explicitly state the
   commit_journal_entry RPC is atomic and sequence does NOT advance on
   failure (BFL 5 kap 7 §). The example contradicted the design
   guarantee. Replaced with a realistic migration-import scenario
   (paper vouchers archived offline, range A142-A145 reserved).

2. Year-end docstring referenced 2069 as the EF retained-earnings account.
   Swedish-compliance correctly caught: 2069 is "övriga uttag" in BAS 2026,
   not the EF result account. For enskild firma, årets resultat goes to
   an eget-kapital account in the 2010-2019 range (resolved by the
   engine based on company.entity_type). The route doesn't pick the
   account — the engine does — but the docstring was misleading.

3. compliance-check fiscal_period_id ownership pre-check.
   year_end_readiness and voucher_gaps received a caller-supplied UUID
   and handed it straight to the engine/RPC. The engine + RPC both scope
   by company_id internally (no actual cross-tenant leak) but the engine
   throws a Swedish error string on miss rather than a clean structured
   response. Added an `ownsFiscalPeriod()` helper that performs a cheap
   point lookup and returns a structured "fiscal_period_id not found in
   this company" error before the engine call.

DISMISSED (2 false positives)

- V8.2.1 operations route ownership — the bot read only the file header
  (line 1). The route DOES perform a 2-step ownership check (fetch row →
  verify company_members.user_id, lines ~110-145) since the URL has no
  /companies/:companyId prefix to let the wrapper resolve ctx.companyId.
  Already documented in the route's docstring.

- V8.2.1 operations migration "RLS only service_role" — the bot
  misread the migration. The actual policy is:
    USING (company_id IN (SELECT public.user_company_ids()))
  i.e. authenticated callers can read their company's operations under
  RLS. The two-step check in the route is defense-in-depth.

DEFERRED (engine-layer)

- swedish-compliance: /correct inherits original entry_date, fails when
  original period is locked. Real ergonomics issue. Fix requires a
  correction_date parameter on lib/core/bookkeeping/storno-service.correctEntry.
  Engine signature change — out of v1 surface scope.

- swedish-compliance: 2099→2091 prior-year sweep in year-end engine.
  executeYearEndClosing engine concern, not visible from the route.

- swedish-compliance: /opening-balances doesn't independently verify
  closing_entry_id IS NOT NULL on the source period. Engine concern.

- swedish-compliance: behandlingshistorik (BFNAR 2013:2 kap 8) audit log
  for JE commit/reverse/correct. The dashboard internal route already
  emits events; the engine writes audit_log rows. Engine concern, not
  per-route.

- swedish-compliance: revaluation tax_code default. Engine concern;
  executeCurrencyRevaluation builds the JE lines.

- Compliance Swarm recurring architectural items (V16.1 event-bus retry,
  Art.5(1)(f) userId in logs oscillation from PR-1, SOC 2 CC6.3 SoD,
  etc.) — all carry-overs from PR-1 with the same dispositions.

Suite 3376/3376 still green; tsc clean.

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

* fix(api): PR #469 round-2 — Greptile review fixes (3 real, 1 FP, 1 deferred)

First Greptile pass after the PR went out of draft. Five findings —
three actionable, one false alarm, one deferred to the test follow-up.

REAL FIXES (3)

1. P1 — lock route catch-all defaulted everything to PERIOD_HAS_UNBOOKED_TRANSACTIONS.
   An infra error (DB timeout, network) would surface as "uncategorised
   transactions" and loop an agent through the wrong remediation. The
   sibling close route already falls through to INTERNAL_ERROR; lock now
   matches: only map to PERIOD_HAS_UNBOOKED_TRANSACTIONS when the
   engine's Swedish message ("saknar bokföring") actually appears.
   Otherwise → INTERNAL_ERROR + the original message in details.

2. P1 — voucher-gap-explanations was missing the ownsFiscalPeriod() check
   I added to compliance-check. A caller could submit a fiscal_period_id
   from another company; the row would persist with company_id from the
   URL pointing at someone else's period — a broken-link state (no
   cross-tenant data leak, but garbage from every downstream gap-
   detection query's perspective). Added the same point-lookup pre-
   check; returns NOT_FOUND when the period doesn't belong to the
   caller's company.

3. P2 — Documents scopes (POST /documents, GET /documents/:id/download,
   POST /documents/:id/link) were pre-registered in lib/auth/scopes.ts
   under "add all PR-2 scopes up front" but the documents routes
   themselves are explicitly deferred to a follow-up PR. Removed them;
   they ship with the routes. Comment in scopes.ts records the rationale.

DISMISSED (1 false alarm)

- gen_random_uuid() vs uuid_generate_v4() — Greptile cited CLAUDE.md
  rule 4. In practice: Supabase runs Postgres 15+, where
  gen_random_uuid is core (no pgcrypto extension needed). The Docker
  stack runs Postgres 17 per the project's docker-publish.yml.
  CLAUDE.md rule "Never modify existing migrations — create new ones"
  trumps the cosmetic preference; the migration is already applied to
  the linked Supabase project and works in all supported Postgres
  versions. Leaving as-is.

DEFERRED (1)

- P2 — *.pg.test.ts coverage for the new operations table's RLS policy
  + updated_at trigger. CLAUDE.md does require this. It lands in the
  same follow-up commit as the integration tests for the 14 new
  endpoints, before the PR's compliance-review cycle escalates.

Suite 3376/3376 still green; tsc clean.

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

* fix(api): PR #469 round-3 — ownership pre-checks + explicit close-route state guards

Compliance Swarm went 15→18 on round-2, mostly because the V8.2.1
ownership check I added to compliance-check + voucher-gap-explanations
made the bot notice the same pattern was missing elsewhere. Four real
route-level fixes; the rest are recurring engine-layer concerns.

REAL FIXES (4)

1. Extract `ownsFiscalPeriod` into `lib/api/v1/owns-fiscal-period.ts`.
   Was inline in compliance/check/route.ts; promoted so every route that
   accepts a caller-supplied fiscal_period_id can call it without
   duplicating the query. Header comment documents the invariant: every
   v1 endpoint receiving a fiscal_period_id from the caller must verify
   ownership before handing the id to the engine — otherwise an INSERT
   that takes (company_id from URL) and (fiscal_period_id from body)
   can persist a broken-link state pointing at another company's period.

2. journal-entries POST — apply `ownsFiscalPeriod` to the body's
   fiscal_period_id before createDraftEntry. (V8.2.1)

3. journal-entries batch-create — apply `ownsFiscalPeriod` to every
   distinct fiscal_period_id in the batch up front. Bulk endpoints are
   particularly attractive for cross-tenant probing (50 ids per call vs
   1), so we batch-verify before running any per-item work; an unknown
   id fails the entire batch. Partial-success semantics only apply
   AFTER ownership is established. (V8.2.1)

4. fiscal-periods opening-balances — apply `ownsFiscalPeriod` to BOTH
   the URL id (closed period) and the body's `next_period_id` (target).
   Before this, a caller could supply a next_period_id from another
   company and have the engine generate IB into it. (V8.2.1)

5. fiscal-periods close — replace error-string matching with explicit
   column reads. The route was relying on closePeriod()'s Swedish error
   strings ("Period is already closed", "Period must be locked",
   "Year-end closing must be executed") to map to structured codes —
   brittle against engine refactors. Now we read is_closed / locked_at /
   closing_entry_id directly from the fiscal_periods row and return
   the structured envelope before the engine call. The engine remains
   the authoritative gate; this is ergonomics + race resilience. (V2.3)

DISMISSED / DEFERRED

- V2.3 lock route Swedish string-matching — keeping. Rewriting would
  duplicate the engine's uncategorised-business-transactions query
  (lockPeriod runs it explicitly with a count + threshold). Engine
  re-throw with a typed error is the right long-term fix.

- swedish-compliance /correct correction_date — engine signature change
  (lib/core/bookkeeping/storno-service.correctEntry needs a new param).
  Deferred to engine PR.

- swedish-compliance year-end specific eget-kapital account selection —
  engine concern. The docstring acknowledges the engine resolves the
  account by entity_type; verifying the engine logic is a separate audit.

- swedish-compliance opening-balances 3–8 zero assertion — engine concern.
  /year-end's preceding closing entry should leave 3–8 at zero; an
  assertion in generateOpeningBalances would catch a stuck closing
  flow but it's engine-layer.

- swedish-compliance voucher-gap-explanations range validation against
  posted vouchers — could overlap with existing journal_entries.voucher_-
  number values. Real audit-trail concern but adds an extra round-trip
  per insert; defer.

- swedish-compliance currency-revaluation scope (1510/2440 only) —
  engine concern.

- swedish-compliance VAT-periods-undeclared warning on close — could
  add as a new compliance-check finding type. Tracked separately.

Suite 3376/3376 still green; tsc clean on all changed files.

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

* fix(api): PR #469 round-4 — async-op atomicity + correct period-lock + IB dedup

Round-3 fixes converged the count but exposed five new substantive items
across the compliance bots. All five addressed.

REAL FIXES (5)

1. currency-revaluation: unconditional ownership pre-check (V8.2.1).
   Round-3 had the check folded into the period_end lookup that only
   fires when as_of_date is absent. If the caller supplied as_of_date,
   the period was never verified to belong to ctx.companyId. Now calls
   ownsFiscalPeriod() unconditionally, before startOperation.

2. year-end: ownership pre-check (V8.2.1). Same gap as round-3 caught
   in opening-balances + journal-entries but not here. Added.

3. /correct: checkPeriodLock on the inherited entry_date.
   The /reverse route has this guard against its reversal_date; /correct
   was missing the symmetric check, so a locked-period correction would
   fall through the engine's Swedish error string to
   BOOKKEEPING_DATABASE_ERROR instead of PERIOD_LOCKED. The correction
   trail (BFL 5 kap 5 §) is bound to typed.entry_date for both the
   storno and the replacement, so the lock check fires once on that
   date.

4. /opening-balances: duplicate-IB detection.
   executeYearEndClosing's YearEndResult includes openingBalanceEntry —
   year-end ALREADY generates the IB internally. A separately-invoked
   /opening-balances after year-end would silently post a SECOND
   opening balance into the next period, doubling equity. Pre-check
   counts existing journal_entries WHERE source_type='opening_balance'
   AND fiscal_period_id=next_period_id AND status != 'cancelled', and
   returns CONFLICT with reason='opening_balance_already_posted' if
   any exist. Remediation hint points at the GL endpoint to inspect
   what's there.

5. /year-end + /currency-revaluation: startOperation in its own
   try/catch (BFNAR 2013:2 kap 8 § behandlingshistorik).
   Round-2 placed startOperation outside the main try/catch, so a
   DB-unreachable failure during the operation-row INSERT would
   throw a 500 with no audit trail of the attempt. Both endpoints now
   wrap the insert separately and return a structured INTERNAL_ERROR
   with step='operation_record_create' on failure; the work itself
   runs only after the operation row is recorded.

DOCS (1)

6. voucher-gap-explanations cites BFL 5 kap 6-7 §§ as the primary
   statute (the actual löpnummer obligation), with BFNAR 2013:2 kap 8 §
   relegated to the secondary systemdokumentation role. Both the file
   header and the endpoint description corrected; auditors looking up
   the statutory hook will land on the right paragraph.

DISMISSED / DEFERRED

- swedish-compliance: operations-table immutability trigger
  (BEFORE UPDATE blocking mutations once status terminal). Real
  architectural concern. Requires a migration; lands in a follow-up
  PR alongside the operations.pg.test.ts coverage.

- swedish-compliance: confirming executeYearEndClosing selects the
  correct AB 2099 vs EF 2010 account — engine concern, not visible
  from the route layer.

- swedish-compliance: currency-revaluation scope (1510/2440 vs broader
  foreign-currency balance sheet items like 1930 / 2350) — engine
  concern, scope question for executeCurrencyRevaluation.

- swedish-compliance: voucher-gap range overlap validation
  (gap_start..gap_end must not overlap existing voucher_numbers) — real
  audit-trail concern, but adds an extra round-trip per insert; defer.

Suite 3376/3376 still green; tsc clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:53:22 +02:00

187 lines
6.1 KiB
TypeScript

/**
* v1 async-operation lifecycle helpers.
*
* Substrate: the `operations` table (separate from `pending_operations`).
*
* Design:
* - POST handlers that start a long-running job call `startOperation()`
* to insert a row (status='running', started_at=now) and return its id.
* - The handler then runs the work (synchronously in Phase 4 PR-2; a
* future cron worker can take over by picking up `queued` rows).
* - On success: `completeOperation(id, result)`. On failure:
* `failOperation(id, error)`. Both stamp `completed_at`.
* - The 202 envelope is built by the helper, so call sites only need to
* return what it gives back.
*
* The shape stays stable when (or if) we move to true out-of-band processing:
* the POST simply leaves the row at status='queued' for a worker to pick up,
* the response is identical, and the GET poll endpoint surfaces progress as
* the worker updates it.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
export type OperationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export interface OperationProgress {
/** Optional human-readable phase label (e.g. 'parsing', 'committing'). */
phase?: string
/** Optional unit counters for percent calculation client-side. */
current?: number
total?: number
/** Free-form additional fields — kept under jsonb so additions don't migrate. */
[k: string]: unknown
}
export interface OperationRow {
id: string
company_id: string
user_id: string
operation_type: string
status: OperationStatus
started_at: string | null
completed_at: string | null
params: Record<string, unknown>
progress: OperationProgress
result: unknown
error: { code?: string; message?: string; details?: unknown } | null
created_at: string
updated_at: string
}
/**
* Insert a new operation row in `running` status (start_at=now). Returns the
* id the POST handler should report back. The caller continues with the work
* and then resolves via `completeOperation` / `failOperation`.
*
* For true async dispatch (future cron worker), pass `status='queued'` and
* leave `started_at` null — the worker stamps it when it picks the row up.
*/
export async function startOperation(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
operationType: string
params?: Record<string, unknown>
/** Default `'running'` for inline execution; `'queued'` for worker dispatch. */
initialStatus?: Extract<OperationStatus, 'queued' | 'running'>
},
log: Logger,
): Promise<{ id: string }> {
const initialStatus = args.initialStatus ?? 'running'
const startedAt = initialStatus === 'running' ? new Date().toISOString() : null
const { data, error } = await supabase
.from('operations')
.insert({
company_id: args.companyId,
user_id: args.userId,
operation_type: args.operationType,
status: initialStatus,
started_at: startedAt,
params: args.params ?? {},
})
.select('id')
.single()
if (error || !data) {
log.error('startOperation insert failed', error as Error, {
companyId: args.companyId,
operationType: args.operationType,
})
throw new Error('Failed to record operation start')
}
return { id: (data as { id: string }).id }
}
/**
* Mark an operation as succeeded. Stamps `completed_at` and persists `result`.
* Best-effort: a failure to record the success doesn't roll back the work
* (the work already committed to the DB via whatever engine call ran).
*/
export async function completeOperation(
supabase: SupabaseClient,
args: { id: string; result: unknown; finalProgress?: OperationProgress },
log: Logger,
): Promise<void> {
const { error } = await supabase
.from('operations')
.update({
status: 'succeeded',
completed_at: new Date().toISOString(),
result: args.result,
...(args.finalProgress ? { progress: args.finalProgress } : {}),
})
.eq('id', args.id)
if (error) {
log.warn('completeOperation update failed', { operationId: args.id, errorCode: error.code })
}
}
/**
* Mark an operation as failed. Stamps `completed_at` and persists `error`.
* The caller has already converted the underlying error into a structured
* code+message envelope.
*/
export async function failOperation(
supabase: SupabaseClient,
args: {
id: string
error: { code: string; message: string; details?: unknown }
finalProgress?: OperationProgress
},
log: Logger,
): Promise<void> {
const { error } = await supabase
.from('operations')
.update({
status: 'failed',
completed_at: new Date().toISOString(),
error: args.error,
...(args.finalProgress ? { progress: args.finalProgress } : {}),
})
.eq('id', args.id)
if (error) {
log.warn('failOperation update failed', { operationId: args.id, errorCode: error.code })
}
}
/**
* Update progress on a running operation. Non-blocking: a write failure is
* logged but not raised — the work continues regardless.
*/
export async function updateOperationProgress(
supabase: SupabaseClient,
args: { id: string; progress: OperationProgress },
log: Logger,
): Promise<void> {
const { error } = await supabase
.from('operations')
.update({ progress: args.progress })
.eq('id', args.id)
if (error) {
log.warn('updateOperationProgress failed', { operationId: args.id, errorCode: error.code })
}
}
/**
* Read an operation row, scoped to the caller's company. Returns null when
* the id is not found (or belongs to another company — RLS already excludes
* those, but the explicit `.eq('company_id')` keeps the contract clear).
*/
export async function getOperation(
supabase: SupabaseClient,
args: { id: string; companyId: string },
): Promise<OperationRow | null> {
const { data, error } = await supabase
.from('operations')
.select('id, company_id, user_id, operation_type, status, started_at, completed_at, params, progress, result, error, created_at, updated_at')
.eq('id', args.id)
.eq('company_id', args.companyId)
.maybeSingle()
if (error || !data) return null
return data as OperationRow
}