Spaces:
Sleeping
Sleeping
| ; | |
| (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); | |