pwaLUPMIS2/src/remotedb.js
ekke 933bfcf4c0 Permit-map iframe embed, lu_parcels schema, security guards, LayerSwitcher fix
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>
2026-06-01 16:20:15 +02:00

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,
};