Files
data-entry-app/frontend/src/lib/api.ts
T

359 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-25 20:43:37 +12:00
import { env } from '$env/dynamic/public';
2026-04-25 22:51:36 +12:00
import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
2026-04-29 23:05:27 +12:00
mockMixCalculatorOptions,
mockMixCalculatorSessions,
2026-04-25 22:51:36 +12:00
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
2026-04-25 20:43:37 +12:00
import type {
2026-04-25 22:51:36 +12:00
ClientAccessAccount,
ClientAccessPowerBiExport,
DashboardSummary,
2026-04-25 22:51:36 +12:00
ClientUserCreateInput,
ClientUserModulePermission,
2026-04-25 22:51:36 +12:00
ClientUserUpdateInput,
2026-04-25 20:43:37 +12:00
LoginResponse,
2026-04-29 23:05:27 +12:00
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession,
MixCalculatorUpdateInput,
2026-04-25 22:51:36 +12:00
Mix,
MixCreateInput,
MixIngredientUpdateInput,
MixUpdateInput,
2026-04-25 20:43:37 +12:00
Product,
ProductCostBreakdown,
RawMaterial,
RawMaterialCreateInput,
RawMaterialPriceCreateInput,
Scenario
} from '$lib/types';
2026-04-25 22:51:36 +12:00
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
2026-04-25 20:43:37 +12:00
2026-04-27 21:53:36 +12:00
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
2026-04-25 20:43:37 +12:00
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
2026-04-27 21:53:36 +12:00
type ApiFetch = typeof fetch;
function getApiBaseUrl() {
2026-04-29 23:53:51 +12:00
if (!browser) {
const internalBaseUrl = typeof process !== 'undefined' ? process.env.INTERNAL_API_BASE_URL?.trim() : '';
if (internalBaseUrl) {
return internalBaseUrl.replace(/\/+$/, '');
}
}
2026-04-27 21:53:36 +12:00
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
}
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
}
function buildApiUrl(path: string) {
return `${getApiBaseUrl()}${path}`;
}
2026-04-25 22:51:36 +12:00
function getToken(auth: AuthMode) {
if (!browser) {
return null;
}
if (auth === 'client') {
return getStoredClientSession()?.token ?? null;
}
if (auth === 'admin') {
return getStoredAdminSession()?.token ?? null;
}
if (auth === 'manager') {
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
}
2026-04-25 22:51:36 +12:00
return null;
}
function normalizeRequestError(error: unknown) {
if (error instanceof Error) {
const message = error.message.trim();
const isNetworkFetchFailure =
/failed to fetch|fetch failed|networkerror when attempting to fetch resource|load failed|network request failed/i.test(
message
) || error.name === 'NetworkError';
if (isNetworkFetchFailure) {
return new Error(BACKEND_UNAVAILABLE_MESSAGE);
}
return error;
}
return new Error('An unexpected error occurred while contacting the server.');
}
2026-04-27 21:53:36 +12:00
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
2026-04-25 20:43:37 +12:00
try {
2026-04-25 22:51:36 +12:00
const token = getToken(auth);
2026-04-27 21:53:36 +12:00
const response = await fetcher(buildApiUrl(path), {
2026-04-25 22:51:36 +12:00
headers: token ? { Authorization: `Bearer ${token}` } : undefined
});
2026-04-25 20:43:37 +12:00
if (!response.ok) {
2026-04-25 22:51:36 +12:00
if (auth !== 'none') {
throw new Error(response.statusText || 'Unauthorized');
}
2026-04-25 20:43:37 +12:00
return fallback;
}
return (await response.json()) as T;
2026-04-25 22:51:36 +12:00
} catch (error) {
if (auth !== 'none') {
throw normalizeRequestError(error);
2026-04-25 22:51:36 +12:00
}
2026-04-25 20:43:37 +12:00
return fallback;
}
}
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
// includes the auth-mode and last 8 chars of the bearer token so different
// sessions can't read each other's entries. Any mutation calls clearApiCache()
// to invalidate. Memory footprint is bounded by entries naturally aging out.
type CacheEntry = { value: unknown; expiresAt: number };
const responseCache = new Map<string, CacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
}
async function cachedFetchJson<T>(
path: string,
fallback: T,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher);
}
const key = makeCacheKey(path, auth);
const now = Date.now();
const cached = responseCache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value as T;
}
// De-duplicate concurrent callers (e.g. two effects firing the same load).
const existing = inflightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
})
.finally(() => {
inflightRequests.delete(key);
});
inflightRequests.set(key, promise);
return promise;
}
export function clearApiCache() {
responseCache.clear();
inflightRequests.clear();
}
2026-04-27 21:53:36 +12:00
async function request<T>(
path: string,
options: RequestInit,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {})
},
...options
});
2026-04-25 20:43:37 +12:00
if (!response.ok) {
let message = 'Request failed';
2026-04-25 20:43:37 +12:00
try {
const body = (await response.json()) as { detail?: string };
message = body.detail ?? message;
} catch {
message = response.statusText || message;
}
throw new Error(message);
2026-04-25 20:43:37 +12:00
}
const isMutation = !!options.method && options.method.toUpperCase() !== 'GET';
if (isMutation && browser) {
// Mutations invalidate cached reads — keeps Dashboard / lists fresh
// after the user creates or updates anything.
clearApiCache();
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
2026-04-25 20:43:37 +12:00
}
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
2026-04-27 21:53:36 +12:00
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
2026-04-29 23:05:27 +12:00
mixCalculatorOptions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
2026-04-29 23:05:27 +12:00
mixCalculatorSessions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
2026-04-29 23:05:27 +12:00
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorSession>('/api/mix-calculator', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
2026-04-27 21:53:36 +12:00
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
2026-04-27 21:53:36 +12:00
clientAccessExport: (fetcher?: ApiFetch) =>
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>(
'/api/dashboard/summary',
{
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
},
'client',
fetcher
),
2026-04-25 22:51:36 +12:00
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
2026-04-25 20:43:37 +12:00
method: 'POST',
body: JSON.stringify({ email, password })
}),
// Internal Hunter Stock Feeds login. Returns the same LoginResponse shape
// (with `permissions` populated) so the existing client-session store can
// consume it directly.
internalLogin: (email: string, password: string) =>
request<LoginResponse>('/api/access/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
internalSession: (fetcher?: ApiFetch) =>
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
2026-05-08 09:06:14 +12:00
updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) =>
request<LoginResponse>('/api/access/me', {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
2026-04-25 22:51:36 +12:00
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
2026-04-25 22:51:36 +12:00
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
createMix: (payload: MixCreateInput) =>
request<Mix>('/api/mixes', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMix: (mixId: number, payload: MixUpdateInput) =>
request<Mix>(`/api/mixes/${mixId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
addMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<Mix>(`/api/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteMixIngredient: (mixId: number, ingredientId: number) =>
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
2026-04-25 20:43:37 +12:00
createRawMaterial: (payload: RawMaterialCreateInput) =>
request<RawMaterial>('/api/raw-materials', {
method: 'POST',
body: JSON.stringify(payload)
2026-04-25 22:51:36 +12:00
}, 'client'),
2026-04-25 20:43:37 +12:00
addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) =>
request(`/api/raw-materials/${rawMaterialId}/prices`, {
method: 'POST',
body: JSON.stringify(payload)
2026-04-25 22:51:36 +12:00
}, 'client'),
createClientUser: (payload: ClientUserCreateInput) =>
request<ClientAccessAccount>('/api/client-access/users', {
method: 'POST',
body: JSON.stringify(payload)
}, 'manager'),
2026-04-25 22:51:36 +12:00
updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager'),
updateClientUserModulePermission: (userId: number, permission: Pick<ClientUserModulePermission, 'module_key'>, payload: { access_level: string }) =>
request<ClientAccessAccount>(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager'),
2026-04-25 22:51:36 +12:00
updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager')
2026-04-25 20:43:37 +12:00
};