PRD 0001: Restructure api.js into per-domain service modules#

Source of truth for this epic. Tracker: epic issue. The epic links here; this file is not embedded in the issue body.

Problem#

src/services/api.js is meant to be the central transport layer: it creates the Axios instance, resolves the base URL (runtime config → Vite env), attaches the bearer token, and auto-logs-out on 401. That part is genuinely cross-cutting — every feature depends on it and it carries no domain knowledge.

Over time the file has also accumulated domain-specific endpoint catalogs exported as named objects:

  • diagnose — 7 admin-diagnose methods (includes the 404 → not_found normalization that is the single home for that response shape).

  • tokens — 12 token-management methods.

  • plan — ~38 write/mutation methods spanning eight distinct domains (sources, instruments/modules/lines, obs-units, programs, pre-scheduled slots, obs-modes, setup/switch-plan, obs-config/CHAI).

  • scheduler — 1 method (check_observable).

plan in particular is the planner feature’s entire write surface parked in the central module, so every new planner endpoint edits the same file that owns auth and 401 handling — coupling that has nothing to do with transport. A second smell compounds it: the abstraction is only half-applied. The planner components still issue ~30 raw inline api.get('/obs/…'), api.get('/instruments/…'), api.get('/sources/…') reads that never made it into the plan object, so plan wraps only the mutations while the matching reads stay scattered across the components. A reader of api.js sees the writes and has no idea the reads exist.

This diverges from the repo’s own convention of grouping by domain everywhere else — src/store/modules/<domain>, src/components/<domain>/.

Solution#

Return api.js to a transport-only module and give each domain its own service file, mirroring the store/component layout the repo already uses:

src/services/
  api.js        → Axios instance + interceptors only; `export default api`
  diagnose.js   → export const diagnose = { … }   (404→not_found kept intact)
  tokens.js     → export const tokens = { … }
  plan.js       → export const plan = { …writes AND reads for the planner domain… }
  scheduler.js  → export const scheduler = { checkObservable }

Each domain file does import api from './api' and exports the same named object under the same name, so importers change only their import path, not their call sites (e.g. import { plan as planApi } from '@/services/api' becomes from '@/services/plan'). Behavior is unchanged — no endpoint URL, param, or logic changes — with one deliberate deepening: the planner domain’s inline reads are folded into plan.js so the module owns reads and writes together and the components stop calling raw api.get(...).

This is a mechanical, behavior-preserving refactor. Its value is structural: api.js stops being a change-magnet coupled to every feature, and each feature’s endpoint surface lives with that feature.

Precondition#

This epic is a follow-up to PR #71 (branch 22-include-scheduler_loop-options-in-operational_config), which introduces plan/scheduler and itself edits api.js. All slices assume post-#71 develop; starting earlier would only collide with that PR.

Implementation decisions#

  • api.js keeps only transport: the axios.create(...) instance, baseURL resolution, paramsSerializer, the bearer-token request interceptor, the 401 auto-logout response interceptor, and export default api. Nothing else.

  • One file per domain, each exporting the existing named object verbatim so call sites are untouched. Names stay diagnose, tokens, plan, scheduler.

  • diagnose.js preserves the getOperationReport 404 → not_found normalization as-is — it is the single home for the not_found report shape, shared by FailureAlertPanel.vue and DiagnoseView.vue; it must not drift.

  • Importer updates are path-only. Known importers to migrate:

    • diagnose: store/modules/transfer.js (import api, { diagnose }), components/transfer/FailureAlertPanel.vue, views/DiagnoseView.vue.

    • tokens: store/modules/tokens.js.

    • plan: the 9 planner components under src/components/planner/ (ChaiInparPanel, ChaiLinesPanel, InstrumentsPanel, ObsConfigDialog, ObsSetupPanel, ObsUnitsPanel, ProgramsPanel, SlotsPanel, SourcesPanel).

    • scheduler: components/observer/CheckObservablePanel.vue.

    • The many import api from '@/services/api' default-import consumers are unaffected — the default export stays put.

  • Sequencing: every extraction removes its block from the same api.js, so the slices form a linear chain (diagnose → tokens → plan+scheduler → fold reads) rather than parallel PRs that would conflict on api.js. The plan+scheduler slice is the one that leaves api.js transport-only.

  • Reads-folding (final slice): move the ~30 inline planner api.get(...) reads into plan.js as named read methods (e.g. getSources, getObsUnits, getLines, getInstrumentModules, …); planner components call those instead of raw api.get. Endpoint strings and params are copied verbatim.

Testing decisions#

The repo has no test framework configured, so verification is behavioral via the build/lint gates plus a targeted smoke of the affected screens:

  • npm run lint and npm run build must pass for every slice (a moved/renamed import that misses a call site fails the build — the primary safety net for a path-only refactor).

  • Manual smoke of each affected surface after its slice: the Diagnose drawer and /admin/diagnose view (diagnose), token management (tokens), the planner panels and observer check-observable panel (plan/scheduler), confirming the same requests fire with the same params (network tab) and the 404 → not_found path still renders.

  • Prior art for “behavior lives at the boundary, not the file layout”: the existing diagnose/tokens exports and their consumers (FailureAlertPanel.vue, DiagnoseView.vue, store/modules/tokens.js) — their call sites should be byte-for-byte identical after migration.

Out of scope#

  • No change to endpoint URLs, request params, response handling, or any runtime behavior.

  • No migration of the default-import api consumers (they already depend only on transport and stay as-is).

  • No new nesting/reshaping of the plan object’s method names beyond adding the folded read methods (no plan.sources.create restructuring).

  • No backend (ops-db-api) changes.

  • Doing the refactor before PR #71 merges.