PotholeIQ / backend-api.js
Varun10000's picture
PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)
45e997f
Raw
History Blame Contribute Delete
8.63 kB
'use strict';
(function initPotholeBackend(globalScope) {
const API_PREFIX = '/api';
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function deepMerge(baseValue, incomingValue) {
if (!isPlainObject(baseValue) || !isPlainObject(incomingValue)) {
return incomingValue === undefined ? baseValue : incomingValue;
}
const result = { ...baseValue };
for (const [key, incoming] of Object.entries(incomingValue)) {
const existing = result[key];
if (Array.isArray(incoming)) {
result[key] = incoming.slice();
continue;
}
if (isPlainObject(existing) && isPlainObject(incoming)) {
result[key] = deepMerge(existing, incoming);
continue;
}
if (incoming !== undefined) {
result[key] = incoming;
}
}
return result;
}
function stripPhotoDataUrl(value) {
if (Array.isArray(value)) {
return value.map((item) => stripPhotoDataUrl(item));
}
if (!isPlainObject(value)) {
return value;
}
const clone = {};
for (const [key, child] of Object.entries(value)) {
if (key === 'photoDataUrl') continue;
clone[key] = stripPhotoDataUrl(child);
}
return clone;
}
function toTimestamp(value) {
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : 0;
}
function caseFreshness(record) {
if (!record || typeof record !== 'object') return 0;
const report = record.report || {};
return Math.max(
toTimestamp(record.updatedAt),
toTimestamp(record.statusUpdatedAt),
toTimestamp(record.ts),
toTimestamp(record.timestamp),
toTimestamp(report.workflowLastUpdatedAt),
toTimestamp(report.resolvedAt),
toTimestamp(report.supervisor?.at),
toTimestamp(report.enrichment?.enrichedAt),
toTimestamp(report.submittedAt)
);
}
function mergeCaseLists(localCases, remoteCases) {
const merged = new Map();
const allCases = [...(Array.isArray(localCases) ? localCases : []), ...(Array.isArray(remoteCases) ? remoteCases : [])];
allCases.forEach((record) => {
if (!record || !record.caseId) return;
const existing = merged.get(record.caseId);
if (!existing) {
merged.set(record.caseId, record);
return;
}
const existingFreshness = caseFreshness(existing);
const nextFreshness = caseFreshness(record);
const nextPreferred = nextFreshness >= existingFreshness;
merged.set(
record.caseId,
nextPreferred ? deepMerge(existing, record) : deepMerge(record, existing)
);
});
return Array.from(merged.values()).sort((a, b) => {
const aTs = caseFreshness(a);
const bTs = caseFreshness(b);
if (aTs !== bTs) return aTs - bTs;
return String(a.caseId || '').localeCompare(String(b.caseId || ''));
});
}
async function request(path, options = {}) {
const response = await fetch(`${API_PREFIX}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const errorMessage = payload?.error || `Request failed with HTTP ${response.status}`;
throw new Error(errorMessage);
}
return payload;
}
async function health() {
const payload = await request('/health', { method: 'GET' });
return payload?.ok === true;
}
async function getCases() {
const payload = await request('/cases', { method: 'GET' });
return Array.isArray(payload?.cases) ? payload.cases : [];
}
async function getCase(caseId) {
if (!caseId) return null;
const payload = await request(`/cases/${encodeURIComponent(caseId)}`, { method: 'GET' });
return payload?.case || null;
}
async function upsertCase(record) {
const payload = await request('/cases/upsert', {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl(record || {})),
});
return payload?.case || null;
}
async function bulkUpsertCases(cases) {
const payload = await request('/cases/bulk-upsert', {
method: 'POST',
body: JSON.stringify({
cases: Array.isArray(cases) ? cases.map((record) => stripPhotoDataUrl(record || {})) : [],
}),
});
return Array.isArray(payload?.cases) ? payload.cases : [];
}
async function patchCase(caseId, patch) {
if (!caseId) throw new Error('caseId is required.');
const payload = await request(`/cases/${encodeURIComponent(caseId)}/patch`, {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl(patch || {})),
});
return payload?.case || null;
}
async function submitSupervisorDecision(caseId, decisionPayload = {}) {
if (!caseId) throw new Error('caseId is required.');
const payload = await request('/workflow/supervisor-decision', {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl({
caseId,
...decisionPayload,
})),
});
return payload || null;
}
async function submitCrewCloseout(caseId, closeoutPayload = {}) {
if (!caseId) throw new Error('caseId is required.');
const payload = await request('/workflow/crew-closeout', {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl({
caseId,
...closeoutPayload,
})),
});
return payload || null;
}
async function submitDispatchPlan(dispatchPayload = {}) {
const payload = await request('/workflow/dispatch-plan', {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl(dispatchPayload || {})),
});
return payload || null;
}
async function resolveCase(caseId, resolvePayload = {}) {
if (!caseId) throw new Error('caseId is required.');
const payload = await request('/workflow/resolve-case', {
method: 'POST',
body: JSON.stringify(stripPhotoDataUrl({
caseId,
...resolvePayload,
})),
});
return payload || null;
}
async function uploadCaseFile(caseId, fileName, fileBlob, contentType = '') {
if (!caseId) throw new Error('caseId is required.');
if (!fileName) throw new Error('fileName is required.');
if (!(fileBlob instanceof Blob)) {
throw new Error('fileBlob must be a Blob or File.');
}
const response = await fetch(
`${API_PREFIX}/box/upload-case-file?caseId=${encodeURIComponent(caseId)}&fileName=${encodeURIComponent(fileName)}`,
{
method: 'POST',
headers: contentType ? { 'Content-Type': contentType } : {},
body: fileBlob,
}
);
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
throw new Error(payload?.error || `Request failed with HTTP ${response.status}`);
}
return payload?.upload || null;
}
async function hydrateCasesIntoLocalStorage(storageKey = 'submitted_cases') {
if (typeof localStorage === 'undefined') return null;
let localCases = [];
try {
localCases = JSON.parse(localStorage.getItem(storageKey) || '[]');
} catch {
localCases = [];
}
const remoteCases = await getCases();
const mergedCases = mergeCaseLists(localCases, remoteCases);
localStorage.setItem(storageKey, JSON.stringify(mergedCases));
const remoteIds = new Set(remoteCases.map((item) => item?.caseId).filter(Boolean));
const unsyncedLocal = localCases.filter((item) => item?.caseId && !remoteIds.has(item.caseId));
if (unsyncedLocal.length) {
bulkUpsertCases(unsyncedLocal).catch((err) => {
console.warn('[PotholeBackend] could not sync local-only cases:', err);
});
}
return mergedCases;
}
async function syncLocalStorageCases(storageKey = 'submitted_cases') {
if (typeof localStorage === 'undefined') return [];
try {
const cases = JSON.parse(localStorage.getItem(storageKey) || '[]');
return bulkUpsertCases(cases);
} catch (err) {
console.warn('[PotholeBackend] could not read local cases for sync:', err);
return [];
}
}
globalScope.PotholeBackend = {
health,
getCases,
getCase,
upsertCase,
bulkUpsertCases,
patchCase,
submitSupervisorDecision,
submitDispatchPlan,
submitCrewCloseout,
resolveCase,
uploadCaseFile,
hydrateCasesIntoLocalStorage,
syncLocalStorageCases,
mergeCaseLists,
};
})(window);