Files
ERP-core/Frontend/erp-system/lib/api-client.ts
T
Dhananjaya99 e7b1f270a5 feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
2026-10-18 12:46:31 +05:30

166 lines
7.2 KiB
TypeScript

// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1). Per-endpoint
// methods live in lib/api/*.ts — no scattered fetch() in components.
//
// Transport: the API is reached SAME-ORIGIN through the Next rewrite in next.config.ts
// (/api/* -> BACKEND_ORIGIN). That is why API_BASE is relative and why no CORS setup
// exists on the backend: there is no cross-origin request to allow.
//
// Auth: the session is an httpOnly `erp_at` cookie issued by POST /auth/login (docs/11
// §2.0) — there is no bearer token to read, and by design JS cannot read the cookie.
// `credentials: "include"` is what actually authenticates every call.
import { ApiResult, ProblemDetails } from "@/types/common"
const API_BASE = "/api/v1"
/** Fired on window whenever the API answers 403, so the auth context can re-read the person's permissions. */
export const FORBIDDEN_EVENT = "erp:forbidden"
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
export class ApiError extends Error {
status: number
code?: string
detail?: string
errors?: Record<string, string[]>
traceId?: string
constructor(problem: ProblemDetails) {
super(problem.title || "Request failed")
this.status = problem.status
this.code = problem.code
this.detail = problem.detail
this.errors = problem.errors
this.traceId = problem.traceId
}
}
export interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown
/** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). Echo the ETag verbatim, quotes included. */
ifMatch?: string
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
idempotencyKey?: string
/** Sent as X-XSRF-TOKEN. Now sent automatically on every POST/PUT/PATCH/DELETE (the API checks all of them); kept so a caller can force it. */
csrf?: boolean
}
/** Reads the non-httpOnly CSRF cookie. It rotates on every session write, so read it per call. */
export function readCsrfToken(): string | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// erp_at is short-lived; erp_rt (a separate, refresh-route-scoped cookie) outlives it. Before
// this, an expired erp_at surfaced as a bare 401 on whatever the user happened to click next
// (confirming an invoice, recording a payment, ...) with no attempt to renew the session — this
// dedupes concurrent 401s onto one /auth/refresh-token call so every caller gets one retry.
let refreshPromise: Promise<boolean> | null = null
function refreshSession(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = fetch(`${API_BASE}/auth/refresh-token`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: "{}",
})
.then((res) => res.ok)
.catch(() => false)
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
function redirectToLogin() {
if (typeof window === "undefined") return
const next = `${window.location.pathname}${window.location.search}`
window.location.href = `/login?next=${encodeURIComponent(next)}`
}
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
// The API double-submit-checks every state-changing request that rides on the session cookie, so send the token
// on all of them (it is just echoing a cookie of our own origin).
const method = (rest.method ?? "GET").toUpperCase()
const stateChanging = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"
const csrfToken = csrf || stateChanging ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
const finalHeaders: Record<string, string> = {
Accept: "application/json",
...(body !== undefined && !isFormData ? { "Content-Type": "application/json" } : {}),
...(ifMatch ? { "If-Match": ifMatch } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
...((headers as Record<string, string> | undefined) ?? {}),
}
const response = await fetch(`${API_BASE}${path}`, {
...rest,
credentials: "include", // sends erp_at; the whole auth story depends on this
headers: finalHeaders,
body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined,
})
if (response.status === 401 && !isRetry && !path.startsWith("/auth/")) {
const refreshed = await refreshSession()
if (refreshed) return rawRequest(path, options, true)
redirectToLogin()
}
// A session older than the CSRF check has no XSRF-TOKEN cookie (or it rotated): renewing the session re-issues it.
// Peek at a clone so the normal error path below still reads the body.
if (response.status === 403 && !isRetry && !path.startsWith("/auth/")) {
const peek = await response.clone().json().catch(() => null) as ProblemDetails | null
if (peek?.code === "CSRF_TOKEN_MISMATCH" && (await refreshSession())) return rawRequest(path, options, true)
}
if (!response.ok) {
if (response.status === 403 && typeof window !== "undefined") {
// The role may have changed under this tab: let the auth context re-read what the person can still do.
window.dispatchEvent(new CustomEvent(FORBIDDEN_EVENT))
}
let problem: ProblemDetails
try {
problem = (await response.json()) as ProblemDetails
} catch {
// e.g. a proxy/network failure with a non-JSON body.
problem = { title: response.statusText || "Request failed", status: response.status }
}
if (!problem.status) problem.status = response.status
throw new ApiError(problem)
}
return response
}
/** Fire a request and decode the JSON body only (no ETag needed). */
export async function apiRequest<T>(path: string, options?: RequestOptions): Promise<T> {
const response = await rawRequest(path, options)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
/** Fire a request and also surface the ETag header, for resources that support If-Match. */
export async function apiRequestWithETag<T>(path: string, options?: RequestOptions): Promise<ApiResult<T>> {
const response = await rawRequest(path, options)
const etag = response.headers.get("ETag")
const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T)
return { data, etag }
}
/** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */
export function buildQuery(params: object): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) {
if (value === null || value === undefined || value === "") continue
search.set(key, String(value))
}
const qs = search.toString()
return qs ? `?${qs}` : ""
}