import type { AIInsight, ChickenCounting, ChickenWeight, CountingWrite, Cycle, CycleFilter, CycleWrite, Flock, FlockWrite, InitialBalanceResult, Karung, KarungRequestResult, Kandang, KandangWrite, KPI, LatestAvg, ManualInput, ManualInputWrite, Paginated, PanelIoT, 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) .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 { 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; 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(path: string, init: RequestInit = {}, csrfRetried = false): Promise { 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(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(data: Paginated | T[]): T[] { if (Array.isArray(data)) return data; return data.results ?? []; } const get = (path: string, query?: Query) => request(withQuery(path, query)); const post = (path: string, body?: unknown) => request(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }); const patch = (path: string, body: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(body) }); const del = (path: string) => request(path, { method: 'DELETE' }); export const api = { auth: { csrf: () => get<{ csrfToken: string }>('/auth/csrf/'), login: (user_name: string, password: string) => post('/auth/login/', { user_name, password }), logout: () => post('/auth/logout/'), me: () => get('/auth/me/'), }, sites: { list: async (query?: { user_id?: number }) => unwrapList(await get | Site[]>('/sites/', query)), create: (body: SiteWrite) => post('/sites/', body), update: (id: number, body: Partial) => patch(`/sites/${id}/`, body), remove: (id: number) => del(`/sites/${id}/`), }, kandangs: { list: async (query?: { site_id?: number }) => unwrapList(await get | Kandang[]>('/kandangs/', query)), create: (body: KandangWrite) => post('/kandangs/', body), update: (id: number, body: Partial) => patch(`/kandangs/${id}/`, body), remove: (id: number) => del(`/kandangs/${id}/`), }, flocks: { list: async (query?: { kandang_id?: number }) => unwrapList(await get | Flock[]>('/flocks/', query)), create: (body: FlockWrite) => post('/flocks/', body), update: (id: number, body: Partial) => patch(`/flocks/${id}/`, body), remove: (id: number) => del(`/flocks/${id}/`), }, cycles: { list: async (query?: CycleFilter) => unwrapList(await get | Cycle[]>('/cycles/', query)), create: (body: CycleWrite) => post('/cycles/', body), update: (id: number, body: Partial) => patch(`/cycles/${id}/`, body), remove: (id: number) => del(`/cycles/${id}/`), initialBalance: (id: number, body: { date: string; feed_in_manual: number }) => post(`/cycles/${id}/initial-balance/`, body), initialBalanceCompare: ( id: number, query: { date: string; manual?: number } ) => get(`/cycles/${id}/initial-balance-compare/`, query), }, kpis: { list: async (query?: CycleFilter) => unwrapList(await get | KPI[]>('/kpis/', query)), }, manualInputs: { list: async (query?: CycleFilter) => unwrapList(await get | ManualInput[]>('/manual-inputs/', query)), create: (body: ManualInputWrite) => post('/manual-inputs/', body), update: (id: number, body: Partial) => patch(`/manual-inputs/${id}/`, body), }, countings: { list: async (query?: CycleFilter) => unwrapList( await get | ChickenCounting[]>('/chicken-countings/', query) ), create: (body: CountingWrite) => post('/chicken-countings/', body), update: (id: number, body: Partial) => patch(`/chicken-countings/${id}/`, body), remove: (id: number) => del(`/chicken-countings/${id}/`), }, weights: { list: async (query?: CycleFilter) => unwrapList(await get | ChickenWeight[]>('/chicken-weights/', query)), latestAvg: (query?: CycleFilter) => get('/chicken-weights/latest-avg/', query), }, karungs: { list: async (query?: CycleFilter) => unwrapList(await get | Karung[]>('/karungs/', query)), latest: (query?: CycleFilter) => get('/karungs/latest/', query), request: (body: { cycle_id: number; section?: 'today' | 'yesterday_history' }) => post('/karungs/request/', body), }, panelIot: { list: async (query?: CycleFilter) => unwrapList(await get | PanelIoT[]>('/panel-iot/', query)), dates: async (query?: CycleFilter) => get('/panel-iot/dates/', query), }, insights: { list: async (query?: CycleFilter) => unwrapList(await get | AIInsight[]>('/ai-insights/', query)), }, };