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>
This commit is contained in:
ekke 2026-06-01 16:20:15 +02:00
parent cfaceb3487
commit 933bfcf4c0
21 changed files with 1766 additions and 772 deletions

Binary file not shown.

12
dist/.htaccess vendored
View File

@ -13,11 +13,17 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
</FilesMatch>
# Common single-page-app behaviour: if a route doesn't map to a real file or
# directory, send the request to index.php so the PWA can handle it client-side.
# Comment out the next block if hash-based routing is preferred (no rewrites).
<IfModule mod_rewrite.c>
RewriteEngine On
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# Common single-page-app behaviour: if a route doesn't map to a real file
# or directory, send the request to index.php so the PWA can handle it
# client-side. Comment out this block if hash-based routing is preferred.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

803
dist/assets/index-YjHYbDyk.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-YjHYbDyk.js.map vendored Normal file

File diff suppressed because one or more lines are too long

157
dist/embed.php vendored Normal file
View File

@ -0,0 +1,157 @@
<?php
/**
* LUPMIS2 PWA Iframe embed entry point
*
* Same SSO model as index.php (cookie validate session inject
* window.LUPMIS_SESSION), with three things added for the iframe channel
* defined by LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4 and the
* Permit Map Integration document §2:
*
* 1. URL parameters (mode, lon, lat, zoom, upn, basemap, application_code)
* are parsed and exposed as `window.LUPMIS_EMBED` so the PWA can
* run in a slim, single-purpose mode (see main.js + src/embed-bridge.js).
* 2. `Content-Security-Policy: frame-ancestors` restricts who may embed
* this page never `*`. The list comes from EMBED_ALLOWED_PARENTS
* below and should be tightened to the real permitting host once it
* is confirmed.
* 3. A body class hint (`embed embed-mode-permit`) is set client-side
* via the inline script so CSS can hide the unrelated chrome (navbar,
* bottom dock, account offcanvas, etc.).
*/
session_start();
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// (same logic as index.php)
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $validate_url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode === 200) {
$data = json_decode($response, true);
if (
is_array($data)
&& isset($data['valid']) && $data['valid'] === true
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
) {
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard — same rule as index.php
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// Session payload (same shape as index.php)
// ────────────────────────────────────────────────────────────────────────────
$payload = [];
if (isset($_SESSION['user_id'])) {
$payload = [
'user_id' => $_SESSION['user_id'] ?? null,
'ua_id' => $_SESSION['ua_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'title' => $_SESSION['title'] ?? null,
'full_name' => $_SESSION['full_name'] ?? null,
'email' => $_SESSION['email'] ?? null,
'user_type' => $_SESSION['user_type'] ?? null,
'phone' => $_SESSION['phone'] ?? null,
'ua_position' => $_SESSION['ua_position'] ?? null,
'region_id' => $_SESSION['region_id'] ?? null,
'district_id' => $_SESSION['district_id'] ?? null,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Embed config — parse the contract's URL parameters (see Permit Map
// Integration doc §2.1). Strict whitelisting + type coercion keeps invalid
// input from reaching the PWA.
// ────────────────────────────────────────────────────────────────────────────
$validBasemaps = ['topo','osm','satellite','googlesat','carto-light','carto-dark','none'];
$validModes = ['permit'];
$mode = isset($_GET['mode']) ? (string)$_GET['mode'] : 'permit';
$basemap = isset($_GET['basemap']) ? (string)$_GET['basemap'] : null;
$upn = isset($_GET['upn']) ? (string)$_GET['upn'] : null;
$appCode = isset($_GET['application_code']) ? (string)$_GET['application_code'] : null;
$lon = isset($_GET['lon']) && is_numeric($_GET['lon']) ? (float)$_GET['lon'] : null;
$lat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
$zoom = isset($_GET['zoom']) && is_numeric($_GET['zoom']) ? (int)$_GET['zoom'] : null;
$embed = [
'mode' => in_array($mode, $validModes, true) ? $mode : 'permit',
'lon' => $lon,
'lat' => $lat,
'zoom' => $zoom,
'upn' => $upn,
'basemap' => ($basemap && in_array($basemap, $validBasemaps, true)) ? $basemap : null,
'application_code' => $appCode,
];
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session + embed config
// ────────────────────────────────────────────────────────────────────────────
$indexPath = __DIR__ . '/index.html';
$html = is_readable($indexPath)
? file_get_contents($indexPath)
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing.</p></body></html>';
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
$sessionJson = json_encode($payload, $jsonFlags);
$embedJson = json_encode($embed, $jsonFlags);
$inject =
"<script>window.LUPMIS_SESSION = {$sessionJson};" .
"window.LUPMIS_EMBED = {$embedJson};" .
// Set the body class as early as possible so CSS rules apply on first paint.
"document.addEventListener('DOMContentLoaded',function(){" .
"document.body.classList.add('embed','embed-mode-' + (window.LUPMIS_EMBED.mode || 'permit'));" .
"});</script>";
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Headers — frame-ancestors restricts who may embed; tighten this list once
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
// ────────────────────────────────────────────────────────────────────────────
$EMBED_ALLOWED_PARENTS = [
'https://permits.lupmis4luspa.org',
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
echo $html;

29
dist/index.html vendored
View File

@ -1525,6 +1525,33 @@
display: none;
}
/* ----------------------------------------------------------------
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
The body class is set by the inline script injected by embed.php
(`embed embed-mode-permit`). In permit mode the iframe is hosted
inside the permitting app, so all LUPMIS2 chrome is hidden —
only the map and its floating controls remain visible. The map
container expands to fill the viewport.
---------------------------------------------------------------- */
body.embed-mode-permit .navbar,
body.embed-mode-permit .bottom-dock,
body.embed-mode-permit .offcanvas,
body.embed-mode-permit .offcanvas-toggle,
body.embed-mode-permit #install-btn,
body.embed-mode-permit #offline-indicator,
body.embed-mode-permit .map-tools-bar {
display: none !important;
}
body.embed-mode-permit .main-content,
body.embed-mode-permit .map-container {
flex: 1 1 auto;
height: 100%;
}
body.embed-mode-permit #map {
height: 100svh;
height: 100dvh;
}
@media (max-width: 576px) {
.ol-editbar.ol-bar {
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
@ -1571,7 +1598,7 @@
}
}
</style>
<script type="module" crossorigin src="/assets/index-DJ2WL3EC.js"></script>
<script type="module" crossorigin src="/assets/index-YjHYbDyk.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-CvK8xBSr.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-BR0zF6aa.js">

15
dist/index.php vendored
View File

@ -65,6 +65,21 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard
// ────────────────────────────────────────────────────────────────────────────
// On the public production host, calling this file without a valid SSO session
// is not allowed — bounce the visitor to the central LUSPA login portal. On
// local development (any host that is not *.lupmis4luspa.org, e.g. localhost,
// 127.0.0.1, a developer's .local hostname) the guard is bypassed so the PHP
// entry point can still be exercised directly during testing.
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
// ────────────────────────────────────────────────────────────────────────────

6
dist/sw.js vendored
View File

@ -29,7 +29,11 @@
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
// old module/shell caches.
const CACHE_VERSION = 'v8';
// v9: Iframe embed endpoint (/embed via public/embed.php) + postMessage bridge
// for the permitting integration; lu_parcels structural refactor in the
// local DB; production access guard + no-district overlay; LayerSwitcher
// ordering fix. New shell + hashed bundle.
const CACHE_VERSION = 'v9';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -1525,6 +1525,33 @@
display: none;
}
/* ----------------------------------------------------------------
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
The body class is set by the inline script injected by embed.php
(`embed embed-mode-permit`). In permit mode the iframe is hosted
inside the permitting app, so all LUPMIS2 chrome is hidden —
only the map and its floating controls remain visible. The map
container expands to fill the viewport.
---------------------------------------------------------------- */
body.embed-mode-permit .navbar,
body.embed-mode-permit .bottom-dock,
body.embed-mode-permit .offcanvas,
body.embed-mode-permit .offcanvas-toggle,
body.embed-mode-permit #install-btn,
body.embed-mode-permit #offline-indicator,
body.embed-mode-permit .map-tools-bar {
display: none !important;
}
body.embed-mode-permit .main-content,
body.embed-mode-permit .map-container {
flex: 1 1 auto;
height: 100%;
}
body.embed-mode-permit #map {
height: 100svh;
height: 100dvh;
}
@media (max-width: 576px) {
.ol-editbar.ol-bar {
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an

117
main.js
View File

@ -88,20 +88,93 @@ import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers
import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js';
// Map instance (global for access across functions)
let mapView = null;
let mapTools = null;
// Module-level reference so the embed bridge can access the parcels layer
// once loadParcels() has created it.
let parcelsLayer = null;
let embedBridge = null;
// Iframe embed mode. Set by public/embed.php when serving the /embed route;
// undefined for the normal /index.php entry point.
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
let currentMode = 'addLocation';
// In embed permit mode we don't want the default Add-Location click to fire,
// so start the mode in a neutral state.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation';
// ============================================================================
// Application Initialization
// ============================================================================
/**
* Pre-flight: when an SSO session is present but the user has no district
* assigned, the app cannot function (every API call is scoped to a district).
* Show a blocking message and halt initialisation so we never silently fall
* back to a default district.
*
* Local dev (no window.LUPMIS_SESSION at all) is *not* affected that path
* still uses the remotedb FALLBACK_DISTRICT_ID for testing.
*
* @returns {boolean} true if the user is blocked (init should abort)
*/
function showNoDistrictBlockerIfNeeded() {
const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null;
if (!session || typeof session !== 'object') return false; // dev mode
const id = session.district_id;
if (id !== null && id !== undefined && String(id).length > 0) return false;
// Authenticated but no district — render an overlay and abort init.
console.warn('[App] Authenticated user has no district assigned; halting init.');
const overlay = document.createElement('div');
overlay.id = 'no-district-overlay';
overlay.setAttribute('role', 'alertdialog');
overlay.setAttribute('aria-modal', 'true');
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' +
'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;';
const name = session.full_name || session.username || 'You';
overlay.innerHTML = `
<div style="max-width:480px;text-align:center;border:1px solid #e5e7eb;
border-radius:12px;padding:28px 24px;box-shadow:0 8px 24px rgba(0,0,0,0.08);
background:#fff;font-family:var(--font-body,sans-serif);">
<div style="font-size:42px;line-height:1;margin-bottom:12px;">🛑</div>
<h2 style="margin:0 0 12px;color:var(--primary,#005eb8);font-size:1.35rem;">
No district assigned
</h2>
<p style="margin:0 0 10px;color:#333;">
${escapeHtml(name)}, your user profile is not associated with any
district. LUPMIS2 cannot load the relevant map data without one.
</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:0.95rem;">
Please contact the system administrator to have a district assigned
to your account.
</p>
<button type="button" id="no-district-portal-btn"
style="background:var(--primary,#005eb8);color:#fff;border:0;
border-radius:8px;padding:10px 18px;font-weight:600;cursor:pointer;">
Return to LUSPA portal
</button>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => {
window.location.href = 'https://lupmis4luspa.org/';
});
return true;
}
async function initApp() {
console.log('[App] Initializing...');
// Pre-flight: authenticated user must have a district assigned.
if (showNoDistrictBlockerIfNeeded()) return;
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
await initPWA({
installButton: '#install-btn',
@ -143,8 +216,20 @@ async function initApp() {
// 'water': '💧', 'school': '🏫', 'health': '🏥',
// 'market': '🏪', 'default': '📍', 'other': '📌'
// In iframe embed permit mode, install the postMessage bridge BEFORE the
// regular handlers so its outbound parcel:select / parcel:cleared events
// are wired up; the regular click/dblclick handlers below short-circuit in
// that mode (the bridge owns map interaction in the embed).
if (IS_EMBED_PERMIT) {
embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG });
}
// Set up map click handler immediately after map creation
mapView.onClick((lon, lat, feature, evt) => {
// Embed permit mode: the bridge handles parcel selection itself; the
// normal popup/add-location behaviour does not apply.
if (IS_EMBED_PERMIT) return;
console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
console.log('[MapClick] currentMode =', currentMode);
@ -192,6 +277,8 @@ async function initApp() {
// Set up double-click handler for overlay feature info
// Uses '_layerType' property to distinguish zone features from other layers
mapView.onDblClick((lon, lat, feature, evt) => {
// Embed permit mode shows no info popups (the host owns the UI).
if (IS_EMBED_PERMIT) return;
if (!feature) return;
const layerType = feature.get('_layerType');
@ -329,6 +416,16 @@ async function initApp() {
loadDistrictBoundary();
loadCollectorZones();
loadParcels();
// In embed permit mode the parcels layer is the user's working surface,
// so make it visible immediately and hand the layer to the bridge so it
// can emit `ready` (and resolve any pending `set:selected` UPN) once the
// features arrive. loadParcels() runs its synchronous prologue (creating
// the layer and assigning the module-level reference) before returning
// its promise, so `parcelsLayer` is already set here.
if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) {
parcelsLayer.setVisible(true);
embedBridge.attachParcelsLayer(parcelsLayer);
}
loadBuildingFootprints();
loadContoursHillshade();
loadOSMRoads();
@ -1359,18 +1456,22 @@ function parcelsToGeoJSON(parcels) {
seen.add(id);
}
// Prefer the GeoJSON geometry (sp_boundary) if available; fall back to WKT
// Geometry sources, in order:
// - API path: `geom` is a GeoJSON object, `boundary` is the WKT string
// - local cache: `geometry_wkt` is the WKT string
let geometry = null;
if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
if (parcel.geom && parcel.geom.type && parcel.geom.coordinates) {
geometry = { type: parcel.geom.type, coordinates: parcel.geom.coordinates };
} else if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates };
} else {
const wkt = parcel.boundary || parcel.polygon || parcel.geom || parcel.wkt;
const wkt = parcel.boundary || parcel.geometry_wkt || parcel.polygon || parcel.wkt;
geometry = parseWKT(wkt);
}
if (!geometry) continue;
// Collect all properties except bulky geometry fields
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'wkt', 'textboundary', 'sp_boundary']);
// Collect all properties except bulky geometry fields and local housekeeping.
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'geometry_wkt', 'wkt', 'textboundary', 'sp_boundary', 'fetched_at']);
const properties = { _layerType: 'parcel' };
for (const [key, value] of Object.entries(parcel)) {
if (skipKeys.has(key)) continue;
@ -1407,7 +1508,9 @@ async function loadParcels() {
// Create the Parcels layer immediately (empty) so it always appears
// in the LayerSwitcher. Features will be added once data is available.
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
const parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
// Assigned to the module-level `parcelsLayer` so the iframe embed bridge
// can pick it up after loadParcels() returns from its sync prologue.
parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
if (!parcelsLayer) {
console.warn('[App] Could not create Parcels layer');
return;

View File

@ -13,11 +13,17 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
</FilesMatch>
# Common single-page-app behaviour: if a route doesn't map to a real file or
# directory, send the request to index.php so the PWA can handle it client-side.
# Comment out the next block if hash-based routing is preferred (no rewrites).
<IfModule mod_rewrite.c>
RewriteEngine On
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# Common single-page-app behaviour: if a route doesn't map to a real file
# or directory, send the request to index.php so the PWA can handle it
# client-side. Comment out this block if hash-based routing is preferred.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

157
public/embed.php Normal file
View File

@ -0,0 +1,157 @@
<?php
/**
* LUPMIS2 PWA Iframe embed entry point
*
* Same SSO model as index.php (cookie validate session inject
* window.LUPMIS_SESSION), with three things added for the iframe channel
* defined by LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4 and the
* Permit Map Integration document §2:
*
* 1. URL parameters (mode, lon, lat, zoom, upn, basemap, application_code)
* are parsed and exposed as `window.LUPMIS_EMBED` so the PWA can
* run in a slim, single-purpose mode (see main.js + src/embed-bridge.js).
* 2. `Content-Security-Policy: frame-ancestors` restricts who may embed
* this page never `*`. The list comes from EMBED_ALLOWED_PARENTS
* below and should be tightened to the real permitting host once it
* is confirmed.
* 3. A body class hint (`embed embed-mode-permit`) is set client-side
* via the inline script so CSS can hide the unrelated chrome (navbar,
* bottom dock, account offcanvas, etc.).
*/
session_start();
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// (same logic as index.php)
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $validate_url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode === 200) {
$data = json_decode($response, true);
if (
is_array($data)
&& isset($data['valid']) && $data['valid'] === true
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
) {
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard — same rule as index.php
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// Session payload (same shape as index.php)
// ────────────────────────────────────────────────────────────────────────────
$payload = [];
if (isset($_SESSION['user_id'])) {
$payload = [
'user_id' => $_SESSION['user_id'] ?? null,
'ua_id' => $_SESSION['ua_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'title' => $_SESSION['title'] ?? null,
'full_name' => $_SESSION['full_name'] ?? null,
'email' => $_SESSION['email'] ?? null,
'user_type' => $_SESSION['user_type'] ?? null,
'phone' => $_SESSION['phone'] ?? null,
'ua_position' => $_SESSION['ua_position'] ?? null,
'region_id' => $_SESSION['region_id'] ?? null,
'district_id' => $_SESSION['district_id'] ?? null,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Embed config — parse the contract's URL parameters (see Permit Map
// Integration doc §2.1). Strict whitelisting + type coercion keeps invalid
// input from reaching the PWA.
// ────────────────────────────────────────────────────────────────────────────
$validBasemaps = ['topo','osm','satellite','googlesat','carto-light','carto-dark','none'];
$validModes = ['permit'];
$mode = isset($_GET['mode']) ? (string)$_GET['mode'] : 'permit';
$basemap = isset($_GET['basemap']) ? (string)$_GET['basemap'] : null;
$upn = isset($_GET['upn']) ? (string)$_GET['upn'] : null;
$appCode = isset($_GET['application_code']) ? (string)$_GET['application_code'] : null;
$lon = isset($_GET['lon']) && is_numeric($_GET['lon']) ? (float)$_GET['lon'] : null;
$lat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
$zoom = isset($_GET['zoom']) && is_numeric($_GET['zoom']) ? (int)$_GET['zoom'] : null;
$embed = [
'mode' => in_array($mode, $validModes, true) ? $mode : 'permit',
'lon' => $lon,
'lat' => $lat,
'zoom' => $zoom,
'upn' => $upn,
'basemap' => ($basemap && in_array($basemap, $validBasemaps, true)) ? $basemap : null,
'application_code' => $appCode,
];
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session + embed config
// ────────────────────────────────────────────────────────────────────────────
$indexPath = __DIR__ . '/index.html';
$html = is_readable($indexPath)
? file_get_contents($indexPath)
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing.</p></body></html>';
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
$sessionJson = json_encode($payload, $jsonFlags);
$embedJson = json_encode($embed, $jsonFlags);
$inject =
"<script>window.LUPMIS_SESSION = {$sessionJson};" .
"window.LUPMIS_EMBED = {$embedJson};" .
// Set the body class as early as possible so CSS rules apply on first paint.
"document.addEventListener('DOMContentLoaded',function(){" .
"document.body.classList.add('embed','embed-mode-' + (window.LUPMIS_EMBED.mode || 'permit'));" .
"});</script>";
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Headers — frame-ancestors restricts who may embed; tighten this list once
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
// ────────────────────────────────────────────────────────────────────────────
$EMBED_ALLOWED_PARENTS = [
'https://permits.lupmis4luspa.org',
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
echo $html;

View File

@ -65,6 +65,21 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard
// ────────────────────────────────────────────────────────────────────────────
// On the public production host, calling this file without a valid SSO session
// is not allowed — bounce the visitor to the central LUSPA login portal. On
// local development (any host that is not *.lupmis4luspa.org, e.g. localhost,
// 127.0.0.1, a developer's .local hostname) the guard is bypassed so the PHP
// entry point can still be exercised directly during testing.
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
// ────────────────────────────────────────────────────────────────────────────

View File

@ -29,7 +29,11 @@
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
// old module/shell caches.
const CACHE_VERSION = 'v8';
// v9: Iframe embed endpoint (/embed via public/embed.php) + postMessage bridge
// for the permitting integration; lu_parcels structural refactor in the
// local DB; production access guard + no-district overlay; LayerSwitcher
// ordering fix. New shell + hashed bundle.
const CACHE_VERSION = 'v9';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -50,10 +50,13 @@ export class MapTools {
zIndex: 99,
});
// Insert both layers just before the last layer (Overlays group)
// so the LayerSwitcher order becomes: Overlays > Measurements > Markers > Base Maps
// Insert both layers just before the Overlays group so the LayerSwitcher
// order becomes: Overlays > Measurements > Markers > Base Maps. Locate the
// Overlays group by title rather than assuming it is the last layer —
// other layers (e.g. the GPS trail/position layers) may sit on top of it.
const layers = this.map.getLayers();
const overlayIdx = layers.getLength() - 1; // Overlays is the last layer
let overlayIdx = layers.getArray().findIndex((l) => l.get('title') === 'Overlays');
if (overlayIdx < 0) overlayIdx = layers.getLength();
layers.insertAt(overlayIdx, this.drawLayer);
layers.insertAt(overlayIdx, this.measureLayer);

View File

@ -370,11 +370,14 @@ export class MapView {
title: 'Drawings',
layers: [this.drawingsLayer],
});
// Insert as a top-level map layer just before the Overlays group
// so the LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps
// Insert as a top-level map layer just before the Overlays group so the
// LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps.
// Find Overlays by reference rather than assuming it is the last layer —
// other layers (e.g. the GPS trail/position layers added by
// _initGpsRendering) may sit on top of it.
const mapLayers = this.map.getLayers();
const overlayIdx = mapLayers.getLength() - 1; // Overlays is the last layer
mapLayers.insertAt(overlayIdx, this._drawingsGroup);
const overlayIdx = mapLayers.getArray().indexOf(this.overlayGroup);
mapLayers.insertAt(overlayIdx >= 0 ? overlayIdx : mapLayers.getLength(), this._drawingsGroup);
// 2. Create a Select interaction that works on ALL vector layers.
// It starts INACTIVE so it doesn't steal clicks from normal handlers.

View File

@ -164,27 +164,58 @@ export async function initSchema() {
)
`;
// Create parcels table for caching parcel data
// status: 'verified' = from API, 'new' = drawn locally, needs verification
// Create parcels table — mirrors the server's spatial.lu_parcels structure
// (land-use parcels). Attribute columns match the server 1:1 so the local
// data viewer shows real fields rather than a JSON blob.
// status — local-only flag: 'verified' = downloaded from the API,
// 'new' = drawn locally and pending server verification.
// fetched_at — local-only cache timestamp.
// Migrate older databases that used the previous JSON-blob schema
// (id, geometry_wkt, properties, status, fetched_at): if the new `upn`
// column is missing, drop the cached table so it is recreated with the
// lu_parcels columns. Cached server parcels re-download on next load.
console.log('[Database] Creating parcels table...');
try {
const cols = await sql`PRAGMA table_info(parcels)`;
if (cols.length > 0 && !cols.some((c) => c.name === 'upn')) {
console.log('[Database] Migrating parcels table to lu_parcels structure (dropping old cache)...');
await sql`DROP TABLE parcels`;
}
} catch (e) {
console.warn('[Database] parcels migration check failed:', e);
}
await sql`
CREATE TABLE IF NOT EXISTS parcels (
id INTEGER PRIMARY KEY,
upn TEXT,
style INTEGER,
landuse TEXT,
zone_code TEXT,
zone_name TEXT,
sector TEXT,
block TEXT,
parcel_no TEXT,
prop_no TEXT,
st_name TEXT,
prop_add TEXT,
fac_name TEXT,
min_height INTEGER,
max_height INTEGER,
eff_date TEXT,
lp_name TEXT,
locality TEXT,
mmda TEXT,
last_update TEXT,
remarks TEXT,
geometry_wkt TEXT,
properties TEXT,
created_at TEXT,
updated_at TEXT,
districtid INTEGER,
status TEXT DEFAULT 'verified',
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
// Migrate: add status column if it doesn't exist (for existing databases)
try {
await sql`SELECT status FROM parcels LIMIT 1`;
} catch {
console.log('[Database] Adding status column to parcels table...');
await sql`ALTER TABLE parcels ADD COLUMN status TEXT DEFAULT 'verified'`;
}
// Create building_footprints table for caching footprint data
console.log('[Database] Creating building_footprints table...');
await sql`
@ -586,30 +617,59 @@ export async function getLocalCollectorZones() {
// Parcels
// ============================================================================
/** Coerce a value for an INTEGER column; '' / null / undefined / NaN → null. */
function numOrNull(v) {
if (v === '' || v === null || v === undefined) return null;
const n = Number(v);
return Number.isNaN(n) ? null : n;
}
/**
* Save parcels to the local table.
* Replaces all existing rows.
* Save parcels to the local table (mirrors spatial.lu_parcels).
* Replaces all server-cached rows. Each API field maps to its own column.
* Geometry is taken from the server `geom` field (WKT, EPSG:4326).
*
* @param {Array} parcels - Array of parcel objects from the API
*/
export async function saveParcels(parcels) {
try {
// Wrap the bulk load in a single transaction — with ~25k parcels, per-row
// auto-commits on OPFS would be prohibitively slow.
await sql`BEGIN`;
await sql`DELETE FROM parcels`;
let saved = 0;
for (const p of parcels) {
const id = p.id || p.parcelid || p.parcel_id || null;
const id = p.id ?? p.parcelid ?? p.parcel_id ?? null;
if (id == null) continue; // skip rows without a usable ID
const props = JSON.stringify(p);
// API field names: 'boundary' (WKT), 'polygon', 'geom', 'wkt'
const wkt = p.boundary || p.polygon || p.geom || p.wkt || '';
// Geometry must be a WKT *string* for the geometry_wkt TEXT column.
// The API sends WKT in `boundary` and a GeoJSON *object* in `geom`, so
// prefer the string fields and only accept `geom` when it is a string.
const wkt = p.boundary || p.geometry_wkt || p.polygon || p.wkt
|| (typeof p.geom === 'string' ? p.geom : '');
await sql`
INSERT OR REPLACE INTO parcels (id, geometry_wkt, properties, fetched_at)
VALUES (${id}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
INSERT OR REPLACE INTO parcels (
id, upn, style, landuse, zone_code, zone_name, sector, block, parcel_no,
prop_no, st_name, prop_add, fac_name, min_height, max_height, eff_date,
lp_name, locality, mmda, last_update, remarks, geometry_wkt,
created_at, updated_at, districtid, status, fetched_at
) VALUES (
${id}, ${p.upn ?? null}, ${numOrNull(p.style)}, ${p.landuse ?? null},
${p.zone_code ?? null}, ${p.zone_name ?? null}, ${p.sector ?? null},
${p.block ?? null}, ${p.parcel_no ?? null}, ${p.prop_no ?? null},
${p.st_name ?? null}, ${p.prop_add ?? null}, ${p.fac_name ?? null},
${numOrNull(p.min_height)}, ${numOrNull(p.max_height)}, ${p.eff_date ?? null},
${p.lp_name ?? null}, ${p.locality ?? null}, ${p.mmda ?? null},
${p.last_update ?? null}, ${p.remarks ?? null}, ${wkt},
${p.created_at ?? null}, ${p.updated_at ?? null}, ${numOrNull(p.districtid)},
'verified', CURRENT_TIMESTAMP
)
`;
saved++;
}
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'duplicates replaced)');
await sql`COMMIT`;
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'skipped/replaced)');
} catch (error) {
try { await sql`ROLLBACK`; } catch { /* no active txn */ }
console.error('[Database] ✗ Failed to save parcels:', error);
throw error;
}
@ -617,13 +677,14 @@ export async function saveParcels(parcels) {
/**
* Load all cached parcels from the local table.
* @returns {Promise<Array|null>} Array of parcel objects, or null if empty
* Each row is a plain object keyed by column name (incl. geometry_wkt).
* @returns {Promise<Array|null>} Array of parcel rows, or null if empty
*/
export async function getLocalParcels() {
try {
const rows = await sql`SELECT properties FROM parcels ORDER BY id`;
const rows = await sql`SELECT * FROM parcels ORDER BY id`;
if (rows.length === 0) return null;
return rows.map(r => JSON.parse(r.properties));
return rows;
} catch (error) {
console.error('[Database] ✗ Failed to read local parcels:', error);
return null;
@ -631,16 +692,40 @@ export async function getLocalParcels() {
}
/**
* Update a single parcel's properties in the local table.
* Only the properties JSON blob is updated; geometry stays unchanged.
* Update a single parcel's attribute columns in the local table.
* Geometry, id, created_at and status are left unchanged; updated_at is bumped.
*
* @param {number|string} parcelId - The parcel id
* @param {Object} updatedProps - Full updated properties object
* @param {Object} p - Updated attribute values (keys = lu_parcels columns)
*/
export async function updateParcel(parcelId, updatedProps) {
export async function updateParcel(parcelId, p) {
try {
const props = JSON.stringify(updatedProps);
await sql`UPDATE parcels SET properties = ${props} WHERE id = ${parcelId}`;
await sql`
UPDATE parcels SET
upn = ${p.upn ?? null},
style = ${numOrNull(p.style)},
landuse = ${p.landuse ?? null},
zone_code = ${p.zone_code ?? null},
zone_name = ${p.zone_name ?? null},
sector = ${p.sector ?? null},
block = ${p.block ?? null},
parcel_no = ${p.parcel_no ?? null},
prop_no = ${p.prop_no ?? null},
st_name = ${p.st_name ?? null},
prop_add = ${p.prop_add ?? null},
fac_name = ${p.fac_name ?? null},
min_height = ${numOrNull(p.min_height)},
max_height = ${numOrNull(p.max_height)},
eff_date = ${p.eff_date ?? null},
lp_name = ${p.lp_name ?? null},
locality = ${p.locality ?? null},
mmda = ${p.mmda ?? null},
last_update = ${p.last_update ?? null},
remarks = ${p.remarks ?? null},
districtid = ${numOrNull(p.districtid)},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${parcelId}
`;
console.log('[Database] ✓ Parcel updated:', parcelId);
broadcastChange('parcels', 'UPDATE', parcelId);
} catch (error) {
@ -654,15 +739,28 @@ export async function updateParcel(parcelId, updatedProps) {
* The parcel is tagged with status='new' to indicate it needs verification.
*
* @param {string} geometryWkt - WKT geometry string (EPSG:4326)
* @param {Object} properties - Attribute properties from the form
* @param {Object} p - Attribute values from the form (keys = lu_parcels columns)
* @returns {Promise<{id: number}>} The new row id
*/
export async function insertNewParcel(geometryWkt, properties) {
export async function insertNewParcel(geometryWkt, p = {}) {
try {
const props = JSON.stringify(properties);
await sql`
INSERT INTO parcels (id, geometry_wkt, properties, status, fetched_at)
VALUES (NULL, ${geometryWkt}, ${props}, 'new', CURRENT_TIMESTAMP)
INSERT INTO parcels (
id, upn, style, landuse, zone_code, zone_name, sector, block, parcel_no,
prop_no, st_name, prop_add, fac_name, min_height, max_height, eff_date,
lp_name, locality, mmda, last_update, remarks, geometry_wkt,
created_at, updated_at, districtid, status, fetched_at
) VALUES (
NULL, ${p.upn ?? null}, ${numOrNull(p.style)}, ${p.landuse ?? null},
${p.zone_code ?? null}, ${p.zone_name ?? null}, ${p.sector ?? null},
${p.block ?? null}, ${p.parcel_no ?? null}, ${p.prop_no ?? null},
${p.st_name ?? null}, ${p.prop_add ?? null}, ${p.fac_name ?? null},
${numOrNull(p.min_height)}, ${numOrNull(p.max_height)}, ${p.eff_date ?? null},
${p.lp_name ?? null}, ${p.locality ?? null}, ${p.mmda ?? null},
${p.last_update ?? null}, ${p.remarks ?? null}, ${geometryWkt},
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ${numOrNull(p.districtid)},
'new', CURRENT_TIMESTAMP
)
`;
const idResult = await sql`SELECT last_insert_rowid() as id`;
const newId = idResult[0]?.id;

262
src/embed-bridge.js Normal file
View File

@ -0,0 +1,262 @@
/**
* LUPMIS2 iframe embed bridge
*
* Implements the postMessage contract defined in
* LUPMIS2_Permit_Map_Integration.docx §2
* which itself follows the iframe channel from
* LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4.
*
* Outbound (embed host):
* { type: 'ready' }
* { type: 'parcel:select', upn, parcel_id, lon, lat,
* zone_code, zone_name, landuse,
* min_height, max_height }
* { type: 'parcel:cleared' }
* { type: 'error', code, message }
*
* Inbound (host embed):
* { type: 'set:view', lon, lat, zoom }
* { type: 'set:selected', upn }
* { type: 'clear:selected' }
* { type: 'set:basemap', key }
*
* The bridge is framework-agnostic apart from the OpenLayers imports it
* lives one level above the proposed `map-core` library so once that library
* is extracted (concept §3.1) this file can be lifted into it unchanged.
*
* Note on security: outbound messages are sent with target origin `*` because
* the embed cannot know its parent's origin in advance and the payload is
* non-sensitive (selection metadata only). The HOST is expected to verify
* `event.origin === '<embed origin>'` before trusting any message, as
* documented in §2.2 of the integration doc. Inbound commands are
* type-checked against a strict whitelist before being acted on.
*/
import { fromLonLat, toLonLat } from 'ol/proj.js';
import { getCenter } from 'ol/extent.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import { Style, Stroke, Fill } from 'ol/style.js';
const KNOWN_COMMANDS = new Set([
'set:view',
'set:selected',
'clear:selected',
'set:basemap',
]);
/**
* Create and install the embed bridge.
*
* @param {Object} opts
* @param {Object} opts.mapView the MapView instance (uses onClick,
* getMap, setBaseMap).
* @param {Object} opts.embedConfig window.LUPMIS_EMBED contents.
* @returns {{ attachParcelsLayer: (layer) => void, emitError: (code, msg) => void }}
*/
export function createEmbedBridge({ mapView, embedConfig }) {
const map = mapView.getMap();
const parent = (window.parent && window.parent !== window) ? window.parent : null;
// -------------------------------------------------------------------------
// Highlight layer — visual cue for the currently-selected parcel.
// -------------------------------------------------------------------------
const highlightSource = new VectorSource();
const highlightLayer = new VectorLayer({
source: highlightSource,
zIndex: 9999,
style: new Style({
stroke: new Stroke({ color: '#f97316', width: 3 }),
fill: new Fill({ color: 'rgba(249,115,22,0.18)' }),
}),
properties: {
title: 'Permit selection',
displayInLayerSwitcher: false,
},
});
map.addLayer(highlightLayer);
let parcelsLayer = null;
let pendingSelectUpn = embedConfig?.upn ? String(embedConfig.upn) : null;
let readyEmitted = false;
// -------------------------------------------------------------------------
// Outbound
// -------------------------------------------------------------------------
function send(message) {
if (!parent) {
console.warn('[embed-bridge] No parent window — would have sent:', message);
return;
}
try {
parent.postMessage(message, '*');
} catch (e) {
console.warn('[embed-bridge] postMessage failed:', e);
}
}
function emitError(code, message) {
send({ type: 'error', code, message });
}
function emitReady() {
if (readyEmitted) return;
readyEmitted = true;
send({ type: 'ready' });
}
/** Build a `parcel:select` payload from a feature and (optionally) the click point. */
function parcelPayload(feature, lon, lat) {
const p = feature.getProperties();
let outLon = lon, outLat = lat;
if (outLon == null || outLat == null) {
const ext = feature.getGeometry()?.getExtent();
if (ext) {
const [cx, cy] = toLonLat(getCenter(ext));
outLon = cx; outLat = cy;
}
}
return {
type: 'parcel:select',
upn: p.upn ?? null,
parcel_id: p.id ?? null,
lon: outLon ?? null,
lat: outLat ?? null,
zone_code: p.zone_code ?? null,
zone_name: p.zone_name ?? null,
landuse: p.landuse ?? null,
min_height: p.min_height ?? null,
max_height: p.max_height ?? null,
};
}
function highlightFeature(feature) {
highlightSource.clear();
if (feature) {
const clone = feature.clone();
highlightSource.addFeature(clone);
}
}
// -------------------------------------------------------------------------
// Click → parcel:select / parcel:cleared
// -------------------------------------------------------------------------
mapView.onClick((lon, lat, _markerFeature, evt) => {
let parcelFeature = null;
map.forEachFeatureAtPixel(evt.pixel, (f) => {
if (f.get('_layerType') === 'parcel') {
parcelFeature = f;
return true;
}
});
if (parcelFeature) {
highlightFeature(parcelFeature);
send(parcelPayload(parcelFeature, lon, lat));
} else {
highlightFeature(null);
send({ type: 'parcel:cleared' });
}
});
// -------------------------------------------------------------------------
// Inbound commands
// -------------------------------------------------------------------------
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || typeof msg !== 'object' || !KNOWN_COMMANDS.has(msg.type)) return;
try {
switch (msg.type) {
case 'set:view': {
if (typeof msg.lon === 'number' && typeof msg.lat === 'number') {
const view = map.getView();
view.setCenter(fromLonLat([msg.lon, msg.lat]));
if (typeof msg.zoom === 'number') view.setZoom(msg.zoom);
}
break;
}
case 'set:selected':
if (msg.upn) selectByUpn(String(msg.upn));
break;
case 'clear:selected':
highlightFeature(null);
pendingSelectUpn = null;
break;
case 'set:basemap':
if (msg.key && typeof mapView.setBaseMap === 'function') {
mapView.setBaseMap(msg.key);
}
break;
}
} catch (e) {
emitError('COMMAND_FAILED', `Failed to handle ${msg.type}: ${e.message}`);
}
});
/**
* Find the parcel with the given UPN, highlight it, fit the view to it,
* and emit a synthesized parcel:select so the host receives the metadata.
* If the parcels haven't finished loading yet, the UPN is queued and the
* lookup is retried as features stream in.
*/
function selectByUpn(upn) {
if (!parcelsLayer) { pendingSelectUpn = upn; return; }
const features = parcelsLayer.getSource().getFeatures();
const feature = features.find((f) => String(f.get('upn') ?? '') === upn);
if (!feature) { pendingSelectUpn = upn; return; }
pendingSelectUpn = null;
highlightFeature(feature);
const ext = feature.getGeometry()?.getExtent();
if (ext) {
map.getView().fit(ext, { padding: [50, 50, 50, 50], duration: 400, maxZoom: 17 });
}
send(parcelPayload(feature, null, null));
}
// -------------------------------------------------------------------------
// Parcels attached → emit `ready`, drain any pending set:selected
// -------------------------------------------------------------------------
function attachParcelsLayer(layer) {
parcelsLayer = layer;
const source = layer.getSource();
const drain = () => {
// Microtask hop so a batch addFeatures() finishes before we react.
queueMicrotask(() => {
if (pendingSelectUpn) selectByUpn(pendingSelectUpn);
emitReady();
});
};
if (source.getFeatures().length > 0) {
drain();
} else {
// Stay subscribed: parcels may arrive in waves (cache then API refresh),
// and a pending UPN may only resolve after the second wave.
let scheduled = false;
source.on('addfeature', () => {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
if (pendingSelectUpn) selectByUpn(pendingSelectUpn);
emitReady();
});
});
}
}
// -------------------------------------------------------------------------
// Apply initial config: basemap + view (UPN is handled after parcels load)
// -------------------------------------------------------------------------
if (embedConfig?.basemap && typeof mapView.setBaseMap === 'function') {
mapView.setBaseMap(embedConfig.basemap);
}
if (typeof embedConfig?.lon === 'number' && typeof embedConfig?.lat === 'number') {
const view = map.getView();
view.setCenter(fromLonLat([embedConfig.lon, embedConfig.lat]));
view.setZoom(typeof embedConfig?.zoom === 'number' ? embedConfig.zoom : 15);
}
return { attachParcelsLayer, emitError };
}

View File

@ -32,16 +32,27 @@ const FALLBACK_DISTRICT_ID = '1';
const API_TOKEN = '1c46538c712e9b5b';
/**
* Returns the authenticated user's district_id, or the dev fallback.
* The getter runs on each spread of API_CREDENTIALS, so changing the
* session at runtime (rare but possible) takes effect immediately.
* 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 {
const id = (typeof window !== 'undefined') && window.LUPMIS_SESSION?.district_id;
if (id !== null && id !== undefined && String(id).length > 0) {
return String(id);
}
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;
}