175 lines
8.5 KiB
JavaScript
175 lines
8.5 KiB
JavaScript
// Same-origin: in dev, Vite proxies /api and /uploads to the API server
|
|
// (see vite.config.js); in production, nginx serves the built client and
|
|
// reverse-proxies the same paths to the Node server (see client/nginx.conf).
|
|
// Relative URLs are what make the app reachable from a phone/PWA over the LAN —
|
|
// an absolute http://localhost:3001 would resolve to the *visiting device*.
|
|
export const SERVER_BASE = ''
|
|
const API_BASE = `${SERVER_BASE}/api`
|
|
|
|
// Lets the auth gate react to a session expiring mid-session: any protected
|
|
// request that comes back 401 flips the app to the login screen instead of
|
|
// surfacing a generic error in each page.
|
|
let onUnauthorized = null
|
|
export function setUnauthorizedHandler(handler) {
|
|
onUnauthorized = handler
|
|
}
|
|
|
|
async function request(path, options = {}) {
|
|
const isJsonBody = typeof options.body === 'string'
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
headers: isJsonBody ? { 'Content-Type': 'application/json' } : undefined,
|
|
credentials: 'same-origin',
|
|
...options,
|
|
})
|
|
if (res.status === 401) {
|
|
if (onUnauthorized) onUnauthorized()
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body.error || 'Unauthorized')
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body.error || `Request failed with status ${res.status}`)
|
|
}
|
|
if (res.status === 204) return null
|
|
return res.json()
|
|
}
|
|
|
|
function withQuery(path, params = {}) {
|
|
const search = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
search.set(key, value)
|
|
}
|
|
}
|
|
const query = search.toString()
|
|
return query ? `${path}?${query}` : path
|
|
}
|
|
|
|
// Rabbits
|
|
export const getRabbits = (params) => request(withQuery('/rabbits', params))
|
|
export const searchRabbits = (params) => request(withQuery('/rabbits/search', params))
|
|
// Global quick-search bar: prefix-matches across farm/name/ear_number/color.
|
|
// `fields` is a comma-joined subset of those keys (omit for all). Each result
|
|
// carries `matched_fields` so the dropdown can show which filter it hit.
|
|
export const quickSearchRabbits = (params) =>
|
|
request(withQuery('/rabbits/quick-search', params))
|
|
export const getRabbit = (id) => request(`/rabbits/${id}`)
|
|
export const getRabbitAncestors = (id) => request(`/rabbits/${id}/ancestors`)
|
|
export const createRabbit = (data) =>
|
|
request('/rabbits', { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateRabbit = (id, data) =>
|
|
request(`/rabbits/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
// Which existing rabbit an Add Rabbit submission would top up (same matching
|
|
// as POST /rabbits), or null when it would be created new.
|
|
export const findExistingRabbit = (data) =>
|
|
request('/rabbits/find-existing', { method: 'POST', body: JSON.stringify(data) })
|
|
export const deleteRabbit = (id) => request(`/rabbits/${id}`, { method: 'DELETE' })
|
|
export const uploadRabbitPhoto = (id, file) => {
|
|
const formData = new FormData()
|
|
formData.append('photo', file)
|
|
return request(`/rabbits/${id}/photo`, { method: 'POST', body: formData })
|
|
}
|
|
|
|
// Pedigree scanning (AI vision on the server; see server/lib/pedigreeScan.ts).
|
|
// `getPedigreeScanStatus` → { enabled } decides whether the scan button shows.
|
|
export const getPedigreeScanStatus = () => request('/pedigree-scan')
|
|
export const scanPedigree = (imageBlob) => {
|
|
const formData = new FormData()
|
|
formData.append('image', imageBlob, 'pedigree.jpg')
|
|
return request('/pedigree-scan', { method: 'POST', body: formData })
|
|
}
|
|
|
|
// Litters
|
|
export const getLitters = () => request('/litters')
|
|
export const getLitter = (id) => request(`/litters/${id}`)
|
|
export const createLitter = (data) =>
|
|
request('/litters', { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateLitter = (id, data) =>
|
|
request(`/litters/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
export const deleteLitter = (id) => request(`/litters/${id}`, { method: 'DELETE' })
|
|
|
|
// Events — manually-logged timeline events (show / sale / other) for a rabbit
|
|
export const getEvents = (rabbitId) => request(withQuery('/events', { rabbit_id: rabbitId }))
|
|
export const createEvent = (data) =>
|
|
request('/events', { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateEvent = (id, data) =>
|
|
request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
export const deleteEvent = (id) => request(`/events/${id}`, { method: 'DELETE' })
|
|
|
|
// Shows — ARBA show listing (scraped + cached server-side). `getArbaShows`
|
|
// returns { fetched_at, stale, shows: [...] }; `refreshArbaShows` forces a
|
|
// server re-scrape and returns the same shape.
|
|
export const getArbaShows = () => request('/shows/arba')
|
|
export const refreshArbaShows = () => request('/shows/arba/refresh', { method: 'POST' })
|
|
|
|
// Show events — user-created groupings of ARBA shows. A `show` added to an event
|
|
// is a snapshot of the ARBA listing row (see routes/showEvents.ts).
|
|
export const getShowEvents = () => request('/shows/events')
|
|
export const getShowEvent = (id) => request(`/shows/events/${id}`)
|
|
export const createShowEvent = (data) =>
|
|
request('/shows/events', { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateShowEvent = (id, data) =>
|
|
request(`/shows/events/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
export const deleteShowEvent = (id) => request(`/shows/events/${id}`, { method: 'DELETE' })
|
|
export const addShowToEvent = (id, show) =>
|
|
request(`/shows/events/${id}/shows`, { method: 'POST', body: JSON.stringify(show) })
|
|
export const removeShowFromEvent = (id, showId) =>
|
|
request(`/shows/events/${id}/shows/${showId}`, { method: 'DELETE' })
|
|
|
|
// Show entries — a herd rabbit entered into a specific show within an event.
|
|
export const addShowEntry = (id, showId, data) =>
|
|
request(`/shows/events/${id}/shows/${showId}/entries`, { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateShowEntry = (id, showId, entryId, data) =>
|
|
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
export const removeShowEntry = (id, showId, entryId) =>
|
|
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}`, { method: 'DELETE' })
|
|
export const getEventReport = (id) => request(`/shows/events/${id}/report`)
|
|
|
|
// Judges (per show within an event) and placements (one per entry; PUT upserts).
|
|
export const addShowJudge = (id, showId, data) =>
|
|
request(`/shows/events/${id}/shows/${showId}/judges`, { method: 'POST', body: JSON.stringify(data) })
|
|
export const updateShowJudge = (id, showId, judgeId, data) =>
|
|
request(`/shows/events/${id}/shows/${showId}/judges/${judgeId}`, { method: 'PATCH', body: JSON.stringify(data) })
|
|
export const removeShowJudge = (id, showId, judgeId) =>
|
|
request(`/shows/events/${id}/shows/${showId}/judges/${judgeId}`, { method: 'DELETE' })
|
|
export const setShowEntryPlacement = (id, showId, entryId, data) =>
|
|
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}/placement`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
})
|
|
|
|
// Settings
|
|
export const getSettings = () => request('/settings')
|
|
export const updateSettings = (data) =>
|
|
request('/settings', { method: 'PATCH', body: JSON.stringify(data) })
|
|
|
|
// Stats
|
|
export const getStats = () => request('/stats')
|
|
|
|
// Export
|
|
export const exportDatabaseUrl = () => `${API_BASE}/export`
|
|
|
|
// Import — replaces ALL rabbits (and litters) with the contents of a CSV/JSON file
|
|
export const importData = (file) => {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
return request('/import', { method: 'POST', body: formData })
|
|
}
|
|
|
|
// Auth (user accounts — see memory-bank/FEATURE_User_Accounts.md)
|
|
export const getAuthStatus = () => request('/auth/status')
|
|
export const login = (username, password) =>
|
|
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
|
|
export const logout = () => request('/auth/logout', { method: 'POST' })
|
|
// First-run onboarding: creates the first account (409 once any user exists).
|
|
export const setupAccount = (username, password) =>
|
|
request('/auth/setup', { method: 'POST', body: JSON.stringify({ username, password }) })
|
|
|
|
// Users (shared herd — users are logins, not data owners)
|
|
export const getUsers = () => request('/users')
|
|
export const createUser = (username, password) =>
|
|
request('/users', { method: 'POST', body: JSON.stringify({ username, password }) })
|
|
export const resetUserPassword = (id, password) =>
|
|
request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify({ password }) })
|
|
export const deleteUser = (id) => request(`/users/${id}`, { method: 'DELETE' })
|