[AZ-485] [AZ-486] Cycle 1 docs refresh (Step 13)
ci/woodpecker/push/build-arm Pipeline was successful

Phase B cycle 1 was a structural refactor only: F4 (barrel imports +
STC-ARCH-01) and F7 (endpoint builders + STC-ARCH-02). This commit
brings docs in line with source after the cycle, no code changes.

Module docs (12 consumers): swap every /api/<service>/... literal in
code snippets and integration tables for the matching endpoints.*
builder; note the barrel import migration in Dependencies.

New module doc: src__api__endpoints.md (public surface, F4 barrel
re-export note, STC-ARCH-02 enforcement, contract-test reference).

Architecture compliance baseline: mark F4 + F7 CLOSED with commit
hashes (23746ec, 8a461a2).

01_api-transport component description: add endpoints.ts + barrel to
Internal Interfaces, close the F7 caveat, extend Module Inventory.

ripple_log_cycle1.md: Task Step 0.5 reverse-dep analysis records the
import-graph closure (no extra docs needed beyond the direct set).

Carry-over reports landed alongside the docs:
- test_run_report_phase_b_cycle1.md (Step 11 outcome)
- implementation_report_refactor_phase_b_cycle1.md (cycle summary)

State file: trimmed to the autodev <30-line target; Steps 14 + 15
recorded as SKIPPED with rationale (no security or perf surface
changed in this cycle); pointer moved to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 00:01:04 +03:00
parent 8a461a2051
commit 17d5bb45e7
17 changed files with 456 additions and 172 deletions
@@ -42,11 +42,11 @@ export const api = {
- `204``undefined as T`.
- `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
- Otherwise → `res.json()` (no schema validation — caller types the response).
- `refreshToken()``POST /api/admin/auth/refresh` with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean.
- `refreshToken()``POST endpoints.admin.authRefresh()` (i.e. `/api/admin/auth/refresh`) with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean. (Path produced by the `endpoints` builder; closes F7.)
## Dependencies
- **Internal**: none.
- **Internal**: `./endpoints``endpoints.admin.authRefresh()` used by the internal `refreshToken()` helper (since AZ-486 / F7).
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.
## Consumers (intra-repo)
@@ -71,9 +71,9 @@ None defined here. The generic `T` parameter is supplied by call sites.
## Configuration
URLs are **string literals** at every call site (`/api/admin/...`, `/api/flights?...`, etc.). There is no base-URL constant. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends.
URLs are produced by typed builders in `src/api/endpoints.ts` (see `src__api__endpoints.md`) — the F7 finding from the architecture baseline is now CLOSED. Every consumer (this module included) imports `endpoints` and calls `endpoints.<service>.<method>(...)`; the `STC-ARCH-02` static gate forbids re-introducing literal `/api/<service>/...` strings under `src/`.
A `VITE_API_BASE_URL` env-var fix is the canonical Step 4 testability candidate (workspace `README.md` calls this out).
There is no base-URL constant: the path strings are still relative. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends. `getApiBase()` (exported from this module) supplies the host prefix at runtime where the consumer needs an absolute URL (e.g. the manual `fetch(getApiBase() + endpoints.admin.authRefresh(), ...)` call inside `refreshToken()`).
## External integrations
@@ -0,0 +1,135 @@
# Module: `src/api/endpoints.ts`
> **Source**: `src/api/endpoints.ts` (79 lines)
> **Topo batch**: B2 (leaf — no internal imports)
> **Introduced**: AZ-486 (2026-05-11, commit `8a461a2`), closing architecture baseline finding F7.
## Purpose
Single source of truth for every `/api/<service>/<path>` URL the UI talks to. Replaces the hardcoded string literals that previously lived at each `api.*` / `createSSE` call site (and at every `src={...}` URL for API-served images / videos). The `endpoints` object is the canonical wire-contract documentation: each builder produces a character-identical string to the literal it superseded, so MSW handlers + e2e stubs + the nginx routing table all keep matching.
Together with the `STC-ARCH-02` static gate (see [Configuration](#configuration)), this module enforces "no hardcoded API path literals in `src/`" as a build-time invariant rather than a code-review aspiration.
## Public interface
```ts
export const endpoints = {
admin: {
authRefresh: () => string
authLogin: () => string
authLogout: () => string
users: () => string
user: (id: string) => string
classes: () => string
class: (id: string | number) => string
},
annotations: {
classes: () => string
settingsUser: () => string
settingsSystem: () => string
settingsDirectories: () => string
annotations: () => string
annotationsByMedia: (mediaId: string, pageSize?: number) => string // pageSize default = 1000
annotationImage: (annotationId: string) => string
annotationThumbnail: (annotationId: string) => string
annotationEvents: () => string
media: (queryString: string) => string
mediaFile: (mediaId: string) => string
mediaItem: (mediaId: string) => string
mediaBatch: () => string
dataset: (queryString: string) => string
datasetItem: (annotationId: string) => string
datasetBulkStatus: () => string
datasetClassDistribution: () => string
},
flights: {
collection: (queryString?: string) => string // GET ?pageSize=... lists; POST (no qs) creates
aircrafts: () => string
aircraft: (id: string) => string
flight: (id: string) => string
flightWaypoints: (id: string) => string
flightWaypoint: (flightId: string, waypointId: string) => string
flightLiveGps: (id: string) => string
},
detect: {
media: (mediaId: string) => string // POST → trigger detection for a media item
},
} as const
```
The whole object is `as const`, so each leaf's return type is the narrow string literal where possible (e.g. `'/api/admin/auth/refresh'`) and the parameterised builders carry a `string` return.
## Internal logic
- **Pure data + template strings.** No side effects, no I/O, no caching. Every builder is a one-line `() => '...'` or arrow returning a template literal.
- **Function form (not constants)**, per direction at task-creation time:
- Parameterised paths (e.g. `flight(id)`) need a function anyway. Keeping every entry as a function — even the constant ones — gives a single uniform call shape at every site (`endpoints.x.y()`) so reviewers don't have to remember which entries take parens and which don't.
- Per-builder tree-shaking under Vite's production rollup remains intact.
- **Query strings owned by the caller for variable-shape paths.** Where the query is dynamic (`flights.collection`, `annotations.media`, `annotations.dataset`), the caller builds a `URLSearchParams.toString()` and the builder owns only the path + `?`. This keeps the wire contract identical to pre-refactor literals at every callsite.
## Public API (barrel re-export)
`src/api/index.ts` re-exports `endpoints` alongside `api`, `createSSE`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`. Consumers OUTSIDE the `01_api-transport` component MUST import from the barrel (`import { endpoints } from '@/api'` or `from '../api'`) — direct imports of `src/api/endpoints` from other components are blocked by `STC-ARCH-01` (F4 closure, see `src__api__client.md`).
## Dependencies
- **Internal**: none.
- **External**: none.
## Consumers (intra-repo)
After the AZ-486 migration, `endpoints` is imported by:
- `src/api/client.ts` — internal `refreshToken()` helper uses `endpoints.admin.authRefresh()`.
- `src/auth/AuthContext.tsx``authRefresh`, `authLogin`, `authLogout`.
- `src/components/FlightContext.tsx``flights.collection`, `flights.flight`, `annotations.settingsUser`.
- `src/components/DetectionClasses.tsx``admin.classes`, `admin.class`.
- `src/features/admin/AdminPage.tsx``admin.users`, `admin.user`.
- `src/features/annotations/AnnotationsPage.tsx` — annotation CRUD endpoints, `detect.media`.
- `src/features/annotations/AnnotationsSidebar.tsx``annotations.annotationEvents` (SSE), bulk-status, dataset endpoints.
- `src/features/annotations/CanvasEditor.tsx``annotations.annotationImage`, `annotations.annotationThumbnail`.
- `src/features/annotations/MediaList.tsx``annotations.media`, `annotations.mediaFile`, `annotations.mediaItem`, `annotations.mediaBatch`.
- `src/features/annotations/VideoPlayer.tsx``annotations.mediaFile`.
- `src/features/dataset/DatasetPage.tsx``annotations.dataset*` family, `annotations.classes`, `annotations.annotationImage`.
- `src/features/flights/FlightsPage.tsx` — full `flights.*` surface + `annotations.settingsUser`.
- `src/features/settings/SettingsPage.tsx``annotations.settings*`, `flights.aircrafts`.
This is the full intra-repo consumer list — `STC-ARCH-02` guarantees no production-source caller falls outside it.
## Data models
None defined here. Path-string output only.
## Configuration
The module IS the API-path configuration. The only "config" is the nginx routing table that maps each `/api/<service>/...` prefix to a concrete backend service — see `src__api__client.md` → External integrations for the live table.
**Static enforcement (`STC-ARCH-02`)**:
- **Script**: `scripts/check-arch-imports.mjs --mode=api-literals`.
- **Wired into**: `scripts/run-tests.sh` (functional profile, static group) — runs before any unit test.
- **What it forbids**: any `/api/<service>/...` literal in `[`'"]` quoting under `src/`.
- **Exempt files**: this file (`src/api/endpoints.ts`) and `src/**/*.test.ts(x)` only.
- **Bypass policy**: none. Adding a new exempt path requires updating the exempt regex in the script AND a `module-layout.md` rule revision in the same commit.
## External integrations
This module integrates nothing directly. It documents — as TypeScript values — the wire contract for every external integration the SPA has, as routed by `nginx.conf`. See the routing table in `src__api__client.md` → External integrations for the per-prefix backend mapping.
## Security
- **No bearer plumbing here.** Token injection still happens in `client.ts` (`Authorization` header) and `sse.ts` (`access_token` query parameter). Builders return URLs **without** the token.
- **No URL-encoding** of interpolated `id` / `mediaId` / `queryString` parameters. All current callsites pass already-safe values (UUIDs, ints, pre-built `URLSearchParams.toString()` output). If any future caller passes user-controlled text, the builder must add `encodeURIComponent` (see open question below).
- **No CSRF surface change** — same posture as the pre-refactor literals.
## Tests
- **`src/api/endpoints.test.ts`** (36 Vitest assertions): pins every builder's output to its exact pre-refactor URL string. This is the contract documentation — any wire-contract change MUST update this test in the same commit as the backend / MSW / e2e stub change. Includes one barrel-re-export assertion (`endpoints` is reachable via `import { endpoints } from '../api'`).
- **`tests/architecture_imports.test.ts`** (AZ-486 / STC-ARCH-02 suite, 6 cases): verifies the static gate passes on the migrated codebase AND fails when a synthetic single-quoted / double-quoted / template-literal `/api/<service>/...` literal is introduced in `src/`. Also verifies the `*.test.ts` and `//` comment exemptions.
## Notes / open questions
- **`detect.media` only exposes the single-segment path** that the UI uses today (`POST /api/detect/<mediaId>`). The full `detect/` service has more endpoints (per the nginx table) but no UI callsite consumes them. Add new builders only when a real callsite needs them — don't pre-populate.
- **`flights.collection` overloads its return** on whether `queryString` is provided. Acceptable while the contract is "GET with `?pageSize`, POST without" — but if a third flights-collection verb (DELETE? PUT?) is ever added with its own query shape, split into named builders rather than threading more conditional logic through one.
- **No URL-encoding of interpolated params** (see Security). Add `encodeURIComponent` at the first callsite that needs it, plus a contract-test case in `endpoints.test.ts`. Currently safe across all 36 pinned URLs.
- **Wire-contract test coverage is exact-string, not shape.** This is deliberate: a "looks like a path" matcher would silently accept a hyphen-to-underscore change that breaks the backend. Updating these strings IS a wire-contract change — treat the test as a release-gate.
+1 -1
View File
@@ -49,7 +49,7 @@ None defined here. The generic `T` is supplied by the caller.
## Configuration
URLs are passed in by callers (string-literal at call sites). The same testability remark as `api/client.ts` applies: a `VITE_API_BASE_URL` is the natural Step 4 fix.
URLs are passed in by callers. Since AZ-486 / F7 (commit `8a461a2`), callers obtain those URLs from `endpoints.*` builders in `src/api/endpoints.ts` rather than from inline string literals. The `STC-ARCH-02` static gate enforces this at every callsite under `src/`. `createSSE` itself is path-agnostic and takes any `url` — the `endpoints` discipline is upheld at the call site, not here.
## External integrations
@@ -34,18 +34,18 @@ State:
**Bootstrap effect (mount-only)**:
```ts
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
.then(data => { setToken(data.token); setUser(data.user) })
.catch(() => {})
.finally(() => setLoading(false))
```
The refresh endpoint is invoked with `credentials: 'include'` only inside `client.ts`'s **internal** `refreshToken()` helper — but here we go through the public `api.get()` path, which does NOT include credentials. **This is a real divergence**: `client.ts`'s internal `refreshToken()` (used in the 401 retry) sends the cookie; the bootstrap call in `AuthContext` does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) **or** via some other mechanism (a refresh token in `localStorage`, etc.). **Flag for Step 4 verification** against the `admin/` service contract; this is likely a real bug masking by silent `.catch`.
The refresh endpoint is invoked with `credentials: 'include'` only inside `client.ts`'s **internal** `refreshToken()` helper — but here we go through the public `api.get()` path, which does NOT include credentials. **This is a real divergence**: `client.ts`'s internal `refreshToken()` (used in the 401 retry) sends the cookie; the bootstrap call in `AuthContext` does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) **or** via some other mechanism (a refresh token in `localStorage`, etc.). **Flag for Step 4 verification** against the `admin/` service contract; this is likely a real bug masking by silent `.catch`. The path string itself is unaffected by AZ-486 — `endpoints.admin.authRefresh()` produces `'/api/admin/auth/refresh'` character-identically to the pre-refactor literal, so the divergence is structural, not URL-based.
**`login(email, password)`**:
```ts
const data = await api.post<{ token; user }>('/api/admin/auth/login', { email, password })
const data = await api.post<{ token; user }>(endpoints.admin.authLogin(), { email, password })
setToken(data.token); setUser(data.user)
```
@@ -54,7 +54,7 @@ Throws to caller (LoginPage) on bad credentials.
**`logout()`**:
```ts
try { await api.post('/api/admin/auth/logout') } catch {}
try { await api.post(endpoints.admin.authLogout()) } catch {}
setToken(null); setUser(null)
```
@@ -65,7 +65,7 @@ Network failure on logout is silently swallowed because we want to clear local a
## Dependencies
- **Internal**:
- `../api/client``api`, `setToken`.
- `../api` (barrel)`api`, `endpoints`, `setToken`. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
- `../types``AuthUser` type.
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).
@@ -86,7 +86,7 @@ From the §7a dependency graph:
## Configuration
Endpoints (string-literal): `/api/admin/auth/refresh`, `/api/admin/auth/login`, `/api/admin/auth/logout`. Routed by `nginx.conf` to the `admin/` service.
Endpoints (typed builders from `../api/endpoints`, since AZ-486 / F7): `endpoints.admin.authRefresh()`, `endpoints.admin.authLogin()`, `endpoints.admin.authLogout()` — producing `/api/admin/auth/refresh`, `.../login`, `.../logout` respectively. Routed by `nginx.conf` to the `admin/` service.
No env vars consumed directly — token storage policy is defined in `client.ts` (in-memory; not persisted to `localStorage`).
@@ -24,7 +24,7 @@ Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The cur
## Internal logic
- **Class catalogue load** (mount-only `useEffect`):
- `api.get<DetectionClass[]>('/api/annotations/classes')`.
- `api.get<DetectionClass[]>(endpoints.annotations.classes())` (= `/api/annotations/classes`, since AZ-486 / F7).
- On a non-empty array → `setClasses(list)`.
- On an empty array OR a thrown error → `setClasses(FALLBACK_CLASSES)`.
- **`FALLBACK_CLASSES`** is a module-private 3 × |`FALLBACK_CLASS_NAMES`| matrix:
@@ -45,8 +45,8 @@ Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The cur
## Dependencies
- **Internal**:
- `../api/client``api.get<T>()`.
- `../features/annotations/classColors``getClassColor(i)`, `FALLBACK_CLASS_NAMES`.
- `../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
- `../features/annotations/classColors``getClassColor(i)`, `FALLBACK_CLASS_NAMES`. (Cross-component import preserved; flagged in Consumers below.)
- `../types``DetectionClass` type.
- **External**: `react`, `react-i18next`, `react-icons/md`, `react-icons/fa`.
@@ -70,7 +70,7 @@ This is the **canonical example** of the cross-layer import flagged in `_docs/02
## Configuration
Endpoint: `/api/annotations/classes` — string-literal URL (testability fix scheduled for Step 4).
Endpoint: `endpoints.annotations.classes()` `/api/annotations/classes` (typed builder from `../api/endpoints`, since AZ-486 / F7).
Photo-mode value set is `{0, 20, 40}` — hardcoded, mirrored by `FALLBACK_CLASSES`. If the backend grows a fourth mode (e.g. thermal, IR), every consumer of `photoMode` will need a coordinated change.
@@ -78,7 +78,7 @@ Tailwind tokens: `bg-az-orange` (Regular), `bg-az-blue` (Winter), `bg-purple-600
## External integrations
- HTTP `GET /api/annotations/classes``DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
- HTTP `GET endpoints.annotations.classes()` (= `/api/annotations/classes`)`DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
## Security
@@ -27,13 +27,13 @@ export function FlightProvider({ children }: { children: ReactNode }): JSX.Eleme
State:
- `flights: Flight[]` — most recent list returned by `GET /api/flights?pageSize=1000`.
- `flights: Flight[]` — most recent list returned by `GET endpoints.flights.collection('pageSize=1000')` (= `/api/flights?pageSize=1000`).
- `selectedFlight: Flight | null` — the active flight, or `null` if none. Survives across pages because the provider is mounted above the route tree.
**`refreshFlights()`** (`useCallback`, no deps):
```ts
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
setFlights(data.items ?? [])
```
@@ -42,8 +42,8 @@ Errors are silently swallowed (`try { ... } catch {}`). `pageSize=1000` is a har
**Bootstrap effect** (`useEffect` keyed on `[refreshFlights]`, runs once because `refreshFlights` is `useCallback([])`):
1. `refreshFlights()` (no `await` — runs in parallel with #2).
2. `api.get<UserSettings>('/api/annotations/settings/user')`
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>('/api/flights/${settings.selectedFlightId}')``setSelectedFlight(f)`.
2. `api.get<UserSettings>(endpoints.annotations.settingsUser())`
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))``setSelectedFlight(f)`.
- errors at every step silently swallowed.
The selected flight is therefore looked up by **its own GET**, not by indexing into the cached `flights` list. This is intentional — the user might have a `selectedFlightId` that fell off the first 1000 flights.
@@ -52,7 +52,7 @@ The selected flight is therefore looked up by **its own GET**, not by indexing i
```ts
setSelectedFlight(f)
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
```
Optimistic — local state updates immediately; the persisted setting is fire-and-forget. If the PUT fails, the next reload will fall back to the previously-stored ID and the user's selection silently reverts. Flag for Step 4.
@@ -60,7 +60,7 @@ Optimistic — local state updates immediately; the persisted setting is fire-an
## Dependencies
- **Internal**:
- `../api/client``api`.
- `../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
- `../types``Flight`, `UserSettings` types.
- **External**: `react` (`createContext`, `useContext`, `useState`, `useEffect`, `useCallback`, `ReactNode`).
@@ -80,9 +80,9 @@ From the §7a dependency graph:
## Configuration
Endpoints (string-literal): `/api/flights?pageSize=1000`, `/api/flights/{id}`, `/api/annotations/settings/user`. Routed by `nginx.conf` to the `flights/` and `annotations/` services.
Endpoints (typed builders from `../api/endpoints`, since AZ-486 / F7): `endpoints.flights.collection('pageSize=1000')`, `endpoints.flights.flight(id)`, `endpoints.annotations.settingsUser()` — producing `/api/flights?pageSize=1000`, `/api/flights/{id}`, `/api/annotations/settings/user` respectively. Routed by `nginx.conf` to the `flights/` and `annotations/` services.
`pageSize=1000` is a **hardcoded ceiling** — if the system ever has >1000 flights, the cache is silently truncated. The `flights/` service contract probably caps at 1000; verify and document in `data_parameters.md` (Step 6). Flag.
`pageSize=1000` is a **hardcoded ceiling** — if the system ever has >1000 flights, the cache is silently truncated. The `flights/` service contract probably caps at 1000; verify and document in `data_parameters.md` (Step 6). Flag. (Note: the literal lives in the caller, not in the `endpoints.flights.collection` builder — moving the ceiling into the builder is a future change.)
## External integrations
@@ -40,9 +40,9 @@ No props. Reads everything via `api/client` and local state.
- **Bootstrap effect** (`useEffect([])` — runs once at mount):
```ts
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
```
Three independent calls, all silently swallowed on error. No retry,
@@ -51,10 +51,11 @@ No props. Reads everything via `api/client` and local state.
`_docs/ui_design/README.md`.
- **`handleAddClass()`**:
1. Guard: `if (!newClass.name) return`.
2. `await api.post('/api/admin/classes', newClass)`.
3. Refetch via `api.get('/api/annotations/classes')` — note the
**read** path is the public `annotations/` endpoint, while the
**write** path is the `admin/` endpoint. Architectural caveat:
2. `await api.post(endpoints.admin.classes(), newClass)` (= `/api/admin/classes`).
3. Refetch via `api.get(endpoints.annotations.classes())` — note the
**read** path is the public `annotations/` endpoint
(`/api/annotations/classes`), while the **write** path is the
`admin/` endpoint (`/api/admin/classes`). Architectural caveat:
two different services own the same logical entity. Document in
`architecture.md` §integration-points (Step 3a).
4. Reset `newClass` to its initial values.
@@ -62,25 +63,26 @@ No props. Reads everything via `api/client` and local state.
non-2xx); the throw is uncaught and reaches React's error boundary
(none configured). Flag.
- **`handleDeleteClass(id)`**: optimistic local update —
`await api.delete('/api/admin/classes/${id}')` then
`setClasses(prev => prev.filter(c => c.id !== id))`. **No
`await api.delete(endpoints.admin.class(id))` (= `/api/admin/classes/${id}`)
then `setClasses(prev => prev.filter(c => c.id !== id))`. **No
ConfirmDialog** despite this being destructive. Inconsistent with
the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
against `_docs/ui_design/README.md` confirmation-dialog spec.
- **`handleAddUser()`** — analogous to `handleAddClass` against
`POST /api/admin/users` and `GET /api/admin/users`. Guards on
`email && password`.
`POST endpoints.admin.users()` and `GET endpoints.admin.users()`
(both → `/api/admin/users`). Guards on `email && password`.
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
1. `PATCH /api/admin/users/${deactivateId}` with `{ isActive: false }`.
1. `PATCH endpoints.admin.user(deactivateId)` (= `/api/admin/users/${deactivateId}`) with `{ isActive: false }`.
2. Optimistic local update: marks the row inactive.
3. Closes the dialog (`setDeactivateId(null)`).
No "reactivate" path — once `isActive: false`, the row only renders
the badge and no Deactivate button. Verify with `admin/` service:
is reactivation an admin task or out of scope?
- **`handleToggleDefault(a)`** — `PATCH /api/flights/aircrafts/${a.id}`
with `{ isDefault: !a.isDefault }`, then optimistic local flip. Note
this allows multiple `isDefault: true` aircraft to coexist (the
backend should enforce exclusivity; the UI does not).
- **`handleToggleDefault(a)`** — `PATCH endpoints.flights.aircraft(a.id)`
(= `/api/flights/aircrafts/${a.id}`) with `{ isDefault: !a.isDefault }`,
then optimistic local flip. Note this allows multiple `isDefault:
true` aircraft to coexist (the backend should enforce exclusivity;
the UI does not).
- **Layout** (left → center → right, all in one horizontal flex):
- **Left column** (`w-[340px]`): detection-classes table + add row.
- **Center column** (`flex-1 max-w-md`): AI settings form, GPS
@@ -93,7 +95,7 @@ No props. Reads everything via `api/client` and local state.
## Dependencies
- **Internal**:
- `../../api/client` — `api`.
- `../../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
- `../../components/ConfirmDialog` — for user deactivation.
- `../../types` — `DetectionClass`, `Aircraft`, `User`.
- **External**: `react` (`useState`, `useEffect`),
@@ -137,19 +139,18 @@ backend assigns `id` and other server-managed fields.
## External integrations
| Method | Path | Purpose |
| Method | Builder → Path | Purpose |
|---|---|---|
| `GET` | `/api/annotations/classes` | List detection classes (read path uses annotations service) |
| `POST` | `/api/admin/classes` | Create detection class (write path uses admin service) |
| `DELETE` | `/api/admin/classes/{id}` | Delete detection class |
| `GET` | `/api/flights/aircrafts` | List aircraft |
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
| `GET` | `/api/admin/users` | List users |
| `POST` | `/api/admin/users` | Create user |
| `PATCH` | `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
| `GET` | `endpoints.annotations.classes()` → `/api/annotations/classes` | List detection classes (read path uses annotations service) |
| `POST` | `endpoints.admin.classes()` → `/api/admin/classes` | Create detection class (write path uses admin service) |
| `DELETE` | `endpoints.admin.class(id)` → `/api/admin/classes/{id}` | Delete detection class |
| `GET` | `endpoints.flights.aircrafts()` → `/api/flights/aircrafts` | List aircraft |
| `PATCH` | `endpoints.flights.aircraft(id)` → `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
| `GET` | `endpoints.admin.users()` → `/api/admin/users` | List users |
| `POST` | `endpoints.admin.users()` → `/api/admin/users` | Create user |
| `PATCH` | `endpoints.admin.user(id)` → `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/`
backends.
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7). Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/` backends.
## Security
@@ -9,19 +9,21 @@ Owns the `/annotations` route. Lets the user:
2. Play / pause / step a video, scrub the timeline, mute, with frame stepping at 1 / 5 / 10 / 30 / 60 frames in both directions (assumed 30 FPS — see Findings).
3. Draw / move / resize / multi-select bounding boxes on a custom canvas overlay, click-and-drag with Ctrl-modifier behaviours, snap to image-normalized coordinates 01.
4. Pick the active detection class (19 hotkeys) and PhotoMode (Regular / Winter / Night) via the shared `DetectionClasses` component.
5. Save the per-frame detection set back to `POST /api/annotations/annotations`, with **fall-back to in-memory storage** when the file is a local blob: URL or the backend is unreachable.
6. Stream the annotations sidebar from the `GET /api/annotations/annotations/events` SSE feed; show each row with the gradient defined in `_docs/ui_design/README.md`.
7. Trigger AI detection via `POST /api/detect/{mediaId}` (modal log overlay).
5. Save the per-frame detection set back to `POST endpoints.annotations.annotations()` (= `/api/annotations/annotations`), with **fall-back to in-memory storage** when the file is a local blob: URL or the backend is unreachable.
6. Stream the annotations sidebar from the `GET endpoints.annotations.annotationEvents()` (= `/api/annotations/annotations/events`) SSE feed; show each row with the gradient defined in `_docs/ui_design/README.md`.
7. Trigger AI detection via `POST endpoints.detect.media(mediaId)` (= `/api/detect/{mediaId}`) — modal log overlay.
8. Download an annotation as YOLO `.txt` + a PNG of the frame with rectangles burned in.
> All path strings produced by `endpoints.*` builders from `src/api/endpoints.ts` (since AZ-486 / F7).
## Module map
| Module | Layer | Responsibility |
|---|---|---|
| `classColors.ts` | leaf | (already documented separately) Class-number → colour + photoMode-suffix lookup. |
| `MediaList.tsx` | sub-component | Left panel media browser. Owns `media[]` state, debounced filter, dropzone upload, blob: local-mode fallback when backend POST fails. Calls `GET /api/annotations/media`, `DELETE /api/annotations/media/{id}`, `POST /api/annotations/media/batch`. |
| `VideoPlayer.tsx` | sub-component | Native `<video>` wrapper. `forwardRef` exposes `seek(seconds)` and `getVideoElement()`. Custom progress slider + frame-step toolbar. Global `keydown` handler for Space / ←/→ (Ctrl=±150) / M. |
| `AnnotationsSidebar.tsx` | sub-component | Right panel: SSE-driven annotation list (`/api/annotations/annotations/events` filtered by `mediaId`), AI detect button, gradient row background built from per-detection class colour + confidence-modulated alpha, download button (delegates to page). |
| `MediaList.tsx` | sub-component | Left panel media browser. Owns `media[]` state, debounced filter, dropzone upload, blob: local-mode fallback when backend POST fails. Calls `endpoints.annotations.media(qs)`, `endpoints.annotations.mediaItem(id)` (DELETE), `endpoints.annotations.mediaBatch()` (POST). |
| `VideoPlayer.tsx` | sub-component | Native `<video>` wrapper. `forwardRef` exposes `seek(seconds)` and `getVideoElement()`. Custom progress slider + frame-step toolbar. Global `keydown` handler for Space / ←/→ (Ctrl=±150) / M. Image / video bytes via `endpoints.annotations.mediaFile(id)`. |
| `AnnotationsSidebar.tsx` | sub-component | Right panel: SSE-driven annotation list (`endpoints.annotations.annotationEvents()` filtered by `mediaId`), AI detect button (`endpoints.detect.media(mediaId)`), gradient row background built from per-detection class colour + confidence-modulated alpha, download button (delegates to page). |
| `CanvasEditor.tsx` | sub-component | Canvas overlay used in both image-only and video-overlay modes. `forwardRef` exposes `deleteSelected / deleteAll / hasSelection`. Owns zoom (Ctrl+wheel, 0.110×), pan (held in `pan` state — there is no Ctrl+drag pan code, only an unused `pan` state — see Findings), 8-handle resize, multi-select, draw-on-Ctrl-mousedown. Renders detections + a "time-window" preview of *other* annotations during video playback. |
| `AnnotationsPage.tsx` | page | Orchestrator: 3-panel layout via `useResizablePanel` (left 250 / right 200, min/max bounds). Owns `selectedMedia`, `annotations`, `detections`, `selectedClassNum`, `photoMode`, `currentTime`. Wires `MediaList``CanvasEditor``VideoPlayer``AnnotationsSidebar`. Handles the `Save` POST + local-fallback. Builds the YOLO + PNG download. |
@@ -29,24 +31,26 @@ Owns the `/annotations` route. Lets the user:
- **`Detection`** (from `src/types/index.ts`): `{ id, classNum, label, confidence ∈ [0,1], affiliation: 0|1|2, combatReadiness, centerX, centerY, width, height }` — all coords normalized 01. Matches `_docs/legacy/wpf-era.md §10` and parent suite `_docs/01_annotations.md` Detection DTO.
- **`AnnotationListItem`**: `{ id, mediaId, time: 'HH:MM:SS.mmm' | null, createdDate, source: AnnotationSource, status: AnnotationStatus, isSplit, splitTile, detections[] }`. Matches `Annotations` table in parent `_docs/00_database_schema.md` modulo client-side `isSplit / splitTile`.
- **AI detect endpoint**: `POST /api/detect/{mediaId}` — matches parent `../../../../_docs/03_detections.md §2` after nginx strips `/api/detect/`. NOTE: UI does NOT forward the `X-Refresh-Token` header that the spec requires for long-running video detection (`_docs/03_detections.md §2 request headers`). Carried as a finding.
- **AI detect endpoint**: `endpoints.detect.media(mediaId)``POST /api/detect/{mediaId}` — matches parent `../../../../_docs/03_detections.md §2` after nginx strips `/api/detect/`. NOTE: UI does NOT forward the `X-Refresh-Token` header that the spec requires for long-running video detection (`_docs/03_detections.md §2 request headers`). Carried as a finding.
- **Save body**: `{ mediaId, time: 'HH:MM:SS.mmm' | null, detections: Detection[] }`. .NET `TimeSpan.Parse` accepts that format so the round-trip works for `time → VideoTime`. **Body is missing required `Source` and optional `WaypointId`** required by parent spec `CreateAnnotationRequest` — see Findings.
## External integrations
| Endpoint / origin | Where | Direction | Notes |
| Builder → Path | Where | Direction | Notes |
|---|---|---|---|
| `GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000. |
| `GET /api/annotations/media/{id}/file` | `CanvasEditor`, `VideoPlayer`, `AnnotationsPage.handleDownload` | egress | Image / video bytes. |
| `POST /api/annotations/media/batch` | `MediaList.uploadFiles` | egress | `multipart/form-data`. |
| `DELETE /api/annotations/media/{id}` | `MediaList.handleDelete` | egress | Skipped for local blob: entries. |
| `GET /api/annotations/annotations?mediaId&pageSize=1000` | `MediaList.handleSelect`, `AnnotationsSidebar`, `AnnotationsPage.handleSave` | egress | Refresh after save / SSE event. |
| `POST /api/annotations/annotations` | `AnnotationsPage.handleSave` | egress | Body: `{ mediaId, time, detections }`. Falls back to in-memory on 4xx/5xx. |
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor.loadImage` | egress | Per-annotation hashed image; preferred over the raw media for image annotations. |
| `GET /api/annotations/annotations/events` (SSE) | `AnnotationsSidebar` | egress | Listens for `{ annotationId, mediaId, status }` events; filters client-side by `media.id`. |
| `POST /api/detect/{mediaId}` | `AnnotationsSidebar.handleDetect` | egress | Triggers AI inference — endpoint shape unverified vs `_docs/03_detections.md`. |
| `endpoints.annotations.media(qs)``GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000 (in caller). |
| `endpoints.annotations.mediaFile(id)``GET /api/annotations/media/{id}/file` | `CanvasEditor`, `VideoPlayer`, `AnnotationsPage.handleDownload` | egress | Image / video bytes. |
| `endpoints.annotations.mediaBatch()``POST /api/annotations/media/batch` | `MediaList.uploadFiles` | egress | `multipart/form-data`. |
| `endpoints.annotations.mediaItem(id)``DELETE /api/annotations/media/{id}` | `MediaList.handleDelete` | egress | Skipped for local blob: entries. |
| `endpoints.annotations.annotationsByMedia(mediaId, 1000)``GET /api/annotations/annotations?mediaId&pageSize=1000` | `MediaList.handleSelect`, `AnnotationsSidebar`, `AnnotationsPage.handleSave` | egress | Refresh after save / SSE event. |
| `endpoints.annotations.annotations()``POST /api/annotations/annotations` | `AnnotationsPage.handleSave` | egress | Body: `{ mediaId, time, detections }`. Falls back to in-memory on 4xx/5xx. |
| `endpoints.annotations.annotationImage(id)``GET /api/annotations/annotations/{id}/image` | `CanvasEditor.loadImage` | egress | Per-annotation hashed image; preferred over the raw media for image annotations. |
| `endpoints.annotations.annotationEvents()``GET /api/annotations/annotations/events` (SSE) | `AnnotationsSidebar` | egress | Listens for `{ annotationId, mediaId, status }` events; filters client-side by `media.id`. |
| `endpoints.detect.media(mediaId)``POST /api/detect/{mediaId}` | `AnnotationsSidebar.handleDetect` | egress | Triggers AI inference — endpoint shape unverified vs `_docs/03_detections.md`. |
| `URL.createObjectURL(File)` | `MediaList.uploadFiles`, `AnnotationsPage.handleDownload` | browser API | Local-mode blob URLs are revoked on delete or unmount. |
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7); `STC-ARCH-02` forbids re-introducing literal `/api/...` strings in `src/`.
## Findings carried into Step 4 / 6 / 8
1. **`VideoPlayer.stepFrames` hardcodes `fps = 30`** — frame stepping is wrong for any other fps (most drone footage is 25 / 30 / 60). UI spec says "Frame duration = 1 / video FPS" (`_docs/ui_design/README.md`). Should read from `video.getVideoPlaybackQuality()` / metadata. Step 4.
@@ -16,25 +16,27 @@ Default-exported page component, no props. Mounts under `/dataset` in `App.tsx`.
- **Filters**: `fromDate`, `toDate`, `statusFilter` (`AnnotationStatus`), `selectedClassNum` (from `DetectionClasses`), `objectsOnly` (boolean), `search` (400 ms debounced via `useDebounce`).
- **Pagination**: client `page` state, server `pageSize` fixed at 20, `totalPages = ceil(totalCount / pageSize)`.
- **Selection**: `Set<annotationId>`. Plain click replaces; Ctrl+click toggles. Validate button appears when set is non-empty.
- **Validate**: `POST /api/annotations/dataset/bulk-status` with `{ annotationIds[], status: Validated }`.
- **Distribution**: lazy-loaded on tab switch via `GET /api/annotations/dataset/class-distribution`.
- **Editor tab** mounts `<CanvasEditor>` with a synthesised `Media` stub built from `editorAnnotation.mediaId` (most fields blank). Used so `CanvasEditor` can fetch the image via `/api/annotations/annotations/{id}/image`.
- **Validate**: `POST endpoints.annotations.datasetBulkStatus()` (= `/api/annotations/dataset/bulk-status`) with `{ annotationIds[], status: Validated }`.
- **Distribution**: lazy-loaded on tab switch via `GET endpoints.annotations.datasetClassDistribution()` (= `/api/annotations/dataset/class-distribution`).
- **Editor tab** mounts `<CanvasEditor>` with a synthesised `Media` stub built from `editorAnnotation.mediaId` (most fields blank). Used so `CanvasEditor` can fetch the image via `endpoints.annotations.annotationImage(id)` (= `/api/annotations/annotations/{id}/image`).
## Dependencies
- Internal: `api/client`, `useDebounce`, `useResizablePanel` (left panel 250 / 200400), `FlightContext`, `DetectionClasses`, `ConfirmDialog` (imported but not used here — see Findings), `features/annotations/CanvasEditor`, `types/index`.
- Internal: `api` (barrel — `api`, `endpoints`, since AZ-485 / F4 + AZ-486 / F7), `useDebounce`, `useResizablePanel` (left panel 250 / 200400), `FlightContext`, `DetectionClasses`, `ConfirmDialog` (imported but not used here — see Findings), `features/annotations/CanvasEditor`, `types/index`.
- External: `react`, `react-i18next`.
## External integrations
| Endpoint | Where | Notes |
| Builder → Path | Where | Notes |
|---|---|---|
| `GET /api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name`. |
| `GET /api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
| `POST /api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
| `GET /api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
| `GET /api/annotations/annotations/{id}/thumbnail` | grid tile `<img src=>` | One HTTP per visible tile. |
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor` (delegated) | When the editor is open. |
| `endpoints.annotations.dataset(qs)``/api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name` (caller builds `URLSearchParams.toString()`). |
| `endpoints.annotations.datasetItem(id)``/api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
| `endpoints.annotations.datasetBulkStatus()``/api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
| `endpoints.annotations.datasetClassDistribution()``/api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
| `endpoints.annotations.annotationThumbnail(id)``/api/annotations/annotations/{id}/thumbnail` | grid tile `<img src=>` | One HTTP per visible tile. |
| `endpoints.annotations.annotationImage(id)``/api/annotations/annotations/{id}/image` | `CanvasEditor` (delegated) | When the editor is open. |
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7).
Spec contract is in parent suite `_docs/09_dataset_explorer.md`.
@@ -5,10 +5,10 @@
## Scope
Owns the `/flights` route. Lets the user:
1. Browse / create / delete `Flight` rows (`POST/DELETE /api/flights/...`).
1. Browse / create / delete `Flight` rows via `endpoints.flights.collection()` (POST) and `endpoints.flights.flight(id)` (DELETE).
2. Plan a mission on a Leaflet map: add waypoints, draw work-area / no-go rectangles, edit altitude + purpose per point, see live total distance, time, battery %.
3. Toggle into GPS-Denied mode — opens an SSE stream `/api/flights/{id}/live-gps` (and the panel for orthophoto upload + correction once that backend wiring lands; today only the live-GPS readout is connected).
4. Save waypoints back to the Flights API (`/api/flights/{id}/waypoints`).
3. Toggle into GPS-Denied mode — opens an SSE stream `endpoints.flights.flightLiveGps(id)` (= `/api/flights/{id}/live-gps`) (and the panel for orthophoto upload + correction once that backend wiring lands; today only the live-GPS readout is connected).
4. Save waypoints back to the Flights API via `endpoints.flights.flightWaypoints(id)` and `endpoints.flights.flightWaypoint(flightId, waypointId)`.
5. Import / export the plan as JSON.
Currently handles only the planning surface; the gps-denied orthophoto upload / correction inputs in `_docs/ui_design/flights.html` are not yet implemented.
@@ -43,14 +43,14 @@ Currently handles only the planning surface; the gps-denied orthophoto upload /
## External integrations
| Endpoint / origin | Where | Direction | Notes |
| Builder → Path | Where | Direction | Notes |
|---|---|---|---|
| `GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
| `GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
| `POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
| `DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
| `POST/DELETE /api/flights/{id}/waypoints[/{wp}]` | `FlightsPage.handleSave` | egress | Delete-all-then-recreate, sequentially. |
| `GET /api/flights/{id}/live-gps` (SSE) | `FlightsPage` | egress | Open while in GPS mode + flight selected. |
| `endpoints.flights.aircrafts()``GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
| `endpoints.flights.flightWaypoints(id)``GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
| `endpoints.flights.collection()``POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
| `endpoints.flights.flight(id)``DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
| `endpoints.flights.flightWaypoints(id)` + `endpoints.flights.flightWaypoint(flightId, wp)``POST/DELETE /api/flights/{id}/waypoints[/{wp}]` | `FlightsPage.handleSave` | egress | Delete-all-then-recreate, sequentially. |
| `endpoints.flights.flightLiveGps(id)``GET /api/flights/{id}/live-gps` (SSE) | `FlightsPage` | egress | Open while in GPS mode + flight selected. |
| `https://api.openweathermap.org/...` | `flightPlanUtils.getWeatherData` | egress | Direct browser→3rd-party. **Hardcoded API key.** See Findings. |
| `tile.openstreetmap.org` (`TILE_URLS.classic`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
| `server.arcgisonline.com/.../World_Imagery` (`TILE_URLS.satellite`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
@@ -86,7 +86,8 @@ These are the real findings; the per-module rationale is in git history of the d
23. **`handleImport` silently drops the file picker** if the user cancels (`if (!file) return`) — fine. But `handleJsonSave`'s catch uses `alert(...)` for a UX-grade error — replace with the project's modal/toast pattern in Step 4.
24. **`MapPoint` popup recomputes the marker DOM offset on every drag move** to choose dx/dy for the moving-point indicator. Acceptable, but the `(marker as unknown as { _icon: HTMLElement })._icon` cast leaks Leaflet internals.
25. **`DrawControl` registers global `mousedown`/`mousemove`/`mouseup` on the map** while a draw mode is active and disables `map.dragging` for the duration — fine, but no Esc-to-cancel mid-draw.
26. **`FlightContext` ceiling**: `FlightsPage` reads `flights` from `useFlight()` which fetches with `pageSize=1000` (already flagged in `FlightContext` doc). Won't surface here, but `selectFlight` is fire-and-forget — if the PUT to `/api/flights/select` fails the next page reload reverts the choice without notice.
26. **`FlightContext` ceiling**: `FlightsPage` reads `flights` from `useFlight()` which fetches with `pageSize=1000` (already flagged in `FlightContext` doc). Won't surface here, but `selectFlight` is fire-and-forget — if the PUT to `endpoints.annotations.settingsUser()` (= `/api/annotations/settings/user`) fails the next page reload reverts the choice without notice. (Note: the underlying call goes to the annotations settings store, not a hypothetical `/api/flights/select`; see `src__components__FlightContext.md` for the actual PUT path.)
27. **Path builders (since AZ-486 / F7)**: every callsite in this page family now imports `endpoints` from `../../api` (barrel). The wire contract (the path strings) is unchanged; only the JS source surface migrated. Static gate `STC-ARCH-02` forbids re-introducing literal `/api/flights/...` strings.
## What's intentionally NOT here
@@ -29,18 +29,20 @@ No props.
- **State**:
- `system: SystemSettings | null` — loaded from
`GET /api/annotations/settings/system`. `null` until the GET
resolves; the panel does not render until then (`{system && (...)}`).
`GET endpoints.annotations.settingsSystem()` (= `/api/annotations/settings/system`).
`null` until the GET resolves; the panel does not render until
then (`{system && (...)}`).
- `dirs: DirectorySettings | null` — analogous, from
`GET /api/annotations/settings/directories`.
- `aircrafts: Aircraft[]` — from `GET /api/flights/aircrafts`.
`GET endpoints.annotations.settingsDirectories()` (= `/api/annotations/settings/directories`).
- `aircrafts: Aircraft[]` — from `GET endpoints.flights.aircrafts()`
(= `/api/flights/aircrafts`).
- `saving: boolean` — disables the two Save buttons during a PUT.
- **Bootstrap effect** (`useEffect([])`):
```ts
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
```
Three independent calls, all silently swallowed on error. Empty UI
@@ -48,7 +50,7 @@ No props.
- **`saveSystem()`**:
1. Guard: `if (!system) return`.
2. `setSaving(true)`.
3. `await api.put('/api/annotations/settings/system', system)`.
3. `await api.put(endpoints.annotations.settingsSystem(), system)`.
4. `setSaving(false)`.
No optimistic update needed (the PUT body **is** the local state).
@@ -56,10 +58,10 @@ No props.
path is missing**: a thrown PUT leaves `saving: true` permanently
(no `try/finally`). Flag for Step 4.
- **`saveDirs()`** — analogous against
`PUT /api/annotations/settings/directories`. Same missing
`PUT endpoints.annotations.settingsDirectories()`. Same missing
`try/finally` issue.
- **`handleToggleDefault(a)`** — duplicate of the same handler in
`AdminPage`: `PATCH /api/flights/aircrafts/${a.id}` with
`AdminPage`: `PATCH endpoints.flights.aircraft(a.id)` with
`{ isDefault: !a.isDefault }` then optimistic local flip. Two copies
of the same logic in two pages — extract to a shared helper or to
`FlightContext` in Step 8 (the legacy WPF had a single
@@ -79,7 +81,7 @@ No props.
## Dependencies
- **Internal**:
- `../../api/client` — `api`.
- `../../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
- `../../types` — `SystemSettings`, `DirectorySettings`, `Aircraft`.
- **External**: `react` (`useState`, `useEffect`),
`react-i18next` (`useTranslation`).
@@ -117,16 +119,16 @@ No props.
## External integrations
| Method | Path | Purpose |
| Method | Builder → Path | Purpose |
|---|---|---|
| `GET` | `/api/annotations/settings/system` | Load tenant config |
| `PUT` | `/api/annotations/settings/system` | Save tenant config |
| `GET` | `/api/annotations/settings/directories` | Load directory paths |
| `PUT` | `/api/annotations/settings/directories` | Save directory paths |
| `GET` | `/api/flights/aircrafts` | Load aircraft list |
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
| `GET` | `endpoints.annotations.settingsSystem()` → `/api/annotations/settings/system` | Load tenant config |
| `PUT` | `endpoints.annotations.settingsSystem()` → `/api/annotations/settings/system` | Save tenant config |
| `GET` | `endpoints.annotations.settingsDirectories()` → `/api/annotations/settings/directories` | Load directory paths |
| `PUT` | `endpoints.annotations.settingsDirectories()` → `/api/annotations/settings/directories` | Save directory paths |
| `GET` | `endpoints.flights.aircrafts()` → `/api/flights/aircrafts` | Load aircraft list |
| `PATCH` | `endpoints.flights.aircraft(id)` → `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
Routed by `nginx.conf` to `annotations/` and `flights/` backends.
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7). Routed by `nginx.conf` to `annotations/` and `flights/` backends.
## Security