Files
mule-image/frontend/src/services/api.ts
dtoro 574d71371f refactor: strip AI pipeline to binary photo/other classifier
Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:27:17 +02:00

1043 lines
30 KiB
TypeScript

import axios from 'axios'
// Relative API base. In production the nginx in front of the SPA proxies
// /api/ to the backend container; in dev the vite server has the same
// proxy in vite.config.ts. Using a relative URL means requests are
// always same-origin, so the app works whether you hit it from
// localhost, a LAN IP, or a reverse proxy without any CORS dance.
const API_BASE_URL = '/api/v1'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// ── Auth interceptors ──────────────────────────────────────────────────
// Attach the stored JWT to every outgoing request.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// On 401 responses, attempt one silent token refresh. If that also
// fails, clear stored credentials so the AuthContext falls back to the
// login screen on its next render.
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
// Skip retry for auth endpoints themselves to avoid loops.
if (original.url?.startsWith('/auth/')) {
return Promise.reject(error)
}
original._retry = true
if (!isRefreshing) {
isRefreshing = true
// The refresh token lives in AuthContext memory, not in
// localStorage. The interceptor can't access it directly, so we
// rely on the AuthContext's scheduled refresh to keep the access
// token fresh. If the access token is truly expired and no
// refresh has happened, we just force a logout.
localStorage.removeItem('access_token')
isRefreshing = false
// Reject — AuthContext will detect the missing token and show login.
return Promise.reject(error)
}
// Another request is already refreshing — queue this one.
return new Promise((resolve) => {
refreshSubscribers.push((token: string) => {
original.headers.Authorization = `Bearer ${token}`
resolve(api(original))
})
})
},
)
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label.
export interface FolderTreeNode {
id: string
name: string
path: string
photo_count: number
/** User-set "hide from cross-cutting views" flag. When true, photos
* in this folder (and descendants) are excluded from All Photos,
* Map, Tags, People, Search and sidebar counts, but remain visible
* when the user navigates directly into the folder. */
is_hidden: boolean
children: FolderTreeNode[]
}
export const sourceFolders = {
list: async () => {
const response = await api.get('/folders')
return response.data
},
/** Recursive folder tree, one root per active source root. */
tree: async (): Promise<FolderTreeNode[]> => {
const response = await api.get('/folders/tree')
return response.data
},
scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`)
return response.data
},
/** Rename a folder. SourceRoot ids only update the display label;
* Folder ids actually move the directory on disk and update every
* descendant photo's filepath. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
/** Create a new sub-folder under an existing Folder. parent_id MUST
* be a Folder row id (not a SourceRoot id). */
create: async (parentId: string, name: string) => {
const response = await api.post('/folders', {
name,
parent_id: parentId,
})
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Toggle "hide from views" on a folder or source root. Hidden
* folders still index + thumbnail their photos, but those photos
* are excluded from cross-cutting views (All Photos, Map, Tags,
* People, Search, sidebar counts). Navigating directly into the
* folder still shows them. Propagates to every descendant folder. */
setHidden: async (folderId: string, hidden: boolean) => {
const response = await api.post(`/folders/${folderId}/hide`, {
hidden,
})
return response.data as {
id: string
name: string
path: string
is_hidden: boolean
}
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and
* rmtrees the directory — irreversible. */
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
const response = await api.delete(`/folders/${folderId}`, {
params: { mode },
})
return response.data as {
status: string
mode: string
discarded?: number
deleted_photos?: number
file_errors?: number
}
},
}
// Photos API
export const photos = {
list: async (params?: {
limit?: number
offset?: number
folder_id?: string
heap_id?: string
rating?: number
flag?: string
}) => {
const response = await api.get('/photos', { params })
return response.data
},
get: async (photoId: string) => {
const response = await api.get(`/photos/${photoId}`)
return response.data
},
/** Lightweight list of every photo with GPS coordinates, used by the
* Map view. Returns one tiny object per photo (id, lat, lon, taken_at)
* rather than the full photo payload — keeps responses small even on
* big libraries. */
mapPoints: async () => {
const response = await api.get('/photos/map')
return response.data as Array<{
id: string
latitude: number
longitude: number
taken_at: string | null
}>
},
update: async (photoId: string, data: {
filename?: string
rating?: number
user_title?: string | null
user_notes?: string | null
color_label?: string | null
is_picked?: boolean
is_discarded?: boolean
taken_at?: string
}) => {
const response = await api.patch(`/photos/${photoId}`, data)
return response.data
},
/** Bulk discard — matches the backend BulkAction schema. */
bulkDiscard: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'discard',
})
return response.data
},
/** Bulk restore from discarded. */
bulkRestore: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'restore',
})
return response.data
},
/** Bulk set rating (0-5). */
bulkSetRating: async (photoIds: string[], rating: number) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_rating',
value: rating,
})
return response.data
},
/** Bulk set color label (or null to clear). */
bulkSetColor: async (photoIds: string[], color: string | null) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_color',
value: color,
})
return response.data
},
/** Bulk set taken_at — one ISO datetime applied to every listed photo.
* Backend rewrites EXIF on disk per-photo and surfaces per-photo errors
* in the `errors` array so the UI can report a partial apply. */
bulkSetTakenAt: async (photoIds: string[], isoDatetime: string) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_taken_at',
value: isoDatetime,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** Bulk set taken_at with a per-photo map. Used by the "guess from folder"
* flow where every selected photo gets its own suggested date. */
bulkSetTakenAtMap: async (map: Record<string, string>) => {
const response = await api.post('/photos/bulk', {
ids: Object.keys(map),
action: 'set_taken_at_map',
value: map,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** Add the listed tags to every listed photo. Idempotent — re-adding
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'add_tags',
value: tagIds,
})
return response.data as { status: string; added: number }
},
/** Remove the listed tags from every listed photo. Removing a
* non-member is a no-op. Returns { removed: N }. */
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'remove_tags',
value: tagIds,
})
return response.data as { status: string; removed: number }
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/move', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
},
/** Copy photos into a target folder. Originals are unaffected; new
* rows are created with is_duplicate=true. */
copy: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/copy', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> }
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
const token = localStorage.getItem('access_token')
const qs = token ? `?token=${encodeURIComponent(token)}` : ''
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}${qs}`
},
getOriginalUrl: (photoId: string) => {
const token = localStorage.getItem('access_token')
const qs = token ? `?token=${encodeURIComponent(token)}` : ''
return `${API_BASE_URL}/photos/${photoId}/original${qs}`
},
/** Full-resolution display URL. Backend serves the original for web-safe
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
getProxyUrl: (photoId: string) => {
const token = localStorage.getItem('access_token')
const qs = token ? `?token=${encodeURIComponent(token)}` : ''
return `${API_BASE_URL}/photos/${photoId}/proxy${qs}`
},
/** "On this day" memories — photos taken on this date in previous years. */
memories: async (): Promise<MemoriesResponse> => {
const response = await api.get('/photos/memories')
return response.data
},
}
// Library API
export type MediaType = 'photo' | 'raw' | 'heic' | 'video'
export interface ThumbnailStats {
total: number
pending: number
processing: number
completed: number
failed: number
by_media_type: Record<string, number>
}
export interface RegenerateResult {
status: string
matched: number
queued: number
cleared_dirs: number
file_errors: number
filters: {
media_types: MediaType[] | null
only_failed: boolean
}
}
export interface MissingStats {
would_delete?: number
deleted?: number
would_delete_folders?: number
deleted_folders?: number
skipped_unmounted: number
dry_run: boolean
}
export interface PruneResult extends MissingStats {
status?: string
message?: string
}
export interface WorkerInfo {
name: string
status: 'online' | 'unreachable'
active: number
reserved: number
scheduled: number
concurrency: number | null
processed: Record<string, number>
queues: string[]
active_tasks: Array<{
id: string
name: string
args: unknown
time_start: number | null
}>
}
export interface WorkerFailure {
photo_id: string
filename: string
media_type: string
error: string
updated_at: string | null
}
export interface WorkerStatus {
broker_ok: boolean
broker_error: string | null
inspect_error: string | null
workers: WorkerInfo[]
worker_count: number
queues: Record<string, number>
failures: {
total: number
recent: WorkerFailure[]
}
scan_errors: string[]
}
export interface PipelineStage {
key: string
label: string
done: number
total: number
hint: string
/** True when the stage legitimately runs on a subset of photos — e.g.
* GPS / tags / OCR / faces — so 100% coverage is never expected and
* the UI should not frame "missing" as a problem. */
partial?: boolean
/** True when the stage doesn't have a done/total progress semantic
* (e.g. face clusters, duplicate groups — those are output counts,
* not ratios). The UI renders a plain count instead of a bar. */
standalone?: boolean
}
export interface PipelineStats {
total_photos: number
total_images: number
stages: PipelineStage[]
}
export interface ScanStatus {
is_scanning: boolean
current_folder: string | null
processed_files: number
total_files: number
errors: string[]
}
export const library = {
scan: async () => {
const response = await api.post('/library/scan')
return response.data
},
scanStatus: async () => {
const response = await api.get('/library/scan/status')
return response.data
},
stats: async (scope?: 'global'): Promise<LibraryStats> => {
const response = await api.get('/library/stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Maintenance / admin actions surfaced via the Settings panel. */
maintenance: {
thumbnailStats: async (scope?: 'global'): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Reset on-disk thumbs and re-queue Celery generation. With no
* filters, every photo in the library is re-queued. */
regenerateThumbnails: async (
body: {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
} = {},
scope?: 'global',
): Promise<RegenerateResult> => {
const response = await api.post(
'/library/maintenance/regenerate-thumbnails',
body,
{ params: scope ? { scope } : undefined },
)
return response.data
},
/** Celery worker fleet diagnostics + recent task failures. */
workerStatus: async (scope?: 'global'): Promise<WorkerStatus> => {
const response = await api.get('/library/maintenance/worker-status', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Per-stage ingestion progress. */
pipelineStats: async (scope?: 'global'): Promise<PipelineStats> => {
const response = await api.get('/library/maintenance/pipeline-stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Dry-run count of photo rows whose files are no longer on disk
* (under a mounted source root). */
missingStats: async (): Promise<MissingStats> => {
const response = await api.get('/library/maintenance/missing-stats')
return response.data
},
/** Actually delete the orphaned photo rows. */
pruneMissing: async (): Promise<PruneResult> => {
const response = await api.post('/library/maintenance/prune-missing')
return response.data
},
/** Re-run the source-roots / folders / photos integrity cleanup that
* normally runs on backend startup. */
cleanup: async (): Promise<{ status: string; message?: string }> => {
const response = await api.post('/library/maintenance/cleanup')
return response.data
},
/** Recompute duplicate groups from current perceptual hashes.
* Idempotent — safe to fire repeatedly. */
regroupDuplicates: async (): Promise<{ status: string; message?: string }> => {
const response = await api.post('/library/maintenance/regroup-duplicates')
return response.data
},
/** Compute pHash for every photo currently missing one. One-shot
* recovery path for libraries that existed before the phash column
* was added. */
backfillPhashes: async (): Promise<{ status: string; message?: string }> => {
const response = await api.post('/library/maintenance/backfill-phashes')
return response.data
},
},
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
* Drives the grouped grid view in the Duplicates section. */
duplicates: {
groups: async (scope?: 'global'): Promise<DuplicateGroupsResponse> => {
const response = await api.get('/library/duplicates/groups', {
params: scope ? { scope } : undefined,
})
return response.data
},
},
}
// ── Duplicate groups ─────────────────────────────────────────────────────
export interface DuplicateGroupMember {
id: string
filename: string
taken_at: string | null
file_size: number | null
width: number | null
height: number | null
thumb_small: string | null
file_hash: string | null
folder_id: string | null
media_type: string
}
export interface DuplicateGroup {
group_id: string
member_count: number
/** "exact" iff every member shares the same SHA-256 (true byte
* duplicates that pHash also caught). "similar" otherwise. */
reason: 'exact' | 'similar'
members: DuplicateGroupMember[]
}
export interface DuplicateGroupsResponse {
groups: DuplicateGroup[]
total_groups: number
total_members: number
}
// ── Memories ("On this day") ────────────────────────────────────────────
export interface MemoryPhoto {
id: string
filename: string
taken_at: string
thumb_small: string | null
thumb_medium: string | null
media_type: string
width: number | null
height: number | null
rating: number
}
export interface MemoryGroup {
year: number
years_ago: number
photos: MemoryPhoto[]
}
export interface MemoriesResponse {
date: string
memories: MemoryGroup[]
}
export interface LibraryStats {
all_photos: number
rated: number
colored: number
with_gps: number
duplicates: number
discarded: number
needs_review: number
total_photos: number
total_videos: number
total_size: number
total_size_gb: number
source_dirs: string[]
}
// Heaps API
export interface Heap {
id: string
name: string
is_active: boolean
created_at: string
updated_at: string | null
photo_count: number
}
export const heaps = {
list: async (): Promise<Heap[]> => {
const response = await api.get('/heaps')
return response.data
},
create: async (name: string): Promise<Heap> => {
const response = await api.post('/heaps', { name })
return response.data
},
update: async (
heapId: string,
data: { name?: string; is_active?: boolean }
): Promise<Heap> => {
const response = await api.patch(`/heaps/${heapId}`, data)
return response.data
},
delete: async (heapId: string): Promise<void> => {
await api.delete(`/heaps/${heapId}`)
},
/** Duplicate a heap, copying its membership but never marking the new
* one as active. The new heap is named "{name} (copy)". */
duplicate: async (heapId: string): Promise<Heap> => {
const response = await api.post(`/heaps/${heapId}/duplicate`)
return response.data
},
/** Lightweight: just the photo ids in a heap, for client-side membership
* lookups (the basket affordance on thumbnails). */
photoIds: async (heapId: string): Promise<string[]> => {
const response = await api.get(`/heaps/${heapId}/photo_ids`)
return response.data
},
addPhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.post(`/heaps/${heapId}/photos`, {
photo_ids: photoIds,
})
return response.data
},
removePhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.delete(`/heaps/${heapId}/photos`, {
data: { photo_ids: photoIds },
})
return response.data
},
/** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. Optionally creates a subfolder
* inside the target by name. */
convert: async (
heapId: string,
body: {
target_id: string
mode: 'move' | 'copy'
delete_heap: boolean
subfolder_name?: string | null
}
) => {
const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as {
status: string
mode: 'move' | 'copy'
moved: number
copied: number
errors: Array<{ id: string; error: string }>
heap_deleted: boolean
}
},
}
// Upload API — single file per request so the browser can fan out many
// POSTs in parallel with per-file progress. For folder uploads the
// caller passes each File's webkitRelativePath so the backend can
// materialise the folder structure under the destination.
export const uploads = {
uploadFile: async (
file: File,
destinationFolderId: string,
opts: {
relativePath?: string
heapId?: string | null
onProgress?: (loadedBytes: number, totalBytes: number) => void
signal?: AbortSignal
} = {}
) => {
const form = new FormData()
form.append('file', file)
form.append('destination_folder_id', destinationFolderId)
if (opts.relativePath) form.append('relative_path', opts.relativePath)
if (opts.heapId) form.append('heap_id', opts.heapId)
const response = await api.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
signal: opts.signal,
onUploadProgress: (evt) => {
if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total)
},
})
return response.data as {
photo_id: string
filename: string
folder_id: string
folder_path: string
heap_id: string | null
}
},
}
// Download helpers — build a URL the browser can pull directly via an
// <a href>. The backend accepts `?token=` so an <a> works without a
// custom fetch + save-blob dance; the Authorization header is not
// settable on a plain link click.
export const downloads = {
folderUrl: (folderId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}`
},
heapUrl: (heapId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}`
},
trigger: (url: string) => {
// Kicking off a download via a transient <a> click keeps the
// browser in charge of the file dialog + progress indicator. We
// use target=_blank so the current SPA route isn't replaced if
// the server returns an error mid-stream.
const a = document.createElement('a')
a.href = url
a.rel = 'noopener'
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
}
// Sharing API
export interface SharedHeap {
id: string
name: string
owner_username: string
permission: 'read' | 'write'
photo_count: number
}
export interface SharedFolder {
id: string
name: string
folder_type: 'folder' | 'source_root'
owner_username: string
permission: 'read' | 'write'
photo_count: number
}
export interface ShareInfo {
id: string
shared_with_id: string
shared_with_username: string
permission: string
created_at: string
}
export const sharing = {
// Heap shares
shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => {
const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission })
return response.data
},
heapShares: async (heapId: string): Promise<ShareInfo[]> => {
const response = await api.get(`/sharing/heaps/${heapId}`)
return response.data
},
revokeHeapShare: async (heapId: string, shareId: string) => {
await api.delete(`/sharing/heaps/${heapId}/${shareId}`)
},
sharedHeaps: async (): Promise<SharedHeap[]> => {
const response = await api.get('/sharing/heaps/shared-with-me')
return response.data
},
// Folder shares
shareFolder: async (folderId: string, username: string, permission: 'read' | 'write' = 'read') => {
const response = await api.post(`/sharing/folders/${folderId}`, { username, permission })
return response.data
},
folderShares: async (folderId: string): Promise<ShareInfo[]> => {
const response = await api.get(`/sharing/folders/${folderId}`)
return response.data
},
revokeFolderShare: async (folderId: string, shareId: string) => {
await api.delete(`/sharing/folders/${folderId}/${shareId}`)
},
sharedFolders: async (): Promise<SharedFolder[]> => {
const response = await api.get('/sharing/folders/shared-with-me')
return response.data
},
}
// Tags API
export type TagKind = 'user' | 'content_type'
export interface Tag {
id: string
name: string
color: string | null
kind: TagKind
source: string | null
representative_photo_id: string | null
photo_count: number
}
export const tags = {
list: async (kind?: TagKind): Promise<Tag[]> => {
const params = kind ? { kind } : undefined
const response = await api.get('/tags', { params })
return response.data
},
create: async (name: string, color?: string, kind: TagKind = 'user'): Promise<Tag> => {
const response = await api.post('/tags', { name, color, kind })
return response.data
},
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string): Promise<void> => {
await api.delete(`/tags/${tagId}`)
},
/** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
return response.data
},
/** Remove a tag from a photo. */
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
await api.delete(`/photos/${photoId}/tags/${tagId}`)
},
}
// Search API — hybrid FTS + semantic search
export interface SearchResult {
id: string
filename: string
filepath: string
media_type: string
width: number
height: number
taken_at: string | null
rating: number
color_label: string | null
thumb_small: string
thumb_medium: string
score: number
}
export const search = {
query: async (params: {
q?: string
filters?: {
tag_ids?: string[]
date_from?: string
date_to?: string
}
limit?: number
offset?: number
}): Promise<{ results: SearchResult[]; total: number }> => {
const response = await api.post('/photos/search', params)
return response.data
},
}
// Discard API
export const discard = {
list: async () => {
const response = await api.get('/discard')
return response.data
},
restore: async (photoIds: string[]) => {
const response = await api.post('/discard/restore', {
photo_ids: photoIds,
})
return response.data
},
empty: async () => {
const response = await api.delete('/discard/empty')
return response.data
},
/** Permanently delete a specific subset of discarded photos. The backend
* silently skips ids that aren't in the pile, so this can never bypass
* the soft-delete safety net. */
deletePermanent: async (photoIds: string[]) => {
const response = await api.delete('/discard', {
data: { photo_ids: photoIds },
})
return response.data
},
}
// Admin API — user management (admin only)
export interface AdminUser {
id: string
username: string
email: string | null
role: 'admin' | 'user'
is_active: boolean
media_path: string
created_at: string | null
photo_count: number
}
export const admin = {
listUsers: async (): Promise<{ users: AdminUser[]; total: number }> => {
const response = await api.get('/admin/users')
return response.data
},
createUser: async (data: {
username: string
password: string
role: string
}): Promise<AdminUser> => {
const response = await api.post('/admin/users', data)
return response.data
},
updateUser: async (
userId: string,
data: { role?: string; is_active?: boolean; new_password?: string },
): Promise<AdminUser> => {
const response = await api.patch(`/admin/users/${userId}`, data)
return response.data
},
deleteUser: async (userId: string): Promise<{ status: string }> => {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
// --- AI / vision feature flags + manual pipeline triggers -----------
listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.get('/admin/feature-flags')
return response.data
},
/** Set ``value`` to toggle; pass ``null`` to clear the override and
* fall back to the YAML default. */
setFeatureFlag: async (
name: string,
value: boolean | null,
): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value })
return response.data
},
triggerAiBackfill: async (body: {
limit?: number | null
}): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/backfill', body)
return response.data
},
triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/rescan')
return response.data
},
}
export interface FeatureFlagState {
effective: boolean
default: boolean
overridden: boolean
}
export type FeatureFlagSnapshot = Record<string, FeatureFlagState>
export type FeaturesMap = Record<string, boolean>
// Public read of effective feature flags. Available to any signed-in
// user so the frontend can hide sections that depend on a disabled
// pipeline stage (e.g. People when faces are off).
export const features = {
list: async (): Promise<FeaturesMap> => {
const response = await api.get('/features')
return response.data
},
}
export default api