Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements

This commit is contained in:
2026-05-08 00:00:56 +12:00
parent ebee72d4df
commit 1533b5aa9b
29 changed files with 1851 additions and 520 deletions
+95 -10
View File
@@ -14,6 +14,7 @@ import {
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
DashboardSummary,
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
@@ -125,6 +126,62 @@ async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none',
}
}
// 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();
}
async function request<T>(
path: string,
options: RequestInit,
@@ -155,6 +212,12 @@ async function request<T>(
throw new Error(message);
}
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);
@@ -162,13 +225,13 @@ async function request<T>(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
@@ -186,19 +249,41 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
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),
clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
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
),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
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),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',