/** * Remote Database Module * * Handles all API communication with the PostgreSQL backend server. * Provides GET and POST methods for fetching and pushing data. * * Usage: * import { remoteGet, remotePost, getDistrictBoundary } from './remotedb.js'; * * const boundary = await getDistrictBoundary(); */ // ============================================================================ // Configuration // ============================================================================ const API_BASE = 'https://api.lupmis4luspa.org/api/spatial_planning'; /** * Per-request credentials sent with every API call. * * `district_id` is resolved dynamically — when the PWA is loaded via the PHP * entry point (public/index.php), the SSO session is injected into the page * as `window.LUPMIS_SESSION` and we read the authenticated user's district * from there. In local development (Vite serves index.html directly without * PHP), the global is undefined and we fall back to the hard-coded test * district below. * * `api_token` is currently a single global app token — not per-user. */ const FALLBACK_DISTRICT_ID = '1'; const API_TOKEN = '1c46538c712e9b5b'; /** * Returns the authenticated user's district_id. * * - No SSO session at all (window.LUPMIS_SESSION undefined): we're in local * development → fall back to the hard-coded test district. * - Session present but no district_id: the user is authenticated but not * assigned to any district → return null. The bootstrap in main.js detects * this case BEFORE any API call and shows a blocking message; this null * is defence-in-depth so we never silently send district_id=1 for an * authenticated user. * * The getter runs on each spread of API_CREDENTIALS, so a session change at * runtime takes effect immediately. */ function resolveDistrictId() { try { if (typeof window === 'undefined') return FALLBACK_DISTRICT_ID; const session = window.LUPMIS_SESSION; if (!session || typeof session !== 'object') return FALLBACK_DISTRICT_ID; const id = session.district_id; if (id === null || id === undefined || String(id).length === 0) return null; return String(id); } catch { /* no-op */ } return FALLBACK_DISTRICT_ID; } const API_CREDENTIALS = { get district_id() { return resolveDistrictId(); }, api_token: API_TOKEN, }; /** * Get the full session payload (or null if not authenticated). * Exposed for UI code that wants to display the user's name, email, etc. * * Dev-mode helper: setting `localStorage['dev-session']` to a JSON object * (e.g. via `lupmisDevSession({...})` in the console) overrides the real * session — useful when running against `localhost:5173` to test the * authenticated UI without standing up a PHP server. */ export function getSession() { // 1. Real session injected by index.php (production) if (typeof window !== 'undefined' && window.LUPMIS_SESSION && window.LUPMIS_SESSION.user_id) { return window.LUPMIS_SESSION; } // 2. Dev-mode override (developer's own localStorage tweak) try { const raw = localStorage.getItem('dev-session'); if (raw) { const parsed = JSON.parse(raw); if (parsed && parsed.user_id) return parsed; } } catch { /* ignore */ } return null; } // Console helper — set a fake session for dev work. Reload to apply. // lupmisDevSession({ user_id: 42, district_id: '1', full_name: 'Test User', ... }) // Clear it via lupmisDevSession(null) or localStorage.removeItem('dev-session'). if (typeof window !== 'undefined') { window.lupmisDevSession = (payload) => { if (payload == null) { localStorage.removeItem('dev-session'); console.log('[Dev] Session override cleared. Reload to apply.'); } else { localStorage.setItem('dev-session', JSON.stringify(payload)); console.log('[Dev] Session override saved. Reload to apply:', payload); } }; } // ============================================================================ // Server Reachability // ============================================================================ /** Default timeout for API requests (ms) */ const REQUEST_TIMEOUT = 30_000; /** Timeout for the fast reachability probe (ms) */ const PING_TIMEOUT = 5_000; /** Cached result of the last reachability check */ let _serverReachable = null; /** * Quick probe to determine if the API server is responding. * Sends a small POST to a lightweight endpoint with a short timeout. * The result is cached so subsequent calls within the same page load * return immediately. * * @param {boolean} [force=false] - Re-check even if a cached result exists * @returns {Promise} true if the server responded in time */ export async function checkServerReachable(force = false) { if (_serverReachable !== null && !force) return _serverReachable; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), PING_TIMEOUT); try { const response = await fetch(`${API_BASE}/get_layers.php`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(API_CREDENTIALS), signal: controller.signal, }); _serverReachable = response.ok; } catch { _serverReachable = false; } finally { clearTimeout(timer); } console.log('[RemoteDB] Server reachable:', _serverReachable); return _serverReachable; } /** * Returns the cached server-reachability flag (synchronous). * Returns null if checkServerReachable() has not been called yet. * @returns {boolean|null} */ export function isServerReachable() { return _serverReachable; } // ============================================================================ // Core Request Helpers // ============================================================================ /** * Create an AbortController that auto-aborts after `ms` milliseconds. * If the caller already supplied a signal in `options`, it is combined * so that either the caller's abort or the timeout will cancel the request. */ function withTimeout(options, ms = REQUEST_TIMEOUT) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); // If the caller provided their own signal, chain it if (options.signal) { options.signal.addEventListener('abort', () => controller.abort()); } return { signal: controller.signal, clear: () => clearTimeout(timer), }; } /** * Perform a GET request to the remote API. * Credentials are sent as URL query parameters. * Automatically times out after REQUEST_TIMEOUT ms. * * @param {string} endpoint - API endpoint filename (e.g. 'get_district_boundary.php') * @param {Object} [params={}] - Additional query parameters * @param {Object} [options={}] - Extra fetch options * @returns {Promise} Parsed JSON response */ export async function remoteGet(endpoint, params = {}, options = {}) { const url = new URL(`${API_BASE}/${endpoint}`); // Attach credentials and any extra params as query string const allParams = { ...API_CREDENTIALS, ...params }; for (const [key, value] of Object.entries(allParams)) { url.searchParams.set(key, value); } console.log('[RemoteDB] GET', url.toString()); const timeout = withTimeout(options); try { const response = await fetch(url.toString(), { method: 'GET', headers: { 'Accept': 'application/json' }, ...options, signal: timeout.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); console.log('[RemoteDB] GET response:', endpoint, '→', typeof data === 'object' ? `${Array.isArray(data) ? data.length + ' items' : 'object'}` : data); return data; } catch (error) { if (error.name === 'AbortError') { console.error('[RemoteDB] GET timed out:', endpoint); throw new Error(`Request timed out: ${endpoint}`); } console.error('[RemoteDB] GET failed:', endpoint, error); throw error; } finally { timeout.clear(); } } /** * Perform a POST request to the remote API. * Credentials are included in the JSON body. * Automatically times out after REQUEST_TIMEOUT ms. * * @param {string} endpoint - API endpoint filename (e.g. 'some_endpoint.php') * @param {Object} [body={}] - Request payload (credentials are merged in) * @param {Object} [options={}] - Extra fetch options * @returns {Promise} Parsed JSON response */ export async function remotePost(endpoint, body = {}, options = {}) { const url = `${API_BASE}/${endpoint}`; const payload = { ...API_CREDENTIALS, ...body }; console.log('[RemoteDB] POST', url); const timeout = withTimeout(options); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload), ...options, signal: timeout.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); console.log('[RemoteDB] POST response:', endpoint, '→', typeof data === 'object' ? `${Array.isArray(data) ? data.length + ' items' : 'object'}` : data); return data; } catch (error) { if (error.name === 'AbortError') { console.error('[RemoteDB] POST timed out:', endpoint); throw new Error(`Request timed out: ${endpoint}`); } console.error('[RemoteDB] POST failed:', endpoint, error); throw error; } finally { timeout.clear(); } } // ============================================================================ // Spatial Planning Endpoints // ============================================================================ /** * Fetch district boundary geometry from the server. * * @returns {Promise} District boundary GeoJSON or API response */ export async function getDistrictBoundary() { return remotePost('get_district_boundary.php'); } /** * Fetch the list of available map layer categories from the server. * * Response format: * { success: true, data: [{ id, name, description, createdt, editdt }, ...] } * * @returns {Promise} Layer categories list */ export async function getLayers() { return remotePost('get_layers.php'); } /** * Fetch all collector zones for the current district. * * Expected response: * { success: true, data: [{ id, zone_name, boundary: "MULTIPOLYGON(...)", ... }, ...] } * * @returns {Promise} Collector zones list */ export async function getCollectorZones() { return remotePost('get_all_collector_zone_per_district.php'); } /** * Fetch all parcels for the current district. * * Expected response: * { success: true, data: [{ id, ..., polygon: "POLYGON(...)" | "MULTIPOLYGON(...)", ... }, ...] } * * @returns {Promise} Parcels list */ export async function getDistrictParcels() { return remotePost('get_parcels_per_district.php'); } /** * Fetch all building footprints for the current district. * * Expected response: * { success: true, data: [{ id, ..., polygon: "POLYGON(...)" | "MULTIPOLYGON(...)", ... }, ...] } * * @returns {Promise} Building footprints list */ export async function getBuildingFootprints() { return remotePost('get_all_footprint_per_district.php'); } /** * Fetch the Contours hillshade elevation layer from the server. * * Source: table `be_contour_hillside` in the local PostgreSQL `public` schema * (imported from OpenTopography's viz.hh_hillshade). * * The current district_id is passed automatically via API_CREDENTIALS. * * Expected response: * { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] } * * @returns {Promise} Contours hillshade list */ export async function getContoursHillshade() { return remotePost('get_contours_hillshade.php'); } /** * Fetch the OSM roads layer from the server. * * Source: table `pi_osm_roads` in the local PostgreSQL `public` schema * (imported from OpenStreetMap road network for the district). * * Expected response: * { success: true, data: [{ id, ..., geom: "LINESTRING(...)" | "MULTILINESTRING(...)", ... }, ...] } * * @returns {Promise} OSM roads list */ export async function getOSMRoads() { return remotePost('get_osm_roads.php'); } /** * Push a recorded GPS trail (with all its points) to the server. * * Implements the GeoTracker "sync adapter" contract: store-and-forward — the * whole trail is uploaded once recording stops (and retried when back online). * `district_id` and `api_token` are attached automatically by remotePost(). * * ── SERVER SIDE (NOT YET CREATED) ─────────────────────────────────────── * Proposed endpoint: `save_gps_trail.php` * Proposed PostgreSQL/PostGIS tables (SRID 4326): * * CREATE TABLE be_gps_trail ( * id SERIAL PRIMARY KEY, * client_uuid TEXT UNIQUE, -- de-dupe re-syncs * district_id INTEGER, * name TEXT, * started_at TIMESTAMPTZ, * ended_at TIMESTAMPTZ, * point_count INTEGER, * distance_m DOUBLE PRECISION, * track GEOMETRY(LineStringZ, 4326), -- optional aggregate line * createdt TIMESTAMPTZ DEFAULT now() * ); * CREATE TABLE be_gps_trail_point ( * id SERIAL PRIMARY KEY, * trail_id INTEGER REFERENCES be_gps_trail(id) ON DELETE CASCADE, * seq INTEGER, * geom GEOMETRY(PointZ, 4326), * accuracy DOUBLE PRECISION, * altitude DOUBLE PRECISION, * heading DOUBLE PRECISION, * speed DOUBLE PRECISION, * satellites INTEGER, -- nullable (web has no sat count) * recorded_at TIMESTAMPTZ * ); * * Request body (JSON, plus injected credentials): * { client_uuid, name, started_at, ended_at, point_count, distance_m, * points: [ { seq, longitude, latitude, altitude, accuracy, * altitude_accuracy, heading, speed, satellites, recorded_at } ] } * * Expected response: { success: true, id: } * Should be idempotent on client_uuid (INSERT ... ON CONFLICT DO UPDATE). * ───────────────────────────────────────────────────────────────────────── * * @param {Object} trail local trail row (client_uuid, name, started_at, …) * @param {Array} points local point rows (seq, longitude, latitude, …) * @returns {Promise<{ remoteId: (string|number|null) }>} */ export async function pushGpsTrail(trail, points) { const payload = { client_uuid: trail.client_uuid, name: trail.name ?? null, started_at: trail.started_at, ended_at: trail.ended_at, point_count: trail.point_count ?? points.length, distance_m: trail.distance_m ?? 0, points: (points || []).map((p) => ({ seq: p.seq, longitude: p.longitude, latitude: p.latitude, altitude: p.altitude ?? null, accuracy: p.accuracy ?? null, altitude_accuracy: p.altitude_accuracy ?? null, heading: p.heading ?? null, speed: p.speed ?? null, satellites: p.satellites ?? null, recorded_at: p.recorded_at, })), }; const res = await remotePost('save_gps_trail.php', payload); return { remoteId: res?.id ?? res?.remote_id ?? null }; } // ============================================================================ // Exports // ============================================================================ export default { getSession, checkServerReachable, isServerReachable, remoteGet, remotePost, getDistrictBoundary, getLayers, getDistrictParcels, getCollectorZones, getBuildingFootprints, getContoursHillshade, getOSMRoads, pushGpsTrail, };