Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements
This commit is contained in:
+95
-10
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user