masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
5.66 kB
// Typed thin wrapper around the FastAPI dashboard endpoints.
import { getSupabase } from "./supabase";
const API_BASE =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
"http://localhost:8000";
export type Commodity = { code: string; nama: string };
export type BillingStatus = {
plan: "FREE" | "PRO";
is_pro: boolean;
expires_at: string | null;
used_today: number;
limit: number;
remaining: number; // -1 means unlimited (PRO)
};
export type Kabupaten = {
id: string;
nama: string;
lat: number;
lng: number;
tier: string;
ipm: number;
population: number;
};
export type SurplusDeficitRow = {
kab_id: string;
kab_nama: string;
lat: number;
lng: number;
tier: string;
role: "surplus" | "deficit";
volume_tons: number;
price_per_kg: number;
};
export type SurplusDeficitResponse = {
commodity: { code: string; nama: string };
rows: SurplusDeficitRow[];
totals: { surplus_tons: number; deficit_tons: number; balance_tons: number };
};
export type Match = {
surplus: {
kab_id: string;
kab_nama: string;
lat: number;
lng: number;
price_per_kg: number;
};
deficit: {
kab_id: string;
kab_nama: string;
lat: number;
lng: number;
price_per_kg: number;
};
commodity_code: string;
commodity_nama: string;
matched_volume_tons: number;
distance_km: number;
final_score: number;
confidence: string;
flags: string[];
};
export type MatchesResponse = { count: number; matches: Match[] };
// ---------------------------------------------------------------------------
// Forecast types
// ---------------------------------------------------------------------------
export type ForecastPoint = {
date: string; // ISO 8601
point: number; // IDR/kg
p10: number;
p90: number;
};
export type ForecastResponse = {
commodity_code: string;
city_id: string;
city_name: string;
method: string; // "timesfm_2.0" | "seasonal_naive_baseline"
generated_at: string;
horizon_days: number;
history_end_date: string;
forecasts: ForecastPoint[];
};
// ---------------------------------------------------------------------------
// Anomaly types
// ---------------------------------------------------------------------------
export type AnomalyRecord = {
date: string; // ISO 8601
price: number; // IDR/kg
rolling_median: number;
deviation_pct: number; // signed; positive = spike
type: "SPIKE" | "DROP";
score: number;
commodity_code: string;
city_id: string;
city_name: string;
persistent: boolean;
};
export type AnomaliesResponse = {
count: number;
method: string;
anomalies: AnomalyRecord[];
};
// ---------------------------------------------------------------------------
// Fetch helper + API object
// ---------------------------------------------------------------------------
// Thrown when the API rejects our token. Callers can catch this specifically
// to send the user back to /login instead of showing a generic error.
export class UnauthorizedError extends Error {
constructor(path: string) {
super(`${path} requires sign-in`);
this.name = "UnauthorizedError";
}
}
async function fetchJson<T>(path: string): Promise<T> {
const headers: Record<string, string> = {};
// Attach the Supabase access token when there is a session. getSession()
// refreshes it if it is close to expiry, so a tab left open overnight sends
// a live token rather than a stale one.
const supabase = getSupabase();
if (supabase) {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (token) headers.Authorization = `Bearer ${token}`;
}
const r = await fetch(`${API_BASE}${path}`, { cache: "no-store", headers });
if (r.status === 401) throw new UnauthorizedError(path);
if (!r.ok) throw new Error(`${path} failed: ${r.status}`);
return r.json() as Promise<T>;
}
export const api = {
commodities: () => fetchJson<Commodity[]>("/api/v1/commodities"),
kabupaten: () => fetchJson<Kabupaten[]>("/api/v1/kabupaten"),
surplusDeficit: (commodity: string) =>
fetchJson<SurplusDeficitResponse>(
`/api/v1/surplus-deficit?commodity=${encodeURIComponent(commodity)}`,
),
matches: (params: { commodity?: string; kab_id?: string; limit?: number }) => {
const q = new URLSearchParams();
if (params.commodity) q.set("commodity", params.commodity);
if (params.kab_id) q.set("kab_id", params.kab_id);
if (params.limit) q.set("limit", String(params.limit));
return fetchJson<MatchesResponse>(`/api/v1/matches?${q.toString()}`);
},
forecast: (params: { commodity: string; city: string }) =>
fetchJson<ForecastResponse>(
`/api/v1/forecast?commodity=${encodeURIComponent(params.commodity)}&city=${encodeURIComponent(params.city)}`,
),
anomalies: (params: {
commodity?: string;
city?: string;
limit?: number;
since?: string;
}) => {
const q = new URLSearchParams();
if (params.commodity) q.set("commodity", params.commodity);
if (params.city) q.set("city", params.city);
if (params.limit) q.set("limit", String(params.limit));
if (params.since) q.set("since", params.since);
return fetchJson<AnomaliesResponse>(`/api/v1/anomalies?${q.toString()}`);
},
// Plan + remaining free-tier quota for a WhatsApp number. The API hashes the
// number server-side; it is never stored in raw form.
billingStatus: (phone: string) =>
fetchJson<BillingStatus>(
`/billing/status?phone=${encodeURIComponent(phone)}`,
),
};