Aperture / frontend /js /api.js
KSvend
feat: show real indicator overlay on dashboard map + fix downloads
d2a9a16
/**
* Aperture β€” Backend API Wrapper
* All communication with the FastAPI backend goes through this module.
*/
const BASE = ''; // same-origin; empty string = relative to current host
/**
* Fetch wrapper with JSON handling and error propagation.
* @param {string} path - Relative path, e.g. "/api/eo-products"
* @param {object} opts - fetch() options (optional)
* @returns {Promise<any>}
*/
async function apiFetch(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
const session = JSON.parse(sessionStorage.getItem('aperture_session') || 'null');
if (session) {
headers['Authorization'] = `Bearer ${session.email}:${session.token}`;
}
const res = await fetch(BASE + path, { ...opts, headers });
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
if (Array.isArray(body.detail)) {
detail = body.detail.map(e => e.msg || JSON.stringify(e)).join('; ');
} else {
detail = body.detail || JSON.stringify(body);
}
} catch (_) { /* ignore parse errors */ }
throw new Error(`API ${res.status}: ${detail}`);
}
// 204 No Content β€” nothing to parse
if (res.status === 204) return null;
return res.json();
}
/* ── EO Products ─────────────────────────────────────────── */
/**
* List all available EO products.
* @returns {Promise<Array<{id, name, category, question, estimated_minutes}>>}
*/
export async function listProducts() {
return apiFetch('/api/eo-products');
}
/* ── Jobs ────────────────────────────────────────────────── */
/**
* Submit a new analysis job.
* @param {{aoi, time_range, product_ids, email}} payload
* @returns {Promise<{id, status}>}
*/
export async function submitJob(payload) {
return apiFetch('/api/jobs', {
method: 'POST',
body: JSON.stringify(payload),
});
}
/**
* Get current status and results for a job.
* @param {string} jobId
* @returns {Promise<{id, status, progress, results, created_at, updated_at, error}>}
*/
export async function getJob(jobId) {
return apiFetch(`/api/jobs/${jobId}`);
}
/* ── Downloads ───────────────────────────────────────────── */
/**
* Return auth headers for the current session (for raw fetch calls).
*/
export function authHeaders() {
const session = JSON.parse(sessionStorage.getItem('aperture_session') || 'null');
if (!session) return {};
return { 'Authorization': `Bearer ${session.email}:${session.token}` };
}
/**
* Fetch a file with auth and trigger a browser download.
*
* The anchor must be attached to the DOM for .click() to work
* reliably in Safari/Firefox, and the object URL must outlive the
* click event long enough for the browser to start the download.
*
* @param {string} url - File endpoint URL
* @param {string} filename - Suggested download filename
*/
export async function authDownload(url, filename) {
const res = await fetch(url, { headers: authHeaders() });
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || detail;
} catch (_) { /* ignore */ }
throw new Error(`Download failed (${res.status}): ${detail}`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Defer cleanup so the browser has time to start the download.
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(objectUrl);
}, 1500);
}
/**
* Returns the URL for the PDF report download.
* @param {string} jobId
* @returns {string}
*/
export function reportUrl(jobId) {
return `${BASE}/api/jobs/${jobId}/report`;
}
/**
* Returns the URL for the data package download.
* @param {string} jobId
* @returns {string}
*/
export function packageUrl(jobId) {
return `${BASE}/api/jobs/${jobId}/package`;
}
/**
* Returns the URL for an EO product map PNG.
* @param {string} jobId
* @param {string} productId
* @returns {string}
*/
export function mapUrl(jobId, productId) {
return `${BASE}/api/jobs/${jobId}/maps/${productId}`;
}
/**
* Returns the URL for EO product spatial data JSON.
* @param {string} jobId
* @param {string} productId
* @returns {string}
*/
export function spatialUrl(jobId, productId) {
return `${BASE}/api/jobs/${jobId}/spatial/${productId}`;
}
/**
* Fetch composite overview score + compound signals for the dashboard.
* @param {string} jobId
* @returns {Promise<{overview, compound_signals}>}
*/
export async function getJobOverview(jobId) {
return apiFetch(`/api/jobs/${jobId}/overview`);
}
/* ── AOI Advisor ───────────────────────────────────────── */
/**
* Get Claude-powered region insight for an AOI.
* @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat]
* @returns {Promise<{context, recommended_start, recommended_end, product_priorities, reasoning} | null>}
*/
export async function getAoiAdvice(bbox) {
try {
const result = await apiFetch('/api/aoi-advice', {
method: 'POST',
body: JSON.stringify({ bbox }),
});
// Treat all-null as failure
if (!result || !result.context) return null;
return result;
} catch {
return null;
}
}
/* ── Auth ───────────────────────────────────────────────── */
export async function requestMagicLink(email) {
return apiFetch('/api/auth/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
}
export async function verifyToken(email, token) {
return apiFetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, token }),
});
}
export async function listJobs() {
return apiFetch('/api/jobs');
}