Files
dashboard-cpsp/services/apiClient.ts
T
2026-09-29 15:19:10 +07:00

299 lines
11 KiB
TypeScript

import type {
AIInsight,
ChickenCounting,
ChickenWeight,
CountingWrite,
Cycle,
CycleFilter,
CycleWrite,
Flock,
FlockWrite,
InitialBalanceResult,
InsightApiResponse,
InsightCachedParams,
InsightGenerateJob,
InsightGenerateParams,
InsightJobStatus,
Karung,
KarungRequestResult,
Kandang,
KandangWrite,
KPI,
LatestAvg,
ManualInput,
ManualInputWrite,
Paginated,
PanelIoT,
PusatRegistration,
Site,
SiteWrite,
User,
} from '../types/api.ts';
const API_BASE = (import.meta.env.VITE_API_BASE || '/api/v1').replace(/\/$/, '');
export const AUTH_UNAUTHORIZED_EVENT = 'dashboard-auth-unauthorized';
export class ApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
export function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.message;
if (err instanceof Error) return err.message;
return 'Terjadi kesalahan';
}
function getCookie(name: string): string | null {
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
function parseDetail(body: unknown): string {
if (!body || typeof body !== 'object') return 'Request failed';
const detail = (body as { detail?: unknown }).detail;
if (typeof detail === 'string') {
if (detail.toLowerCase().includes('csrf')) {
return 'Permintaan ditolak (CSRF). Muat ulang halaman lalu coba lagi.';
}
return detail;
}
if (Array.isArray(detail)) return detail.map(String).join(', ');
const entries = Object.entries(body as Record<string, unknown>)
.filter(([key]) => key !== 'detail')
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : String(value)}`);
return entries.length > 0 ? entries.join('; ') : 'Request failed';
}
async function ensureCsrf(): Promise<string> {
const res = await fetch(`${API_BASE}/auth/csrf/`, { credentials: 'include' });
if (!res.ok) {
throw new ApiError('Gagal mengambil CSRF token', res.status, null);
}
const data = (await res.json()) as { csrfToken?: string };
return data.csrfToken || getCookie('csrftoken') || '';
}
function isCsrfFailure(status: number, body: unknown): boolean {
if (status !== 403) return false;
const detail = parseDetail(body).toLowerCase();
return detail.includes('csrf');
}
type Query = Record<string, string | number | boolean | null | undefined>;
function withQuery(path: string, query?: Query): string {
if (!query) return path;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
}
async function request<T>(path: string, init: RequestInit = {}, csrfRetried = false): Promise<T> {
const method = (init.method || 'GET').toUpperCase();
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (method !== 'GET' && method !== 'HEAD') {
const csrf = await ensureCsrf();
if (csrf) headers.set('X-CSRFToken', csrf);
}
const res = await fetch(`${API_BASE}${path}`, {
...init,
method,
headers,
credentials: 'include',
});
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = text;
}
}
if (!res.ok) {
if (isCsrfFailure(res.status, body) && !csrfRetried && method !== 'GET' && method !== 'HEAD') {
return request<T>(path, init, true);
}
if (res.status === 401) {
window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT));
}
throw new ApiError(parseDetail(body), res.status, body);
}
return body as T;
}
function unwrapList<T>(data: Paginated<T> | T[]): T[] {
if (Array.isArray(data)) return data;
return data.results ?? [];
}
const get = <T>(path: string, query?: Query) => request<T>(withQuery(path, query));
const post = <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined });
const patch = <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
const del = (path: string) => request<void>(path, { method: 'DELETE' });
export const api = {
auth: {
csrf: () => get<{ csrfToken: string }>('/auth/csrf/'),
login: (user_name: string, password: string) =>
post<User>('/auth/login/', { user_name, password }),
logout: () => post<void>('/auth/logout/'),
me: () => get<User>('/auth/me/'),
},
sites: {
list: async (query?: { user_id?: number }) =>
unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)),
create: (body: SiteWrite) => post<Site>('/sites/', body),
update: (id: string, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
remove: (id: string) => del(`/sites/${id}/`),
},
kandangs: {
list: async (query?: { site_id?: string }) =>
unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)),
create: (body: KandangWrite) => post<Kandang>('/kandangs/', body),
update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body),
remove: (id: number) => del(`/kandangs/${id}/`),
},
flocks: {
list: async (query?: { kandang_id?: number }) =>
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
remove: (id: number) => del(`/flocks/${id}/`),
},
cycles: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)),
create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
remove: (id: number) => del(`/cycles/${id}/`),
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
initialBalanceCompare: (id: number, query: { date: string; manual?: number }) =>
get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
requestClose: (id: number, body: { proposed_end_date: string }) =>
post<Cycle>(`/cycles/${id}/request-close/`, body),
cancelCloseRequest: (id: number) => post<Cycle>(`/cycles/${id}/cancel-close-request/`, {}),
},
pusat: {
register: (body: { code: string; site_id: string }) =>
post<PusatRegistration>('/pusat/register/', body),
changeCode: (body: { code: string; site_id: string }) =>
post<PusatRegistration>('/pusat/change-code/', body),
registration: (query?: { site_id?: string }) =>
get<PusatRegistration[]>('/pusat/registration/', query),
},
kpis: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<KPI> | KPI[]>('/kpis/', query)),
},
manualInputs: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<ManualInput> | ManualInput[]>('/manual-inputs/', query)),
create: (body: ManualInputWrite) => post<ManualInput>('/manual-inputs/', body),
update: (id: number, body: Partial<ManualInputWrite>) =>
patch<ManualInput>(`/manual-inputs/${id}/`, body),
},
countings: {
list: async (query?: CycleFilter) =>
unwrapList(
await get<Paginated<ChickenCounting> | ChickenCounting[]>('/chicken-countings/', query)
),
create: (body: CountingWrite) => post<ChickenCounting>('/chicken-countings/', body),
update: (id: number, body: Partial<CountingWrite>) =>
patch<ChickenCounting>(`/chicken-countings/${id}/`, body),
remove: (id: number) => del(`/chicken-countings/${id}/`),
sync: (body: { cycle_id: number }) =>
post<{ upserted: number; dates: string[] }>('/chicken-countings/sync/', body),
},
weights: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<ChickenWeight> | ChickenWeight[]>('/chicken-weights/', query)),
latestAvg: (query?: CycleFilter) => get<LatestAvg>('/chicken-weights/latest-avg/', query),
sync: (body: { cycle_id: number }) =>
post<{ upserted: number; dates: string[] }>('/chicken-weights/sync/', body),
},
karungs: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<Karung> | Karung[]>('/karungs/', query)),
latest: (query?: CycleFilter) => get<Karung>('/karungs/latest/', query),
request: (body: { cycle_id: number; section?: 'today' | 'yesterday_history' }) =>
post<KarungRequestResult>('/karungs/request/', body),
},
panelIot: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<PanelIoT> | PanelIoT[]>('/panel-iot/', query)),
dates: async (query?: CycleFilter) => get<string[]>('/panel-iot/dates/', query),
},
insights: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<AIInsight> | AIInsight[]>('/ai-insights/', query)),
/**
* Generate (or refresh) an insight for one kandang.
* Backend may take several minutes (qwen2.5:3b) — callers should
* show long-timeout UX. `kandang_id` is required.
*/
generate: (body: InsightGenerateParams) => {
if (body.kandang_id == null) {
return Promise.reject(
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
);
}
return post<InsightApiResponse>('/ai-insights/generate/', body);
},
/** Async generate (Phase 4): 202 + job_id; poll with jobStatus(). */
generateAsync: (body: InsightGenerateParams) => {
if (body.kandang_id == null) {
return Promise.reject(
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
);
}
return post<InsightGenerateJob>('/ai-insights/generate/', { ...body, async: true });
},
/** Poll async generate job (status + pipeline stage). */
jobStatus: (jobId: string) => get<InsightJobStatus>(`/ai-insights/jobs/${jobId}/`),
/** Load insight from DB for the same scope key (no LLM call). */
cached: (params: InsightCachedParams) => {
if (params.kandang_id == null) {
return Promise.reject(
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
);
}
return get<InsightApiResponse>('/ai-insights/cached/', {
cycle_id: params.cycle_id,
kandang_id: params.kandang_id,
topic: params.topic,
report_type: params.report_type,
report_period: params.report_period ?? undefined,
});
},
},
};