Iframe embed for the Permitting app (LUPMIS2_Reusable_Mapping_Concept §3.2): - public/embed.php — SSO + production gate + frame-ancestors CSP + whitelisted URL params (mode, lon/lat/zoom, upn, basemap, application_code); injects window.LUPMIS_SESSION + window.LUPMIS_EMBED. - public/.htaccess — clean /embed URL (rewrite before the SPA fallback). - src/embed-bridge.js — postMessage protocol: out ready / parcel:select / parcel:cleared / error; in set:view / set:selected / clear:selected / set:basemap. Visual highlight via a dedicated VectorLayer; pending-UPN queue resolved as parcels stream in. - main.js — reads window.LUPMIS_EMBED, gates the normal click/dblclick handlers in permit mode, exposes parcelsLayer to module scope, makes it visible and hands it to the bridge after loadParcels(). - index.html — CSS for body.embed-mode-permit hides navbar/dock/offcanvas and lets the map fill the iframe. - LUPMIS2_Permit_Map_Integration.docx — integration instructions for the Permitting team (contract, show.blade.php changes, phasing). Local lu_parcels structural refactor: - src/database.js — parcels table now mirrors spatial.lu_parcels with explicit columns (upn, style, landuse, zone_code/name, sector, block, parcel_no, prop_no, st_name, prop_add, fac_name, min/max_height, eff_date, lp_name, locality, mmda, last_update, remarks, geom→geometry_wkt, created_at, updated_at, districtid) plus local-only status/fetched_at. Drop-and-recreate migration off `upn` presence. saveParcels wraps the ~25k inserts in a transaction; numeric coercion via numOrNull. updateParcel/insertNewParcel write individual columns. - main.js parcelsToGeoJSON — handles GeoJSON `geom` object (API) and `geometry_wkt` string (local cache); skips housekeeping fields. Production access guard + no-district overlay: - public/index.php — on *.lupmis4luspa.org, redirect to the SSO portal if no session. - src/remotedb.js resolveDistrictId — no silent fallback to '1' for an authenticated user; dev mode (no session at all) keeps the fallback. - main.js — blocking overlay if the session lacks district_id; init aborts so no API call is made with the wrong scope. LayerSwitcher ordering fix: - MapView.initEditBar + MapTools — find the Overlays group by reference / title instead of assuming it's the last layer (the GPS layers add-layered on top in the constructor broke that assumption). Service Worker v8 → v9 to evict stale shell/module caches on deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
/**
|
|
* 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<boolean>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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: <server trail 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,
|
|
};
|