File size: 5,659 Bytes
b81a86b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
// 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)}`,
    ),
};