[AZ-486] F7 endpoint builders + STC-ARCH-02 (cycle 1 close)

Single source of truth for every /api/<service>/... URL the UI talks to:
src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel.
Migrates 13 production callsites in admin / annotations / flights /
settings / dataset / auth / api-client / FlightContext / DetectionClasses
to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals
in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh)
that fails any new hardcoded /api/<service>/ literal in src/ outside
endpoints.ts and *.test.tsx? files.

Tests: +36 contract assertions in src/api/endpoints.test.ts (every
builder, character-identical), +6 STC-ARCH-02 architecture cases in
tests/architecture_imports.test.ts (single / double / template literal
fail paths, *.test.* exemption, line-comment skip, migrated codebase
pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new,
0 regressions. Static profile 31 / 31 PASS.

Closes architecture baseline finding F7. Cycle 1 of Phase B closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 23:03:45 +03:00
parent 23746ec61d
commit 8a461a2051
23 changed files with 777 additions and 127 deletions
+11 -11
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api'
import { api, endpoints } from '../../api'
import { ConfirmDialog } from '../../components'
import type { DetectionClass, Aircraft, User } from '../../types'
@@ -14,41 +14,41 @@ export default function AdminPage() {
const [deactivateId, setDeactivateId] = useState<string | null>(null)
useEffect(() => {
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(() => {})
}, [])
const handleAddClass = async () => {
if (!newClass.name) return
await api.post('/api/admin/classes', newClass)
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
await api.post(endpoints.admin.classes(), newClass)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
}
const handleDeleteClass = async (id: number) => {
await api.delete(`/api/admin/classes/${id}`)
await api.delete(endpoints.admin.class(id))
setClasses(prev => prev.filter(c => c.id !== id))
}
const handleAddUser = async () => {
if (!newUser.email || !newUser.password) return
await api.post('/api/admin/users', newUser)
const updated = await api.get<User[]>('/api/admin/users')
await api.post(endpoints.admin.users(), newUser)
const updated = await api.get<User[]>(endpoints.admin.users())
setUsers(updated)
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
}
const handleDeactivate = async () => {
if (!deactivateId) return
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
setDeactivateId(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useResizablePanel } from '../../hooks'
import { api } from '../../api'
import { api, endpoints } from '../../api'
import MediaList from './MediaList'
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
@@ -36,9 +36,9 @@ export default function AnnotationsPage() {
if (!selectedMedia.path.startsWith('blob:')) {
try {
await api.post('/api/annotations/annotations', body)
await api.post(endpoints.annotations.annotations(), body)
const res = await api.get<{ items: AnnotationListItem[] }>(
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
endpoints.annotations.annotationsByMedia(selectedMedia.id),
)
setAnnotations(res.items)
return
@@ -96,7 +96,7 @@ export default function AnnotationsPage() {
img.crossOrigin = 'anonymous'
img.src = selectedMedia.path.startsWith('blob:')
? selectedMedia.path
: `/api/annotations/media/${selectedMedia.id}/file`
: endpoints.annotations.mediaFile(selectedMedia.id)
await new Promise(res => { img.onload = res; img.onerror = res })
w = img.naturalWidth
h = img.naturalHeight
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api, createSSE } from '../../api'
import { api, createSSE, endpoints } from '../../api'
import { getClassColor } from './classColors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
@@ -21,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
useEffect(() => {
if (!media) return
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
if (event.mediaId === media.id) {
api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
endpoints.annotations.annotationsByMedia(media.id),
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
}
})
@@ -35,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
setDetecting(true)
setDetectLog(['Starting AI detection...'])
try {
await api.post(`/api/detect/${media.id}`)
await api.post(endpoints.detect.media(media.id))
setDetectLog(prev => [...prev, 'Detection complete.'])
} catch (e: any) {
setDetectLog(prev => [...prev, `Error: ${e.message}`])
+3 -2
View File
@@ -1,4 +1,5 @@
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { endpoints } from '../../api'
import { MediaType } from '../../types'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
@@ -76,11 +77,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
const img = new Image()
img.crossOrigin = 'anonymous'
if (annotation && !media.path.startsWith('blob:')) {
img.src = `/api/annotations/annotations/${annotation.id}/image`
img.src = endpoints.annotations.annotationImage(annotation.id)
} else if (media.path.startsWith('blob:')) {
img.src = media.path
} else {
img.src = `/api/annotations/media/${media.id}/file`
img.src = endpoints.annotations.mediaFile(media.id)
}
img.onload = () => {
imgRef.current = img
+5 -5
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { useFlight, ConfirmDialog } from '../../components'
import { api } from '../../api'
import { api, endpoints } from '../../api'
import { useDebounce } from '../../hooks'
import { MediaType } from '../../types'
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
@@ -27,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
if (selectedFlight) params.set('flightId', selectedFlight.id)
if (debouncedFilter) params.set('name', debouncedFilter)
try {
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
setMedia(prev => {
// Keep local-only (blob URL) entries, merge with backend entries
const local = prev.filter(m => m.path.startsWith('blob:'))
@@ -55,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
}
try {
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
endpoints.annotations.annotationsByMedia(m.id),
)
onAnnotationsLoaded(res.items)
} catch {
@@ -72,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
setDeleteId(null)
return
}
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
setDeleteId(null)
fetchMedia()
}
@@ -87,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
const form = new FormData()
form.append('waypointId', '')
for (const file of arr) form.append('files', file)
await api.upload('/api/annotations/media/batch', form)
await api.upload(endpoints.annotations.mediaBatch(), form)
fetchMedia()
return
} catch {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
import { endpoints } from '../../api'
import type { Media } from '../../types'
interface Props {
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
const videoUrl = media.path.startsWith('blob:')
? media.path
: `/api/annotations/media/${media.id}/file`
: endpoints.annotations.mediaFile(media.id)
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
+6 -6
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api'
import { api, endpoints } from '../../api'
import { useDebounce, useResizablePanel } from '../../hooks'
import { useFlight, DetectionClasses, ConfirmDialog } from '../../components'
import CanvasEditor from '../annotations/CanvasEditor'
@@ -42,7 +42,7 @@ export default function DatasetPage() {
if (objectsOnly) params.set('hasDetections', 'true')
if (debouncedSearch) params.set('name', debouncedSearch)
try {
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
setItems(res.items)
setTotalCount(res.totalCount)
} catch {}
@@ -52,7 +52,7 @@ export default function DatasetPage() {
const handleDoubleClick = async (item: DatasetItem) => {
try {
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(item.annotationId))
setEditorAnnotation(ann)
setEditorDetections(ann.detections)
setTab('editor')
@@ -61,7 +61,7 @@ export default function DatasetPage() {
const handleValidate = async () => {
if (selectedIds.size === 0) return
await api.post('/api/annotations/dataset/bulk-status', {
await api.post(endpoints.annotations.datasetBulkStatus(), {
annotationIds: Array.from(selectedIds),
status: AnnotationStatus.Validated,
})
@@ -71,7 +71,7 @@ export default function DatasetPage() {
const loadDistribution = useCallback(async () => {
try {
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
setDistribution(data)
} catch {}
}, [])
@@ -180,7 +180,7 @@ export default function DatasetPage() {
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
>
<img
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
src={endpoints.annotations.annotationThumbnail(item.annotationId)}
alt={item.imageName}
className="w-full h-32 object-cover bg-az-bg"
loading="lazy"
+9 -9
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import { useFlight, ConfirmDialog } from '../../components'
import { api, createSSE } from '../../api'
import { api, createSSE, endpoints } from '../../api'
import FlightListSidebar from './FlightListSidebar'
import FlightParamsPanel from './FlightParamsPanel'
import FlightMap from './FlightMap'
@@ -38,7 +38,7 @@ export default function FlightsPage() {
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
useEffect(() => {
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
setAircraft(getMockAircraftParams())
navigator.geolocation.getCurrentPosition(
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
@@ -48,7 +48,7 @@ export default function FlightsPage() {
useEffect(() => {
if (!selectedFlight) { setPoints([]); return }
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
.then(wps => {
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
id: wp.id,
@@ -62,7 +62,7 @@ export default function FlightsPage() {
useEffect(() => {
if (!selectedFlight || mode !== 'gps') return
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
}, [selectedFlight, mode])
useEffect(() => {
@@ -71,12 +71,12 @@ export default function FlightsPage() {
}, [points, aircraft, initialAltitude])
const handleCreateFlight = async (name: string) => {
await api.post('/api/flights', { name })
await api.post(endpoints.flights.collection(), { name })
refreshFlights()
}
const handleDeleteFlight = async () => {
if (!deleteId) return
await api.delete(`/api/flights/${deleteId}`)
await api.delete(endpoints.flights.flight(deleteId))
if (selectedFlight?.id === deleteId) selectFlight(null)
setDeleteId(null)
refreshFlights()
@@ -199,12 +199,12 @@ export default function FlightsPage() {
const handleSave = async () => {
if (!selectedFlight) return
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
for (const wp of existing) {
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
}
for (let i = 0; i < points.length; i++) {
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
name: `Point ${i + 1}`,
latitude: points[i].position.lat,
longitude: points[i].position.lng,
+7 -7
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api'
import { api, endpoints } from '../../api'
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
export default function SettingsPage() {
@@ -11,27 +11,27 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false)
useEffect(() => {
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(() => {})
}, [])
const saveSystem = async () => {
if (!system) return
setSaving(true)
await api.put('/api/annotations/settings/system', system)
await api.put(endpoints.annotations.settingsSystem(), system)
setSaving(false)
}
const saveDirs = async () => {
if (!dirs) return
setSaving(true)
await api.put('/api/annotations/settings/directories', dirs)
await api.put(endpoints.annotations.settingsDirectories(), dirs)
setSaving(false)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
}