From 0256a1d62376c5112dc04759dab25fc058453f4b Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Thu, 2 Jul 2026 11:02:16 +1000 Subject: [PATCH 01/26] Extract width constants and sync alert state Extract hardcoded width values ('280px' and '30px') into constants for better maintainability. Add comprehensive state synchronization in the in-place update path to ensure collapsed/open states, classes, and UI elements are properly kept in sync when rules are updated. This prevents stale classes and styles from persisting during updates. --- src/pages/tasking/components/alerts.js | 40 ++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/pages/tasking/components/alerts.js b/src/pages/tasking/components/alerts.js index e1911143..a1382517 100644 --- a/src/pages/tasking/components/alerts.js +++ b/src/pages/tasking/components/alerts.js @@ -42,6 +42,8 @@ function createLeafletControl(L) { */ function renderRules(container, rules, opts = {}) { const allowCollapse = opts.allowCollapse !== false; + const expandedWidth = '280px'; + const collapsedWidth = '30px'; container.style.display = rules.length ? '' : 'none'; // Build a set of active rule IDs so we can remove stale DOM elements @@ -73,6 +75,34 @@ function renderRules(container, rules, opts = {}) { if (div) { // --- IN-PLACE UPDATE: only patch count + items, preserve all user state --- + const levelClasses = ['alerts--warning', 'alerts--danger', 'alerts--caution', 'alerts--info']; + div.classList.remove(...levelClasses); + div.classList.add(`alerts--${rule.level}`); + + // Re-sync persisted collapsed/open state so transitions do not leave stale classes. + if (!allowCollapse) { + state.collapsed = false; + state.open = true; + ruleState.set(rule.id, state); + } + + const isCollapsed = !!state.collapsed && allowCollapse; + const isOpen = !isCollapsed && !!state.open; + div.classList.toggle('alerts--collapsed', isCollapsed); + div.classList.toggle('alerts--open', isOpen); + + const body = div.querySelector('.alerts'); + if (body) body.style.width = isCollapsed ? collapsedWidth : expandedWidth; + + const btn = div.querySelector('.alerts__btn'); + if (btn) btn.setAttribute('aria-expanded', String(isOpen)); + + const hideBtn = div.querySelector('.alerts__hide-btn'); + if (hideBtn) hideBtn.style.display = allowCollapse ? '' : 'none'; + + const titleEl = div.querySelector('.alerts__title'); + if (titleEl) titleEl.textContent = rule.title; + const countEl = div.querySelector('.alerts__count'); if (countEl) countEl.textContent = rule.count; // keep prominent class in sync @@ -97,14 +127,14 @@ function renderRules(container, rules, opts = {}) { // --- FIRST-TIME CREATION for this rule --- div = document.createElement('div'); div.setAttribute('data-rule-id', rule.id); - var width = '280px' + let width = expandedWidth; div.className = `leaflet-control alerts alerts--${rule.level}`; if (rule.prominent) { div.classList.add('alerts--prominent'); } if (state.collapsed) { div.classList.add('alerts--collapsed'); - width = "30px" + width = collapsedWidth; } if (!state.collapsed && state.open) { div.classList.add('alerts--open'); @@ -143,7 +173,7 @@ function renderRules(container, rules, opts = {}) { st.open = true; ruleState.set(rule.id, st); div.classList.remove('alerts--collapsed'); - div.querySelector('.alerts').style.width = '280px'; + div.querySelector('.alerts').style.width = expandedWidth; } @@ -168,10 +198,10 @@ function renderRules(container, rules, opts = {}) { btn.setAttribute('aria-expanded', 'false'); div.querySelector('.alerts').animate( - [{ width: '280px' }, { width: '30px' }], + [{ width: expandedWidth }, { width: collapsedWidth }], { duration: 300, easing: 'ease-in-out' } ).onfinish = () => { - div.querySelector('.alerts').style.width = '30px'; + div.querySelector('.alerts').style.width = collapsedWidth; }; div.classList.add('alerts--collapsed'); }); From efb5851f91d3b7138d0728ab0c3934969eb65c66 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 3 Aug 2026 16:11:52 +1000 Subject: [PATCH 02/26] Add collaborative tasking layers (#384) Adds shared tasking layers with marker sync, right-click marker creation, and popup editing/deletion. Also adds the collaborative layer list and visibility controls in the config modal, plus Beacon person-name lookup for marker attribution. --- .gitignore | 1 + src/injectscripts/all.js | 2 + .../tasking/components/collab_marker_icons.js | 142 ++++++ src/pages/tasking/components/job_icon.js | 8 +- .../tasking/components/mapContextMenu.js | 13 + src/pages/tasking/main.js | 227 +++++---- src/pages/tasking/mapLayers/collabLayer.js | 451 ++++++++++++++++++ src/pages/tasking/utils/collabLayerSync.js | 227 +++++++++ src/pages/tasking/viewmodels/Config.js | 95 ++++ src/pages/tasking/viewmodels/Map.js | 3 + src/shared/BeaconClient.js | 5 +- src/shared/BeaconClient/people.js | 24 + static/pages/tasking.html | 67 +++ styles/pages/tasking.css | 277 +++++++++++ 14 files changed, 1457 insertions(+), 85 deletions(-) create mode 100644 src/pages/tasking/components/collab_marker_icons.js create mode 100644 src/pages/tasking/mapLayers/collabLayer.js create mode 100644 src/pages/tasking/utils/collabLayerSync.js create mode 100644 src/shared/BeaconClient/people.js diff --git a/.gitignore b/.gitignore index ab88ddd9..f4912e30 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Lighthouse.zip .DS_Store *.DS_Store package-lock.json +lambda diff --git a/src/injectscripts/all.js b/src/injectscripts/all.js index 4cc041f0..5d57a50c 100644 --- a/src/injectscripts/all.js +++ b/src/injectscripts/all.js @@ -51,6 +51,8 @@ whenWeAreReady(function () { var vars = '?userId=' + user.Id + + '&personId=' + + user.personId + '&host=' + urls.Base + '&source=' + diff --git a/src/pages/tasking/components/collab_marker_icons.js b/src/pages/tasking/components/collab_marker_icons.js new file mode 100644 index 00000000..fd5ead5f --- /dev/null +++ b/src/pages/tasking/components/collab_marker_icons.js @@ -0,0 +1,142 @@ +import L from 'leaflet'; + +/** + * Curated set of Font Awesome 5 Free icons relevant to emergency-service + * field marking (hazards, medical, welfare, shelter, infrastructure, + * vehicles/rescue, status). Grouped for a scannable picker UI. + * + * Every `fa` class here is confirmed present in the bundled + * @fortawesome/fontawesome-free 5.15.4 solid set -- don't add an icon + * without checking node_modules/@fortawesome/fontawesome-free/svgs/solid/. + */ +export const MARKER_ICON_GROUPS = [ + { + group: 'Hazards', + icons: [ + { key: 'fire', label: 'Fire', fa: 'fa-fire' }, + { key: 'fire-extinguisher', label: 'Fire (controlled)', fa: 'fa-fire-extinguisher' }, + { key: 'water', label: 'Flooding', fa: 'fa-water' }, + { key: 'house-damage', label: 'Structural damage', fa: 'fa-house-damage' }, + { key: 'exclamation-triangle', label: 'Hazard', fa: 'fa-exclamation-triangle' }, + { key: 'skull-crossbones', label: 'Danger / poison', fa: 'fa-skull-crossbones' }, + { key: 'biohazard', label: 'Biohazard', fa: 'fa-biohazard' }, + { key: 'radiation', label: 'Radiation', fa: 'fa-radiation' }, + { key: 'bolt', label: 'Downed power line', fa: 'fa-bolt' }, + { key: 'wind', label: 'Storm / wind damage', fa: 'fa-wind' }, + { key: 'smog', label: 'Smoke / air hazard', fa: 'fa-smog' }, + { key: 'car-crash', label: 'Vehicle accident', fa: 'fa-car-crash' }, + { key: 'tree', label: 'Fallen tree', fa: 'fa-tree' }, + { key: 'gas-pump', label: 'Fuel / gas hazard', fa: 'fa-gas-pump' }, + { key: 'ban', label: 'Road closed', fa: 'fa-ban' }, + ], + }, + { + group: 'Medical', + icons: [ + { key: 'ambulance', label: 'Ambulance', fa: 'fa-ambulance' }, + { key: 'first-aid', label: 'First aid', fa: 'fa-first-aid' }, + { key: 'hospital', label: 'Hospital', fa: 'fa-hospital' }, + { key: 'user-md', label: 'Medical personnel', fa: 'fa-user-md' }, + { key: 'user-injured', label: 'Injured person', fa: 'fa-user-injured' }, + { key: 'syringe', label: 'Medical supplies', fa: 'fa-syringe' }, + ], + }, + { + group: 'People', + icons: [ + { key: 'user', label: 'Person', fa: 'fa-user' }, + { key: 'users', label: 'Group of people', fa: 'fa-users' }, + { key: 'wheelchair', label: 'Accessibility needs', fa: 'fa-wheelchair' }, + { key: 'baby-carriage', label: 'Infant / child', fa: 'fa-baby-carriage' }, + { key: 'paw', label: 'Animal / livestock', fa: 'fa-paw' }, + ], + }, + { + group: 'Shelter & Resources', + icons: [ + { key: 'campground', label: 'Evacuation centre', fa: 'fa-campground' }, + { key: 'home', label: 'Shelter / house', fa: 'fa-home' }, + { key: 'warehouse', label: 'Supply depot', fa: 'fa-warehouse' }, + { key: 'tint', label: 'Water supply', fa: 'fa-tint' }, + { key: 'shower', label: 'Sanitation', fa: 'fa-shower' }, + ], + }, + { + group: 'Infrastructure', + icons: [ + { key: 'road', label: 'Road / route', fa: 'fa-road' }, + { key: 'route', label: 'Evacuation route', fa: 'fa-route' }, + { key: 'broadcast-tower', label: 'Communications', fa: 'fa-broadcast-tower' }, + { key: 'plug', label: 'Power / utility', fa: 'fa-plug' }, + ], + }, + { + group: 'Vehicles & Rescue', + icons: [ + { key: 'truck', label: 'Truck', fa: 'fa-truck' }, + { key: 'helicopter', label: 'Helicopter', fa: 'fa-helicopter' }, + { key: 'ship', label: 'Boat', fa: 'fa-ship' }, + { key: 'life-ring', label: 'Rescue', fa: 'fa-life-ring' }, + ], + }, + { + group: 'Status', + icons: [ + { key: 'map-marker-alt', label: 'General marker', fa: 'fa-map-marker-alt' }, + { key: 'flag', label: 'Checkpoint', fa: 'fa-flag' }, + { key: 'check-circle', label: 'Cleared / complete', fa: 'fa-check-circle' }, + { key: 'question-circle', label: 'Unknown / needs check', fa: 'fa-question-circle' }, + ], + }, +]; + +/** Flat key -> { fa, label } lookup, built once. */ +export const MARKER_ICONS_BY_KEY = MARKER_ICON_GROUPS.reduce((acc, g) => { + g.icons.forEach((i) => { acc[i.key] = i; }); + return acc; +}, {}); + +export const DEFAULT_MARKER_ICON_KEY = 'map-marker-alt'; + +/** + * Preset badge-color swatches for the marker form -- chosen to stay + * readable with a white icon glyph on top (no light/pastel tones) and to + * span enough distinct hues for status/severity coding at a glance. + */ +export const MARKER_COLOR_SWATCHES = [ + '#d32f2f', // red + '#f57c00', // orange + '#fbc02d', // amber + '#388e3c', // green + '#00897b', // teal + '#1976d2', // blue + '#3949ab', // indigo + '#8e24aa', // purple + '#6d4c41', // brown + '#455a64', // slate +]; + +/** Look up the FA class for an icon key, falling back to the default marker glyph. */ +export function faClassForIconKey(iconKey) { + return (MARKER_ICONS_BY_KEY[iconKey] || MARKER_ICONS_BY_KEY[DEFAULT_MARKER_ICON_KEY]).fa; +} + +/** + * Build a circular colored badge with a white Font Awesome glyph -- the + * marker style used for collaborative-layer markers (deliberately distinct + * from the teardrop asset/job markers so responders can tell "someone + * dropped this" apart from tracked assets at a glance). + */ +export function buildMarkerBadgeIcon({ icon, fill }, { size = 28 } = {}) { + const faClass = faClassForIconKey(icon); + const bg = fill || '#2b7bbb'; + const html = `
`; + + return L.divIcon({ + className: 'collab-marker-icon', + html, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -size / 2], + }); +} diff --git a/src/pages/tasking/components/job_icon.js b/src/pages/tasking/components/job_icon.js index 985a4002..6688d96e 100644 --- a/src/pages/tasking/components/job_icon.js +++ b/src/pages/tasking/components/job_icon.js @@ -2,7 +2,7 @@ import {jobsToUI} from "../utils/jobTypesToUI.js"; // --- SVG factory (shape+style → L.divIcon) --- import L from "leaflet"; -export function makeShapeIcon({ shape, fill, stroke, radius = 7, strokeWidth = 2 }) { +function shapeInnerSvg({ shape, fill, stroke, radius = 7, strokeWidth = 2 }) { const d = radius * 2; const cx = radius, cy = radius; @@ -118,6 +118,12 @@ export function makeShapeIcon({ shape, fill, stroke, radius = 7, strokeWidth = 2 fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`; } + return inner; +} + +export function makeShapeIcon({ shape, fill, stroke, radius = 7, strokeWidth = 2 }) { + const d = radius * 2; + const inner = shapeInnerSvg({ shape, fill, stroke, radius, strokeWidth }); const svg = ` ${inner} `; diff --git a/src/pages/tasking/components/mapContextMenu.js b/src/pages/tasking/components/mapContextMenu.js index 0deff4f2..479fee73 100644 --- a/src/pages/tasking/components/mapContextMenu.js +++ b/src/pages/tasking/components/mapContextMenu.js @@ -40,10 +40,13 @@ export function installMapContextMenu({ geocodeMarkerIcon = null, // pass your defaultSvgIcon if you want geocodeRedMarkerIcon = null, // pass your defaultRedSvgIcon if you want geocodeMaxResults = 10, + canAddMarker = null, // () => boolean -- show/hide the "Add marker" item + onAddMarker = null, // (latlng) => void -- invoked when it's clicked }) { const ctxMenu = document.getElementById("mapContextMenu"); const btnSearch = document.getElementById("ctxSearchHere"); const btnGeocode = document.getElementById("ctxGeocodeHere"); + const btnAddMarker = document.getElementById("ctxAddCollabMarker"); if (!map || !ctxMenu || !btnSearch || !btnGeocode) { console.warn("MapContextMenu: missing dependencies or DOM"); @@ -78,11 +81,21 @@ export function installMapContextMenu({ map.on("contextmenu", (e) => { lastLatLng = e.latlng; + if (btnAddMarker) { + btnAddMarker.classList.toggle("d-none", !canAddMarker?.()); + } + const p = map.latLngToContainerPoint(e.latlng); const rect = map.getContainer().getBoundingClientRect(); showMenuAt(rect.left + p.x, rect.top + p.y); }); + // ---- ADD MARKER (collaborative layers) ---- + btnAddMarker?.addEventListener("click", () => { + hideMenu(); + if (lastLatLng) onAddMarker?.(lastLatLng); + }); + // ---- SEARCH ---- diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index e581beb7..e14330e4 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -66,6 +66,7 @@ import { registerWaterNSWBoundariesLayer, registerEPAContaminationSitesLayer } f import { registerNSWDeclaredDamsLayer } from "./mapLayers/dams.js"; import { registerBOMLandWarningsLayer } from "./mapLayers/bom.js"; import { registerRainRadarLayer } from "./mapLayers/rainviewer.js"; +import { registerCollabLayers, getVisibleCollabLayers, startAddMarkerFlow } from "./mapLayers/collabLayer.js"; import { registerBOMRainfallLayer, registerBOMRadarLayer, @@ -196,6 +197,13 @@ const params = getSearchParameters(); const apiHost = params.host const sourceUrl = params.source +// Collaborative map layer markers are attributed to the Beacon Person +// record (params.personId), not the login/account id (params.userId) -- +// these are separate id systems in Beacon's data model (see +// BeaconClient/people.js), so userId must never be used as a stand-in +// here even if personId happens to be missing. +const markerActorId = params.personId; + // Tell Team model which API URL to use for shared default-asset pushes setDefaultAssetApiUrl(sourceUrl); @@ -231,6 +239,12 @@ installMapContextMenu({ onGeocodeResultClicked: (_r) => { // TODO: replace with real action }, + // myViewModel isn't constructed yet at this point in the file (see the + // existing `var myViewModel;` module-level pattern below) -- these + // callbacks only run later, on an actual right-click, by which point + // it's fully populated. + canAddMarker: () => getVisibleCollabLayers(myViewModel).length > 0, + onAddMarker: (latlng) => startAddMarkerFlow(myViewModel, sourceUrl, markerActorId, latlng), }); @@ -399,6 +413,34 @@ function VM() { self.taskingsById = new Map(); self.assetsById = new Map(); self.sectorsById = new Map(); + self.personNamesById = new Map(); // personId -> Promise, caches + dedupes concurrent lookups + + /** + * Resolve a Beacon person ID to their display name, caching the result + * (and de-duping concurrent lookups for the same id, since the cache + * stores the in-flight Promise itself). Falls back to the raw id string + * if the lookup fails. + */ + self.resolvePersonName = function (personId) { + const idStr = String(personId); + if (self.personNamesById.has(idStr)) return self.personNamesById.get(idStr); + + const pending = (async () => { + try { + const tk = await getToken(); + const person = await new Promise((resolve, reject) => { + BeaconClient.people.getSimplePerson(idStr, apiHost, params.userId, tk, resolve, reject); + }); + return person?.FullName || idStr; + } catch (err) { + console.warn('Failed to resolve person name for', idStr, err); + return idStr; + } + })(); + + self.personNamesById.set(idStr, pending); + return pending; + }; // Global collections self.teams = ko.observableArray(); @@ -1341,6 +1383,8 @@ function VM() { }); }, fetchAllSectors: (hqs) => self.fetchAllSectors(hqs), + apiUrl: sourceUrl, + userId: params.userId, }; self.config = new ConfigVM(self, configDeps); @@ -2978,6 +3022,7 @@ function VM() { registerBOMFloodWarningBoundariesLayer(self, sourceUrl); registerBOMFireWeatherDistrictsLayer(self, sourceUrl); registerRainRadarLayer(self, map); + registerCollabLayers(self, sourceUrl, markerActorId); // --- Layers Drawer (under zoom) const LayersDrawer = L.Control.extend({ @@ -2992,6 +3037,8 @@ function VM() { onAdd(map) { const c = L.DomUtil.create("div", "layers-drawer"); + this._container = c; + this._map = map; // stop wheel -> no map zoom when scrolling the panel c.addEventListener("wheel", (e) => { e.stopPropagation(); }, { passive: false }); @@ -3075,6 +3122,103 @@ function VM() { this._setBasemap(this._baseKey, map); + this._renderOverlays(); + + // --- Search filter --- + const searchInput = c.querySelector(".ld-search-input"); + this._searchFilter = (query) => { + const q = query.toLowerCase().trim(); + const grid = c.querySelector(".ld-grid"); + const cells = grid.querySelectorAll(".ld-grid-cell"); + + cells.forEach(cell => { + const buttons = cell.querySelectorAll(".ld-overlay-btn"); + let anyVisible = false; + + buttons.forEach(btn => { + let shouldShow = !q; // Show all if no query + + if (q) { + // Extract label from the span.me-2 text content + const labelSpan = btn.querySelector("span.me-2"); + const label = labelSpan ? labelSpan.textContent.trim().toLowerCase() : ""; + shouldShow = label.includes(q); + } + + btn.style.setProperty("display", shouldShow ? "" : "none", "important"); + if (shouldShow) anyVisible = true; + }); + + // Show cell only if at least one button is visible + cell.style.setProperty("display", anyVisible ? "" : "none", "important"); + }); + }; + + searchInput.addEventListener("input", (e) => { + this._searchFilter(e.target.value); + }); + + // --- Toggle button --- + const toggleBtn = c.querySelector(".ld-toggle-btn"); + const panel = c.querySelector(".ld-panel"); + + const fitPanel = () => { + requestAnimationFrame(() => { + const rect = panel.getBoundingClientRect(); + const avail = window.innerHeight - rect.top - 20; // 20px bottom margin + panel.style.maxHeight = Math.max(avail, 160) + "px"; + }); + }; + this._fitPanel = fitPanel; + + L.DomEvent.on(toggleBtn, "click", (ev) => { + L.DomEvent.stop(ev); + const hidden = panel.classList.toggle("d-none"); + toggleBtn.setAttribute("aria-expanded", (!hidden).toString()); + toggleBtn.parentElement.classList.toggle("no-border", !hidden); + localStorage.setItem("layers.open", hidden ? "0" : "1"); + if (!hidden) { + // Clear search when opening + searchInput.value = ""; + this._searchFilter(""); + fitPanel(); + } + }); + + // Re-fit when window resizes + window.addEventListener("resize", () => { + if (!panel.classList.contains("d-none")) fitPanel(); + }); + + // Initial fit if panel starts open + if (this._open) setTimeout(fitPanel, 50); + + // Close panel when map is clicked + map.on("click", () => { + if (!panel.classList.contains("d-none")) { + panel.classList.add("d-none"); + toggleBtn.setAttribute("aria-expanded", "false"); + toggleBtn.parentElement.classList.remove("no-border"); + localStorage.setItem("layers.open", "0"); + } + }); + + L.DomEvent.disableClickPropagation(c); + + return c; + }, + + /** Rebuild the overlay grid (e.g. after a new collaborative layer is created). */ + refresh() { + if (!this._container) return; + this._renderOverlays(); + this._searchFilter?.(""); + }, + + _renderOverlays() { + const map = this._map; + const c = this._container; + // --- Overlays: group by def.group --- const overlayDefs = self.mapVM.getOverlayDefsForControl() || []; const groups = new Map(); @@ -3088,6 +3232,7 @@ function VM() { // --- Build two-column grid of always-visible groups --- const grid = c.querySelector(".ld-grid"); + grid.innerHTML = ""; groups.forEach((defs, groupKey) => { const cell = document.createElement("div"); @@ -3179,88 +3324,6 @@ function VM() { cell.appendChild(body); grid.appendChild(cell); }); - - // --- Search filter --- - const searchInput = c.querySelector(".ld-search-input"); - const searchFilter = (query) => { - const q = query.toLowerCase().trim(); - const cells = grid.querySelectorAll(".ld-grid-cell"); - - cells.forEach(cell => { - const buttons = cell.querySelectorAll(".ld-overlay-btn"); - let anyVisible = false; - - buttons.forEach(btn => { - let shouldShow = !q; // Show all if no query - - if (q) { - // Extract label from the span.me-2 text content - const labelSpan = btn.querySelector("span.me-2"); - const label = labelSpan ? labelSpan.textContent.trim().toLowerCase() : ""; - shouldShow = label.includes(q); - } - - btn.style.setProperty("display", shouldShow ? "" : "none", "important"); - if (shouldShow) anyVisible = true; - }); - - // Show cell only if at least one button is visible - cell.style.setProperty("display", anyVisible ? "" : "none", "important"); - }); - }; - - searchInput.addEventListener("input", (e) => { - searchFilter(e.target.value); - }); - - // --- Toggle button --- - const toggleBtn = c.querySelector(".ld-toggle-btn"); - const panel = c.querySelector(".ld-panel"); - - const fitPanel = () => { - requestAnimationFrame(() => { - const rect = panel.getBoundingClientRect(); - const avail = window.innerHeight - rect.top - 20; // 20px bottom margin - panel.style.maxHeight = Math.max(avail, 160) + "px"; - }); - }; - - L.DomEvent.on(toggleBtn, "click", (ev) => { - L.DomEvent.stop(ev); - const hidden = panel.classList.toggle("d-none"); - toggleBtn.setAttribute("aria-expanded", (!hidden).toString()); - toggleBtn.parentElement.classList.toggle("no-border", !hidden); - localStorage.setItem("layers.open", hidden ? "0" : "1"); - if (!hidden) { - // Clear search when opening - searchInput.value = ""; - searchFilter(""); - fitPanel(); - } - }); - - // Re-fit when window resizes - window.addEventListener("resize", () => { - if (!panel.classList.contains("d-none")) fitPanel(); - }); - - // Initial fit if panel starts open - if (this._open) setTimeout(fitPanel, 50); - - // Close panel when map is clicked - map.on("click", () => { - if (!panel.classList.contains("d-none")) { - panel.classList.add("d-none"); - toggleBtn.setAttribute("aria-expanded", "false"); - toggleBtn.parentElement.classList.remove("no-border"); - localStorage.setItem("layers.open", "0"); - } - }); - - L.DomEvent.disableClickPropagation(c); - - this._container = c; - return c; }, diff --git a/src/pages/tasking/mapLayers/collabLayer.js b/src/pages/tasking/mapLayers/collabLayer.js new file mode 100644 index 00000000..9243c47f --- /dev/null +++ b/src/pages/tasking/mapLayers/collabLayer.js @@ -0,0 +1,451 @@ +import L from "leaflet"; +import { MARKER_ICON_GROUPS, DEFAULT_MARKER_ICON_KEY, MARKER_COLOR_SWATCHES, buildMarkerBadgeIcon, faClassForIconKey } from "../components/collab_marker_icons.js"; +import { + listLayers, + createLayer, + fetchLayerMarkers, + upsertMarker, + deleteMarker, +} from "../utils/collabLayerSync.js"; + +const REFRESH_MS = 10000; // polling only fires while the layer is visible (registerPollingLayer's hasLayer gate) +const DEFAULT_FILL = MARKER_COLOR_SWATCHES[5]; // blue -- also the first swatch highlighted as "active" for a new marker + +const layerKeyFor = (layerId) => `collab-${layerId}`; + +function timeAgo(iso) { + if (!iso) return ""; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return ""; + const mins = Math.round(ms / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.round(hrs / 24)}d ago`; +} + +/** Collaborative layers currently toggled visible on the map. */ +function visibleCollabLayers(vm) { + return (vm.mapVM.collabLayers() || []).filter((layer) => { + const entry = vm.mapVM.onlineLayers.get(layerKeyFor(layer.id)); + return entry && vm.mapVM.map.hasLayer(entry.layerGroup); + }); +} + +/** + * Register the polling Leaflet layer for a single collaborative layer. + * Visibility is controlled entirely by the existing layers drawer / + * `ov.` mechanism already built into registerPollingLayer + + * getOverlayDefsForControl — no separate "enabled set" is needed since + * polling is a no-op while the layer isn't visible. + */ +function registerLayerPolling(vm, apiUrl, layer, actorId) { + const key = layerKeyFor(layer.id); + vm.mapVM.registerPollingLayer(key, { + label: layer.name, + menuGroup: "Collaborative Layers", + refreshMs: REFRESH_MS, + visibleByDefault: false, + fetchFn: () => fetchLayerMarkers(apiUrl, layer.id), + drawFn: (layerGroup, data) => drawCollabMarkers(vm, layerGroup, data, apiUrl, layer.id, key, actorId), + }); +} + +/** + * Fetch the list of collaborative layers for the org, store it on the + * MapVM for the config modal to bind to, and register/refresh polling + * layers for any layer not already registered. + */ +export async function refreshCollabLayerList(vm, apiUrl, actorId) { + const layers = await listLayers(apiUrl); + vm.mapVM.collabLayers(layers); + layers.forEach((layer) => registerLayerPolling(vm, apiUrl, layer, actorId)); + vm.mapVM.layersDrawer?.refresh?.(); + return layers; +} + +/** + * Called once at startup (main.js), alongside the other register*Layer + * calls. The right-click "Add marker" trigger itself is wired up + * separately, into the app's existing map context menu (see + * components/mapContextMenu.js + startAddMarkerFlow/getVisibleCollabLayers + * above) rather than a second contextmenu listener here. + */ +export async function registerCollabLayers(vm, apiUrl, actorId) { + await refreshCollabLayerList(vm, apiUrl, actorId); +} + +/** Create a new named layer, register its polling layer immediately, and refresh the drawer. */ +export async function createCollabLayer(vm, apiUrl, name, actorId) { + const layer = await createLayer(apiUrl, name, actorId); + if (!layer) return null; + const list = vm.mapVM.collabLayers(); + vm.mapVM.collabLayers([...list, layer]); + registerLayerPolling(vm, apiUrl, layer, actorId); + vm.mapVM.layersDrawer?.refresh?.(); + return layer; +} + +// ── Drawing ────────────────────────────────────────────────────────── + +function drawCollabMarkers(vm, layerGroup, data, apiUrl, layerId, key, actorId) { + const markers = (data?.markers || []).filter((m) => !m.deleted); + markers.forEach((marker) => { + const icon = buildMarkerBadgeIcon({ icon: marker.icon, fill: marker.fill || DEFAULT_FILL }); + + const leafletMarker = L.marker([marker.lat, marker.lng], { icon }); + leafletMarker.bindPopup(() => buildMarkerPopupEl(vm, apiUrl, layerId, key, marker, actorId), { + minWidth: 240, + maxWidth: 280, + }); + layerGroup.addLayer(leafletMarker); + }); +} + +// Any marker on a visible layer can be edited/deleted -- protection against +// accidental changes comes from requiring an explicit Edit/Delete button +// click (and a confirm step for delete), not from a separate "edit mode". +function buildMarkerPopupEl(vm, apiUrl, layerId, key, marker, actorId) { + const el = document.createElement("div"); + el.className = "collab-marker-popup"; + + const escHtml = (s) => String(s || "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); + + el.innerHTML = ` +
${escHtml(marker.description) || "No description"}
+
Added by user ${escHtml(marker.createdBy)}${marker.updatedAt ? ` · updated ${timeAgo(marker.updatedAt)}` : ""}
+
+ + +
+
+ Delete this marker? + + +
+ `; + + const editBtn = el.querySelector(".collab-edit-marker-btn"); + const deleteBtn = el.querySelector(".collab-delete-marker-btn"); + const confirmBox = el.querySelector(".collab-marker-confirm"); + const actionsBox = el.querySelector(".collab-marker-actions"); + const confirmDeleteBtn = el.querySelector(".collab-confirm-delete-btn"); + const cancelDeleteBtn = el.querySelector(".collab-cancel-delete-btn"); + + editBtn.addEventListener("click", () => { + vm.mapVM.map.closePopup(); + openMarkerForm(vm, apiUrl, layerId, key, actorId, marker, L.latLng(marker.lat, marker.lng)); + }); + + deleteBtn.addEventListener("click", () => { + actionsBox.classList.add("d-none"); + confirmBox.classList.remove("d-none"); + }); + cancelDeleteBtn.addEventListener("click", () => { + confirmBox.classList.add("d-none"); + actionsBox.classList.remove("d-none"); + }); + confirmDeleteBtn.addEventListener("click", async () => { + await deleteMarker(apiUrl, layerId, marker.id, actorId); + vm.mapVM.refreshPollingLayer(key); + }); + + // Resolve the raw user id shown above into a display name once it's + // available (cached/deduped by vm.resolvePersonName). The placeholder + // stays if the marker's own popup gets closed/rebuilt before this + // resolves -- updating a detached node is a harmless no-op. + if (marker.createdBy && vm.resolvePersonName) { + const authorEl = el.querySelector(".collab-marker-author"); + vm.resolvePersonName(marker.createdBy).then((name) => { + if (authorEl && name) authorEl.textContent = name; + }); + } + + return el; +} + +// ── Marker create/edit form (inline popup) ────────────────────────── + +function buildIconPickerHtml(selectedIcon) { + return MARKER_ICON_GROUPS.map((group) => ` +
${group.group}
+
+ ${group.icons.map((i) => ` + + `).join("")} +
+ `).join(""); +} + +function buildColorSwatchesHtml(selectedFill) { + return MARKER_COLOR_SWATCHES.map((color) => ` + + `).join(""); +} + +/** + * Open an inline popup form (create if `marker` is null, edit otherwise) + * at the given latlng. Only an explicit Save click writes data. + * + * Icon and color are each picked from a small dropdown toggle button, with + * a live preview badge showing the combined result. Both dropdowns render + * as floating panels appended to the map container -- outside the Leaflet + * popup's own content -- so opening/closing either one never changes the + * popup's size or makes it reposition itself. + */ +function openMarkerForm(vm, apiUrl, layerId, key, actorId, marker, latlng) { + let icon = marker?.icon || DEFAULT_MARKER_ICON_KEY; + let fill = marker?.fill || DEFAULT_FILL; + + const el = document.createElement("div"); + el.className = "collab-marker-form"; + el.innerHTML = ` +
+ + Preview +
+
+ + +
+ +
+ + +
+ `; + + const previewEl = el.querySelector(".collab-style-preview"); + const iconToggle = el.querySelector(".collab-icon-toggle"); + const colorToggle = el.querySelector(".collab-color-toggle"); + + const renderPreview = () => { + previewEl.innerHTML = ``; + previewEl.querySelector(".collab-marker-badge").style.background = fill; + iconToggle.querySelector(".collab-toggle-icon").className = `fas ${faClassForIconKey(icon)} collab-toggle-icon`; + colorToggle.querySelector(".collab-toggle-swatch").style.background = fill; + }; + renderPreview(); + + iconToggle.addEventListener("click", () => { + if (iconToggle.classList.contains("open")) { closeFloatingDropdown(); return; } + openFloatingDropdown(vm, iconToggle, "collab-icon-dropdown", (panel) => { + const render = () => { panel.innerHTML = buildIconPickerHtml(icon); }; + render(); + panel.addEventListener("click", (e) => { + const btn = e.target.closest(".collab-icon-btn"); + if (!btn) return; + icon = btn.dataset.icon; + renderPreview(); + render(); // keep the dropdown open so multiple icons can be browsed + }); + }); + }); + + colorToggle.addEventListener("click", () => { + if (colorToggle.classList.contains("open")) { closeFloatingDropdown(); return; } + openFloatingDropdown(vm, colorToggle, "collab-color-dropdown", (panel) => { + panel.innerHTML = `
`; + const swatches = panel.querySelector(".collab-color-swatches"); + swatches.innerHTML = buildColorSwatchesHtml(fill); + swatches.addEventListener("click", (e) => { + const btn = e.target.closest(".collab-color-swatch"); + if (!btn) return; + fill = btn.dataset.color; + renderPreview(); + closeFloatingDropdown(); // color is a single quick pick, close straight away + }); + }); + }); + + const popup = L.popup({ minWidth: 220, maxWidth: 260, closeOnClick: false, autoPanPadding: [16, 16] }) + .setLatLng(latlng) + .setContent(el) + .openOn(vm.mapVM.map); + + popup.on("remove", closeFloatingDropdown); + + el.querySelector(".collab-cancel-btn").addEventListener("click", () => { + vm.mapVM.map.closePopup(popup); + }); + + el.querySelector(".collab-save-btn").addEventListener("click", async () => { + const description = el.querySelector(".collab-desc-input").value.trim(); + const payload = { + id: marker?.id, + lat: latlng.lat, + lng: latlng.lng, + icon, + fill, + description, + }; + vm.mapVM.map.closePopup(popup); + await upsertMarker(apiUrl, layerId, payload, actorId); + vm.mapVM.refreshPollingLayer(key); + }); +} + +// Singleton so only one dropdown (icon or color, across any open marker +// form) is ever on screen at once. +let dropdownCloser = null; + +function closeFloatingDropdown() { + if (dropdownCloser) { + dropdownCloser(); + dropdownCloser = null; + } +} + +/** + * Shared plumbing for a floating panel anchored below `anchorEl`, appended + * to the map container rather than any Leaflet popup's content. `populate` + * is called once with the empty panel element to fill it in and wire its + * own interactions. + */ +function openFloatingDropdown(vm, anchorEl, className, populate) { + closeFloatingDropdown(); + + const map = vm.mapVM.map; + const mapContainer = map.getContainer(); + const anchorRect = anchorEl.getBoundingClientRect(); + const containerRect = mapContainer.getBoundingClientRect(); + + const panel = document.createElement("div"); + panel.className = className; + panel.style.left = `${anchorRect.left - containerRect.left}px`; + panel.style.top = `${anchorRect.bottom - containerRect.top + 4}px`; + + populate(panel); + + mapContainer.appendChild(panel); + anchorEl.classList.add("open"); + L.DomEvent.disableClickPropagation(panel); + L.DomEvent.disableScrollPropagation(panel); + + const onMapInteract = () => closeFloatingDropdown(); + const onDocClick = (e) => { + if (!panel.contains(e.target) && !anchorEl.contains(e.target)) closeFloatingDropdown(); + }; + const onKeyDown = (e) => { if (e.key === "Escape") closeFloatingDropdown(); }; + + map.on("zoomstart dragstart", onMapInteract); + document.addEventListener("click", onDocClick, true); + document.addEventListener("keydown", onKeyDown); + + dropdownCloser = () => { + panel.remove(); + anchorEl.classList.remove("open"); + map.off("zoomstart dragstart", onMapInteract); + document.removeEventListener("click", onDocClick, true); + document.removeEventListener("keydown", onKeyDown); + }; +} + +// ── Right-click "add marker" ───────────────────────────────────────── +// +// Only available when at least one collaborative layer is currently +// visible. With exactly one visible layer, right-click opens the marker +// form immediately. With more than one visible, right-click shows a small +// picker so the user chooses which layer receives the new marker. + +let openContextMenu = null; // cleanup for a currently-open picker menu, if any + +function closeContextMenu() { + if (openContextMenu) { + openContextMenu(); + openContextMenu = null; + } +} + +function showLayerPickerMenu(vm, apiUrl, actorId, layers, containerPoint, latlng) { + closeContextMenu(); + + const map = vm.mapVM.map; + const mapContainer = map.getContainer(); + + const menu = document.createElement("div"); + menu.className = "collab-context-menu"; + menu.style.left = `${containerPoint.x}px`; + menu.style.top = `${containerPoint.y}px`; + + const header = document.createElement("div"); + header.className = "collab-context-menu-header"; + header.textContent = "Add marker to…"; + menu.appendChild(header); + + // Items scroll independently so the header stays put and the menu + // never runs off-screen when many layers are visible at once. + const itemsBox = document.createElement("div"); + itemsBox.className = "collab-context-menu-items"; + menu.appendChild(itemsBox); + + const sortedLayers = layers.slice().sort((a, b) => (a.name || "").localeCompare(b.name || "")); + sortedLayers.forEach((layer) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "collab-context-menu-item"; + item.textContent = layer.name; + item.title = layer.name; + item.addEventListener("click", () => { + closeContextMenu(); + openMarkerForm(vm, apiUrl, layer.id, layerKeyFor(layer.id), actorId, null, latlng); + }); + itemsBox.appendChild(item); + }); + + mapContainer.appendChild(menu); + L.DomEvent.disableClickPropagation(menu); + + const onMapInteract = () => closeContextMenu(); + const onKeyDown = (e) => { if (e.key === "Escape") closeContextMenu(); }; + map.on("click zoomstart dragstart", onMapInteract); + document.addEventListener("keydown", onKeyDown); + + openContextMenu = () => { + menu.remove(); + map.off("click zoomstart dragstart", onMapInteract); + document.removeEventListener("keydown", onKeyDown); + }; +} + +/** Whether the "Add marker" item in the map's right-click context menu should be shown. */ +export function getVisibleCollabLayers(vm) { + return visibleCollabLayers(vm); +} + +/** + * Entry point for the "Add marker to shared layer" item in the app's + * existing right-click context menu (components/mapContextMenu.js). With + * exactly one visible layer, opens the marker form immediately; with more + * than one, shows a small picker so the user chooses which layer receives + * the new marker. + */ +export function startAddMarkerFlow(vm, apiUrl, actorId, latlng) { + closeContextMenu(); + + const visible = visibleCollabLayers(vm); + if (visible.length === 0) return; + + if (visible.length === 1) { + openMarkerForm(vm, apiUrl, visible[0].id, layerKeyFor(visible[0].id), actorId, null, latlng); + return; + } + + const containerPoint = vm.mapVM.map.latLngToContainerPoint(latlng); + showLayerPickerMenu(vm, apiUrl, actorId, visible, containerPoint, latlng); +} diff --git a/src/pages/tasking/utils/collabLayerSync.js b/src/pages/tasking/utils/collabLayerSync.js new file mode 100644 index 00000000..347bc772 --- /dev/null +++ b/src/pages/tasking/utils/collabLayerSync.js @@ -0,0 +1,227 @@ +/** + * collabLayerSync.js + * + * Client-side helper for the collaborative map-layers feature: listing + * layers for an org, creating layers, and reading/writing markers via + * the Lambda / S3 backend. + * + * Mirrors the fetch/cache conventions of defaultAssetSync.js: GETs are + * cached to localStorage so data survives reloads and is available + * immediately on next open; mutations apply an optimistic local update + * before firing the remote write. + */ + +const LAMBDA_BASE = 'https://lambda.lighthouse-extension.com/lad/map-layers'; + +const LS_INDEX_KEY = 'lh_collabLayers_index'; // cached layer list for the current org +const layerCacheKey = (layerId) => `lh_collabLayer_${layerId}`; + +// ── Local cache helpers ───────────────────────────────────────────── + +/** + * Read the cached layer index (list of layers for the current org). + * @returns {Array} + */ +export function loadCachedLayerIndex() { + try { + return JSON.parse(localStorage.getItem(LS_INDEX_KEY)) || []; + } catch { + return []; + } +} + +function saveCachedLayerIndex(layers) { + localStorage.setItem(LS_INDEX_KEY, JSON.stringify(layers || [])); +} + +/** + * Read a cached layer (including its markers). + * @param {string} layerId + * @returns {Object|null} + */ +export function loadCachedLayer(layerId) { + try { + return JSON.parse(localStorage.getItem(layerCacheKey(layerId))) || null; + } catch { + return null; + } +} + +function saveCachedLayer(layerId, layer) { + localStorage.setItem(layerCacheKey(layerId), JSON.stringify(layer)); +} + +// ── List / create layers ──────────────────────────────────────────── + +/** + * List collaborative layers for an org (layers unused for 120+ days are + * excluded server-side, not deleted). + * @param {string} apiUrl + * @returns {Promise>} + */ +export async function listLayers(apiUrl) { + if (!apiUrl) return loadCachedLayerIndex(); + + try { + const url = `${LAMBDA_BASE}?apiUrl=${encodeURIComponent(apiUrl)}`; + const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' } }); + if (!res.ok) { + console.warn('[collabLayerSync] list failed:', res.status); + return loadCachedLayerIndex(); + } + const { layers } = await res.json(); + saveCachedLayerIndex(layers || []); + return layers || []; + } catch (err) { + console.warn('[collabLayerSync] list error:', err); + return loadCachedLayerIndex(); + } +} + +/** + * Create a new named collaborative layer. + * @param {string} apiUrl + * @param {string} name + * @param {string} actorId + * @returns {Promise} the created layer summary, or null on failure + */ +export async function createLayer(apiUrl, name, actorId) { + const trimmed = (name || '').trim(); + if (!apiUrl || !trimmed) return null; + + try { + const res = await fetch(LAMBDA_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiUrl, name: trimmed, createdBy: String(actorId) }), + }); + if (!res.ok) { + throw new Error(`Create layer failed with status ${res.status}`); + } + const layer = await res.json(); + + // Optimistically add to the cached index + const index = loadCachedLayerIndex(); + index.push(layer); + saveCachedLayerIndex(index); + + return layer; + } catch (err) { + console.warn('[collabLayerSync] createLayer error:', err); + return null; + } +} + +// ── Layer markers ──────────────────────────────────────────────────── + +/** + * Fetch a layer's markers (also counts as "use" server-side, so this + * layer won't age out of listLayers()). + * @param {string} apiUrl + * @param {string} layerId + * @returns {Promise} the layer, including its markers array + */ +export async function fetchLayerMarkers(apiUrl, layerId) { + if (!apiUrl || !layerId) return loadCachedLayer(layerId); + + try { + const url = `${LAMBDA_BASE}/${encodeURIComponent(layerId)}?apiUrl=${encodeURIComponent(apiUrl)}`; + const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' } }); + if (!res.ok) { + console.warn('[collabLayerSync] fetchLayerMarkers failed:', res.status); + return loadCachedLayer(layerId); + } + const layer = await res.json(); + saveCachedLayer(layerId, layer); + return layer; + } catch (err) { + console.warn('[collabLayerSync] fetchLayerMarkers error:', err); + return loadCachedLayer(layerId); + } +} + +/** + * Create or update a marker on a layer. Applies an optimistic local + * update to the cached layer before firing the remote write. + * @param {string} apiUrl + * @param {string} layerId + * @param {{id?: string, lat: number, lng: number, shape: string, fill: string, stroke: string, description: string}} marker + * @param {string} actorId + * @returns {Promise} the saved marker (with server-assigned id/timestamps), or null on failure + */ +export async function upsertMarker(apiUrl, layerId, marker, actorId) { + if (!apiUrl || !layerId || !marker) return null; + + // Optimistic local update + const cached = loadCachedLayer(layerId) || { id: layerId, markers: [] }; + cached.markers = Array.isArray(cached.markers) ? cached.markers : []; + const now = new Date().toISOString(); + const optimistic = { + ...marker, + id: marker.id || `local-${Date.now()}`, + updatedBy: String(actorId), + updatedAt: now, + createdBy: marker.createdBy || String(actorId), + createdAt: marker.createdAt || now, + deleted: false, + }; + const idx = cached.markers.findIndex((m) => m.id === optimistic.id); + if (idx >= 0) { + cached.markers[idx] = optimistic; + } else { + cached.markers.push(optimistic); + } + saveCachedLayer(layerId, cached); + + try { + const res = await fetch(`${LAMBDA_BASE}/${encodeURIComponent(layerId)}/features`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiUrl, marker, actorId: String(actorId) }), + }); + if (!res.ok) { + throw new Error(`upsertMarker failed with status ${res.status}`); + } + const saved = await res.json(); + + // Reconcile optimistic entry with server-assigned id/timestamps + const latest = loadCachedLayer(layerId) || cached; + const i = latest.markers.findIndex((m) => m.id === optimistic.id); + if (i >= 0) latest.markers[i] = saved; + else latest.markers.push(saved); + saveCachedLayer(layerId, latest); + + return saved; + } catch (err) { + console.warn('[collabLayerSync] upsertMarker error:', err); + return optimistic; + } +} + +/** + * Delete (soft-delete) a marker from a layer. Optimistically removes it + * from the local cache, then fires the remote write. + * @param {string} apiUrl + * @param {string} layerId + * @param {string} markerId + * @param {string} actorId + * @returns {Promise} + */ +export async function deleteMarker(apiUrl, layerId, markerId, actorId) { + if (!apiUrl || !layerId || !markerId) return; + + // Optimistic local update + const cached = loadCachedLayer(layerId); + if (cached && Array.isArray(cached.markers)) { + cached.markers = cached.markers.filter((m) => m.id !== markerId); + saveCachedLayer(layerId, cached); + } + + try { + const url = `${LAMBDA_BASE}/${encodeURIComponent(layerId)}/features/${encodeURIComponent(markerId)}` + + `?apiUrl=${encodeURIComponent(apiUrl)}&actorId=${encodeURIComponent(actorId)}`; + await fetch(url, { method: 'DELETE' }); + } catch (err) { + console.warn('[collabLayerSync] deleteMarker error:', err); + } +} diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 91fb4831..015e7fda 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -3,6 +3,7 @@ import ko from 'knockout'; import * as bootstrap from 'bootstrap5'; // Modal, Tooltip, etc. import { Enum } from '../utils/enum.js'; +import { createCollabLayer } from '../mapLayers/collabLayer.js'; @@ -188,6 +189,100 @@ export function ConfigVM(root, deps) { onCancel && onCancel(); }; } + // ── Collaborative map layers ── + self.collabLayers = root.mapVM?.collabLayers || ko.observableArray([]); + self.newLayerName = ko.observable(''); + self.creatingCollabLayer = ko.observable(false); + self.collabLayerError = ko.observable(''); + self.collabLayerSearch = ko.observable(''); // filters the (possibly long) layer list below + + function relativeTime(iso) { + if (!iso) return 'never'; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return 'just now'; + const mins = Math.round(ms / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.round(hrs / 24)}d ago`; + } + + // Applies the actual show/hide side-effect when a row's View switch + // changes. `row.key` is the unprefixed registry key used by + // mapVM.onlineLayers; `row.drawerKey` is the 'online-'-prefixed key the + // layers drawer uses for its `ov.` localStorage visibility flag + // (see getOverlayDefsForControl in Map.js) — both must be kept in sync. + // Any user can add/edit/delete markers on a visible layer directly on + // the map (right-click to add, popup buttons to edit/delete) — there's + // no separate "edit mode" toggle here, just View. + self._applyCollabLayerView = (row, enabled) => { + const layerObj = root.mapVM.onlineLayers.get(row.key)?.layerGroup; + if (!layerObj) return; + if (enabled) { + root.mapVM.map.addLayer(layerObj); + localStorage.setItem(`ov.${row.drawerKey}`, '1'); + } else { + root.mapVM.map.removeLayer(layerObj); + localStorage.setItem(`ov.${row.drawerKey}`, '0'); + } + root.mapVM.layersDrawer?.refresh?.(); + }; + + // Sorted alphabetically so a long list stays scannable; filtered by + // collabLayerSearch below for the same reason. + self.collabLayerRows = ko.pureComputed(() => self.collabLayers() + .slice() + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .map(layer => { + const key = `collab-${layer.id}`; + const drawerKey = `online-${key}`; + const row = { + layer, + key, + drawerKey, + name: layer.name, + markerCount: layer.markerCount || 0, + lastUsedLabel: relativeTime(layer.lastUsedAt), + viewEnabled: ko.observable(localStorage.getItem(`ov.${drawerKey}`) === '1'), + }; + row.viewEnabled.subscribe((v) => self._applyCollabLayerView(row, v)); + return row; + })); + + self.filteredCollabLayerRows = ko.pureComputed(() => { + const q = self.collabLayerSearch().trim().toLowerCase(); + const rows = self.collabLayerRows(); + if (!q) return rows; + return rows.filter(row => row.name.toLowerCase().includes(q)); + }); + + self.createCollabLayer = async () => { + const name = self.newLayerName().trim(); + if (!name || !deps.apiUrl) return; + + self.collabLayerError(''); + self.creatingCollabLayer(true); + try { + const layer = await createCollabLayer(root, deps.apiUrl, name, deps.userId); + if (!layer) throw new Error('Create failed'); + self.newLayerName(''); + } catch (err) { + console.error('Error creating collaborative layer:', err); + self.collabLayerError('Failed to create layer. Try again later.'); + } finally { + self.creatingCollabLayer(false); + } + }; + + // Named method (rather than an inline function in the data-bind attribute) + // because knockout-secure-binding's restricted grammar doesn't support + // control-flow statements like `if` inside inline function literals. + self.handleNewLayerNameKeydown = (data, event) => { + if (event.key === 'Enter') self.createCollabLayer(); + return true; + }; + self.fetchPeriod = ko.observable(7).extend({ min: 0, max: 31, digit: true }); self.fetchForward = ko.observable(0).extend({ min: 0, max: 31, digit: true }); self.showAdvanced = ko.observable(false); diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index d4d9b3f8..df697610 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -618,6 +618,9 @@ export function MapVM(Lmap, root) { return defs; }; + // --- Collaborative map layers --- + self.collabLayers = ko.observableArray([]); // layer summaries for the current org, from listLayers() + // helpers self.setOpen = (kind, ref) => self.openPopup({ kind, id: ref.id?.(), ref }); self.clearOpen = () => self.openPopup(null); diff --git a/src/shared/BeaconClient.js b/src/shared/BeaconClient.js index 60260ba3..ab3c0ca8 100644 --- a/src/shared/BeaconClient.js +++ b/src/shared/BeaconClient.js @@ -21,11 +21,12 @@ import * as messages from './BeaconClient/messages.js'; import * as suppliers from './BeaconClient/suppliers.js'; import * as images from './BeaconClient/images.js'; import * as icems from './BeaconClient/icems.js'; +import * as people from './BeaconClient/people.js'; -export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems }; +export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people }; // re-export functions -export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, toFormUrlEncoded }; +export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people, toFormUrlEncoded }; export function toFormUrlEncoded(obj) { const params = []; for (const key in obj) { diff --git a/src/shared/BeaconClient/people.js b/src/shared/BeaconClient/people.js new file mode 100644 index 00000000..a4ff52c5 --- /dev/null +++ b/src/shared/BeaconClient/people.js @@ -0,0 +1,24 @@ +import $ from 'jquery'; + +export function getSimplePerson(personId, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'GET', + url: host + '/Api/v1/People/GetSimplePerson/' + encodeURIComponent(personId) + '?LighthouseFunction=GetSimplePerson&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); +} diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 106bb642..5417a897 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -2701,6 +2701,10 @@

aria-labelledby="headingMapLayers" data-bs-parent="#configOtherSettingsAccordion">
+ +
+ Display Settings +
@@ -2784,6 +2788,66 @@

+ +
+ +

+ Collaborative Layers +
+

+ Shared marker layers that everyone in your organisation can see and + edit together. Enable View to show a layer on the map — once + visible, right-click anywhere on the map to drop a marker on it. + Layers unused for 120 days stop appearing here, but their data + isn't deleted. +

+ +
+ + +
+
+ +
+ + +
+ +
+
    +
  • +
    +
    +
    + markers · + used +
    +
    +
    + + +
    +
  • +
+
+
+ No collaborative layers yet — create one above. +
+
+ No layers match "". +
+ @@ -3861,6 +3925,9 @@ diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index 1cf64883..bab5b25b 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -2602,6 +2602,36 @@ overflow: hidden; overflow: hidden; text-overflow: ellipsis; } +/* Collapsed by default (see tasking.html) so the three permission groups + below don't eat vertical space in the accordion body unless opened. */ +.collab-permissions-toggle { + font-size: 12px; +} +.collab-permissions-toggle .fa-chevron-right { + transition: transform 0.15s ease; +} +.collab-permissions-toggle[aria-expanded="true"] .fa-chevron-right { + transform: rotate(90deg); +} +.collab-new-layer-options { + display: flex; + flex-wrap: wrap; + column-gap: 12px; + row-gap: 2px; +} +.collab-new-layer-options .form-check { + margin-right: 0; /* column-gap above replaces Bootstrap's inline margin */ +} +.collab-layer-delete-confirm { + display: flex; + align-items: center; + gap: 4px; +} +.collab-layer-delete-confirm .btn { + font-size: 11px; + padding: 1px 6px; + line-height: 1.4; +} /* ── Collaborative marker popup (view/edit) ── */ .collab-marker-popup { @@ -2666,6 +2696,11 @@ overflow: hidden; padding: 1px 6px; line-height: 1.4; } +.collab-marker-comments-disabled { + color: #888; + font-size: 11px; + margin-bottom: 6px; +} /* Aesthetic-only text limit indicator (description/comment textareas) -- never affects layout beyond its own line, so it can't destabilize the popup's size the way growing/shrinking the textarea itself would. */ From d78cf8392133303c85f0c04c9115d5d5573c4f57 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 4 Aug 2026 22:48:25 +1000 Subject: [PATCH 14/26] Fix flash of unbound placeholder content before config modal shows (#391) The page reveal (body opacity 1) fired on the plain DOMContentLoaded event, racing ahead of the async require(["knockout", ...]) load and ko.applyBindings call. That let raw, unbound markup (and the config modal popping in afterward) flash briefly on page load. Now the reveal happens right after bindings are applied and the modal is already open. --- src/pages/tasking/main.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index ab8e3e01..33581ba1 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -3683,6 +3683,10 @@ document.addEventListener('DOMContentLoaded', function () { const configModalEl = document.getElementById('configModal'); bootstrap.Modal.getOrCreateInstance(configModalEl).show(); + // reveal the page now that bindings are applied and the modal is open, + // so we don't flash unbound placeholder content beforehand + document.body.style.opacity = '1'; + installModalHotkeys({ modalEl: configModalEl, onSave: () => myViewModel.config.saveAndCloseAndLoad(), @@ -3775,11 +3779,6 @@ document.addEventListener('DOMContentLoaded', function () { }) -// show page once DOM + CSS are ready (don't wait for map tiles) -document.addEventListener('DOMContentLoaded', function () { - document.body.style.opacity = '1'; -}); - function getSearchParameters() { From 53799c47648238f61e16e45e3030dff146613105 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 4 Aug 2026 22:50:22 +1000 Subject: [PATCH 15/26] Give collaborative layer markers their own map pane (#392) Collab markers previously fell into Leaflet's default markerPane (fixed z-index 600), so they weren't part of the layer-order drawer and could render inconsistently relative to Incident/Asset markers depending on the user's pane order config. Adds a pane-collab pane, defaulted to sit under Incident markers but above Asset markers, and makes it a first-class entry in the draggable layer order list. Existing saved pane orders get the new pane inserted at its default relative position instead of always appended at the bottom. Co-authored-by: Claude Sonnet 5 --- src/pages/tasking/main.js | 4 ++++ src/pages/tasking/mapLayers/collabLayer.js | 2 +- src/pages/tasking/viewmodels/Config.js | 15 +++++++++++---- static/pages/tasking.html | 7 +++++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 33581ba1..a7f3bff1 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -352,6 +352,10 @@ map.createPane('pane-top'); map.getPane('pane-top').style.zIndex = 600; map.createPane('pane-top-plus'); map.getPane('pane-top-plus').style.zIndex = 601; +map.createPane('pane-collab'); map.getPane('pane-collab').style.zIndex = 650; +map.createPane('pane-collab-plus'); map.getPane('pane-collab-plus').style.zIndex = 651; + + map.createPane('pane-tippy-top'); map.getPane('pane-tippy-top').style.zIndex = 700; map.createPane('pane-tippy-top-plus'); map.getPane('pane-tippy-top-plus').style.zIndex = 701; diff --git a/src/pages/tasking/mapLayers/collabLayer.js b/src/pages/tasking/mapLayers/collabLayer.js index 76f6e8e2..b67e71dc 100644 --- a/src/pages/tasking/mapLayers/collabLayer.js +++ b/src/pages/tasking/mapLayers/collabLayer.js @@ -204,7 +204,7 @@ function drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, ge markers.forEach((marker) => { const icon = buildMarkerBadgeIcon({ icon: marker.icon, fill: marker.fill || DEFAULT_FILL }); - const leafletMarker = L.marker([marker.lat, marker.lng], { icon }); + const leafletMarker = L.marker([marker.lat, marker.lng], { icon, pane: "pane-collab" }); // Bind a concrete, already-built element rather than Leaflet's // "content factory function" form of bindPopup -- that form gets diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index e7ba0ebd..0096bbca 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -80,8 +80,9 @@ export function ConfigVM(root, deps) { self.paneDefs = [ { id: 'pane-tippy-top', name: 'Incident markers' }, + { id: 'pane-collab', name: 'Collaborative layer markers' }, { id: 'pane-top', name: 'Asset markers' }, - { id: 'pane-middle', name: 'Map overlays icons & labels' }, + { id: 'pane-middle', name: 'Map overlay markers & labels' }, { id: 'pane-lowest', name: 'Map overlay polygons & drawings' } ]; @@ -95,9 +96,15 @@ export function ConfigVM(root, deps) { .filter(Boolean) .map(p => ({ id: p.id, name: p.name })); - // ensure all panes exist (append any missing) - self.paneDefs.forEach(p => { - if (!list.some(x => x.id === p.id)) list.push({ id: p.id, name: p.name }); + // Ensure all panes exist. Panes missing from a saved order (e.g. one + // introduced after the config was last saved) are inserted at their + // default position relative to paneDefs, rather than always at the + // bottom, so a newly-added pane keeps its intended default stacking. + self.paneDefs.forEach((p, defIdx) => { + if (list.some(x => x.id === p.id)) return; + const nextKnownDef = self.paneDefs.slice(defIdx + 1).find(d => list.some(x => x.id === d.id)); + const insertAt = nextKnownDef ? list.findIndex(x => x.id === nextKnownDef.id) : list.length; + list.splice(insertAt, 0, { id: p.id, name: p.name }); }); self.paneOrder(list); diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 6be2d6ca..aeddb226 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -2711,7 +2711,7 @@
- Marker Clustering + Incident Marker Clustering
@@ -2770,9 +2770,12 @@
- Map Icon Layer Order + Marker Layer Order (top → bottom) +
+ Controls the draw order of marker layers on the map. Drag and drop to reorder. +
    From d349dc959a5d3a0e02c6e92b52953247e269c2ad Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Wed, 5 Aug 2026 14:51:57 +1000 Subject: [PATCH 16/26] Add HQ/event attachment, moderator-managed permissions, and a subscriptions model for collaborative map layers (#393) - Every layer must now belong to an HQ (required, defaults to ?hq=) and can optionally be attached to a Beacon event; both are searchable via new BeaconClient modules (entities-backed HQ search, new events.js, new users.js for moderator search). - Marker/delete/comment permissions are now a 3-way mode (anyone/creator/moderators) instead of booleans, with a shared moderator list per layer. Moderators can now manage that list themselves, not just the creator. - Replaced the HQ-filtered "list with a View toggle" model with a subscriptions model: a "My layers" list (always cross-HQ, so unsubscribing never depends on knowing which HQ a layer came from) plus a separate "Find a layer" search to discover and subscribe. Show/hide is now solely the map's own Layers control's job. - listLayers.js supports server-side HQ filtering; permission checks centralized in lib/permissions.js and reused across handlers. - Marker/comment Ops Log entries now carry the layer's EventId when one is attached. Co-authored-by: Claude Sonnet 5 --- .../handlers/addMarkerComment.js | 8 +- lambda/map-layers-v2/handlers/createLayer.js | 104 ++- .../map-layers-v2/handlers/deleteFeature.js | 10 +- lambda/map-layers-v2/handlers/deleteLayer.js | 5 +- lambda/map-layers-v2/handlers/listLayers.js | 13 +- .../handlers/updateLayerModerators.js | 73 ++ .../map-layers-v2/handlers/upsertFeature.js | 10 +- lambda/map-layers-v2/index.js | 2 + lambda/map-layers-v2/lib/permissions.js | 45 ++ src/pages/tasking/main.js | 36 + src/pages/tasking/mapLayers/collabLayer.js | 196 ++++- src/pages/tasking/utils/collabLayerSync.js | 154 +++- src/pages/tasking/viewmodels/Config.js | 697 ++++++++++++++++-- src/shared/BeaconClient.js | 6 +- src/shared/BeaconClient/events.js | 32 + src/shared/BeaconClient/users.js | 53 ++ static/pages/tasking.html | 427 +++++++++-- styles/pages/tasking.css | 35 + 18 files changed, 1720 insertions(+), 186 deletions(-) create mode 100644 lambda/map-layers-v2/handlers/updateLayerModerators.js create mode 100644 lambda/map-layers-v2/lib/permissions.js create mode 100644 src/shared/BeaconClient/events.js create mode 100644 src/shared/BeaconClient/users.js diff --git a/lambda/map-layers-v2/handlers/addMarkerComment.js b/lambda/map-layers-v2/handlers/addMarkerComment.js index a4c7e71d..003d7f39 100644 --- a/lambda/map-layers-v2/handlers/addMarkerComment.js +++ b/lambda/map-layers-v2/handlers/addMarkerComment.js @@ -2,6 +2,7 @@ const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { commentMode, isAuthorized } = require('../lib/permissions'); // POST /map-layers/{id}/features/{markerId}/comments // body: { apiUrl, actorId, opsLogId } @@ -10,7 +11,7 @@ const { json, badRequest, notFound, forbidden } = require('../lib/response'); // marker's comment thread. Like upsertFeature's opsLogId, this Lambda only // stores the pointer -- the comment's text/author lives entirely in that // Ops Log entry and is resolved via BeaconClient.operationslog.get(). -module.exports = async function addMarkerComment(event) { +module.exports = async function addMarkerComment(event, claims) { const layerId = event.pathParameters?.id; const markerId = event.pathParameters?.markerId; let body; @@ -23,6 +24,7 @@ module.exports = async function addMarkerComment(event) { const apiUrl = body.apiUrl; const actorId = String(body.actorId || '').slice(0, 100); const opsLogId = Number(body.opsLogId); + const memberId = String(claims?.sub || ''); if (!apiUrl || !layerId || !markerId) { return badRequest('apiUrl, layer id and marker id are required'); @@ -35,8 +37,8 @@ module.exports = async function addMarkerComment(event) { const marker = layer.markers.find((m) => m.id === markerId); if (!marker) return notFound('Marker not found'); - if (layer.disableComments) { - return forbidden('Comments are disabled on this layer'); + if (!isAuthorized(commentMode(layer), layer, memberId)) { + return forbidden('You do not have permission to comment on this layer'); } marker.commentOpsLogIds = Array.isArray(marker.commentOpsLogIds) ? marker.commentOpsLogIds : []; diff --git a/lambda/map-layers-v2/handlers/createLayer.js b/lambda/map-layers-v2/handlers/createLayer.js index e336217f..330c0b03 100644 --- a/lambda/map-layers-v2/handlers/createLayer.js +++ b/lambda/map-layers-v2/handlers/createLayer.js @@ -3,25 +3,89 @@ const crypto = require('crypto'); const { updateIndex, putLayerObject } = require('../lib/s3Store'); const { json, badRequest } = require('../lib/response'); +const { normalizeMode } = require('../lib/permissions'); -// POST /map-layers body: { apiUrl, name, createdBy, readOnly?, allowDeleteByOthers?, disableComments? } +const MAX_MODERATORS = 100; + +/** Sanitize the client-supplied moderator list: [{id, name}], deduped by id. */ +function sanitizeModerators(input) { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out = []; + for (const m of input) { + const id = String(m?.id || '').trim().slice(0, 100); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push({ id, name: String(m?.name || id).trim().slice(0, 200) }); + if (out.length >= MAX_MODERATORS) break; + } + return out; +} + +/** + * Sanitize the optional event attachment: { id, name } -> stored as + * eventId/eventName, or both null if no event (or an incomplete one) was + * given. eventId/eventName are pure display/bookkeeping (like createdBy) -- + * nothing in this feature authorizes against them. + */ +function sanitizeEvent(input) { + const id = String(input?.id || '').trim().slice(0, 50); + if (!id) return { eventId: null, eventName: null, eventIdentifier: null }; + const name = String(input?.name || id).trim().slice(0, 200); + const identifier = String(input?.identifier || '').trim().slice(0, 50) || null; + return { eventId: id, eventName: name, eventIdentifier: identifier }; +} + +/** + * Sanitize the required HQ attachment: { id, name } -> { hqId, hqName }, or + * both null if missing/incomplete (caller must then reject the request -- + * unlike sanitizeEvent, there's no valid "no HQ" case for a layer). + */ +function sanitizeHq(input) { + const id = String(input?.id || '').trim().slice(0, 50); + if (!id) return { hqId: null, hqName: null }; + const name = String(input?.name || id).trim().slice(0, 200); + return { hqId: id, hqName: name }; +} + +// POST /map-layers +// body: { apiUrl, name, createdBy, hq, markerMode?, deleteMode?, commentMode?, moderators?, event? } +// +// The permission modes and moderator list are fixed at creation time -- +// there's no "edit layer settings" flow for the modes themselves, so every +// handler that reads them back off the stored layer/summary can treat them +// as immutable for that layer's lifetime. The moderator list itself *is* +// editable later by the creator (see updateLayerModerators.js) since who +// should moderate a layer can change over an incident's lifetime even when +// the permission structure doesn't. +// +// Each of markerMode/deleteMode/commentMode is one of 'anyone' | 'creator' +// | 'moderators' (default 'anyone' if omitted/invalid): +// - markerMode: who may create/edit/delete markers. Enforced in +// upsertFeature.js / deleteFeature.js. +// - deleteMode: who may delete the layer itself. Enforced in +// deleteLayer.js. +// - commentMode: who may comment on markers. Enforced in +// addMarkerComment.js. +// 'moderators' always additionally allows the creator (see +// lib/permissions.js isAuthorized). +// +// `hq` (required): { id, name } of the Beacon HQ (entity) this layer +// belongs to -- every layer must have one, stored as hqId/hqName. Also +// fixed at creation, no later "reassign HQ" flow. Drives the layer list's +// default HQ filter (Config.js) -- layers created before this field existed +// simply have no hqId and so only ever show up under "All HQs". // -// The three permission flags are fixed at creation time -- there's no -// "edit layer settings" flow, so every handler that reads them back off the -// stored layer/summary can treat them as immutable for that layer's -// lifetime. -// - readOnly (default false): only the creator may create/edit/delete -// markers; everyone else may still comment (unless disableComments). -// Enforced in upsertFeature.js / deleteFeature.js. -// - allowDeleteByOthers (default true): whether anyone, vs. only the -// creator, may delete the layer itself. Enforced in deleteLayer.js. -// - disableComments (default false): blocks comments for everyone, -// including the creator. Enforced in addMarkerComment.js. +// `event` (optional): { id, name } of a Beacon event this layer relates to, +// stored as eventId/eventName -- purely for display in the layer list +// (Config.js), same "fixed at creation" rule as the permission modes above, +// no later "attach/detach event" flow. // // "The creator" for all of the above means `createdByMemberId` -- // `claims.sub`, the Beacon member id off the caller's own verified token -- // not the client-supplied `createdBy` (actorId/personId), which is only -// bookkeeping/display metadata a caller could set to anything. See +// bookkeeping/display metadata a caller could set to anything. Moderator +// ids are the same Beacon member id space (see lib/permissions.js). See // index.js, which passes the verified claims into every handler. module.exports = async function createLayer(event, claims) { let body; @@ -35,21 +99,25 @@ module.exports = async function createLayer(event, claims) { const name = String(body.name || '').trim().slice(0, 200); const createdBy = String(body.createdBy || '').slice(0, 100); const createdByMemberId = String(claims?.sub || ''); - const readOnly = body.readOnly === true; - const allowDeleteByOthers = body.allowDeleteByOthers !== false; - const disableComments = body.disableComments === true; + const markerMode = normalizeMode(body.markerMode) || 'anyone'; + const deleteMode = normalizeMode(body.deleteMode) || 'anyone'; + const commentMode = normalizeMode(body.commentMode) || 'anyone'; + const moderators = sanitizeModerators(body.moderators); + const { eventId, eventName, eventIdentifier } = sanitizeEvent(body.event); + const { hqId, hqName } = sanitizeHq(body.hq); if (!apiUrl || !name) return badRequest('apiUrl and name are required'); + if (!hqId) return badRequest('hq is required'); const id = crypto.randomUUID(); const now = new Date().toISOString(); const summary = { id, name, createdBy, createdByMemberId, createdAt: now, lastUsedAt: now, markerCount: 0, - readOnly, allowDeleteByOthers, disableComments, + markerMode, deleteMode, commentMode, moderators, eventId, eventName, eventIdentifier, hqId, hqName, }; const layer = { id, apiUrl, name, createdBy, createdByMemberId, createdAt: now, lastUsedAt: now, markers: [], - readOnly, allowDeleteByOthers, disableComments, + markerMode, deleteMode, commentMode, moderators, eventId, eventName, eventIdentifier, hqId, hqName, }; await putLayerObject(apiUrl, id, layer); diff --git a/lambda/map-layers-v2/handlers/deleteFeature.js b/lambda/map-layers-v2/handlers/deleteFeature.js index a391570b..c14397fa 100644 --- a/lambda/map-layers-v2/handlers/deleteFeature.js +++ b/lambda/map-layers-v2/handlers/deleteFeature.js @@ -2,6 +2,7 @@ const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { markerMode, isAuthorized } = require('../lib/permissions'); // DELETE /map-layers/{id}/features/{markerId}?apiUrl=...&actorId=... // Soft-deletes the marker (sets deleted: true) rather than removing it, so @@ -26,11 +27,10 @@ module.exports = async function deleteFeature(event, claims) { const marker = layer.markers.find((m) => m.id === markerId); if (!marker) return notFound('Marker not found'); - // Same rule as upsertFeature.js: a read-only layer restricts marker - // writes (including delete) to the layer's creator, authorized against - // the verified token's memberId. - if (layer.readOnly && memberId !== layer.createdByMemberId) { - return forbidden('Only the layer creator can delete markers on this read-only layer'); + // Same rule as upsertFeature.js: markerMode gates marker deletes too, + // authorized against the verified token's memberId. + if (!isAuthorized(markerMode(layer), layer, memberId)) { + return forbidden('You do not have permission to delete markers on this layer'); } const now = new Date().toISOString(); diff --git a/lambda/map-layers-v2/handlers/deleteLayer.js b/lambda/map-layers-v2/handlers/deleteLayer.js index 847dbb2a..f2deb7c3 100644 --- a/lambda/map-layers-v2/handlers/deleteLayer.js +++ b/lambda/map-layers-v2/handlers/deleteLayer.js @@ -2,6 +2,7 @@ const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { deleteMode, isAuthorized } = require('../lib/permissions'); // DELETE /map-layers/{id}?apiUrl=...&actorId=... // @@ -24,8 +25,8 @@ module.exports = async function deleteLayer(event, claims) { const layer = await getLayerObject(apiUrl, layerId); if (!layer) return notFound('Layer not found'); - if (layer.allowDeleteByOthers === false && memberId !== layer.createdByMemberId) { - return forbidden('Only the layer creator can delete this layer'); + if (!isAuthorized(deleteMode(layer), layer, memberId)) { + return forbidden('You do not have permission to delete this layer'); } const now = new Date().toISOString(); diff --git a/lambda/map-layers-v2/handlers/listLayers.js b/lambda/map-layers-v2/handlers/listLayers.js index b26112ca..96c502be 100644 --- a/lambda/map-layers-v2/handlers/listLayers.js +++ b/lambda/map-layers-v2/handlers/listLayers.js @@ -5,18 +5,25 @@ const { json, badRequest } = require('../lib/response'); const STALE_MS = 120 * 24 * 60 * 60 * 1000; // 120 days -// GET /map-layers?apiUrl=... +// GET /map-layers?apiUrl=...&hqId=... // Lists layers for an org, excluding any unused for 120+ days. The // underlying data is never deleted by this filter -- only omitted from -// the listing. +// the listing. `hqId`, if given, additionally restricts the list to layers +// attached to that HQ (see createLayer.js) -- omitted entirely for "All +// HQs" (Config.js's collabLayerHqFilterPicker cleared). Layers created +// before the HQ requirement existed have no hqId and so never match a +// specific hqId filter, only the unfiltered "All HQs" request. module.exports = async function listLayers(event) { const apiUrl = event.queryStringParameters?.apiUrl; + const hqId = event.queryStringParameters?.hqId || null; if (!apiUrl) return badRequest('apiUrl is required'); const { data } = await getJson(indexKey(apiUrl)); const layers = (data?.layers || []).filter((l) => { const lastUsed = new Date(l.lastUsedAt).getTime(); - return Number.isFinite(lastUsed) && Date.now() - lastUsed <= STALE_MS; + if (!Number.isFinite(lastUsed) || Date.now() - lastUsed > STALE_MS) return false; + if (hqId && l.hqId !== hqId) return false; + return true; }); return json(200, { layers }); diff --git a/lambda/map-layers-v2/handlers/updateLayerModerators.js b/lambda/map-layers-v2/handlers/updateLayerModerators.js new file mode 100644 index 00000000..7cbc42f8 --- /dev/null +++ b/lambda/map-layers-v2/handlers/updateLayerModerators.js @@ -0,0 +1,73 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { isAuthorized } = require('../lib/permissions'); + +const MAX_MODERATORS = 100; + +/** Sanitize the client-supplied moderator list: [{id, name}], deduped by id. */ +function sanitizeModerators(input) { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out = []; + for (const m of input) { + const id = String(m?.id || '').trim().slice(0, 100); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push({ id, name: String(m?.name || id).trim().slice(0, 200) }); + if (out.length >= MAX_MODERATORS) break; + } + return out; +} + +// PUT /map-layers/{id}/moderators body: { apiUrl, moderators: [{id, name}] } +// +// Unlike markerMode/deleteMode/commentMode (fixed at creation, see +// createLayer.js), the moderator list itself can be updated later -- who +// should moderate a layer changes over an incident's lifetime even when the +// permission structure doesn't. The creator or any *current* moderator may +// change it (isAuthorized('moderators', ...) -- same rule as the +// marker/delete/comment 'moderators' mode: creator plus anyone already on +// the list), so a stranger still can't silently add themselves. Replaces +// the full list rather than diffing (simpler, and the client always sends +// its complete current list -- see collabLayerSync.js's +// updateLayerModerators). A moderator removing themselves (or every other +// moderator) is allowed -- same trust level as the creator over this list. +module.exports = async function updateLayerModerators(event, claims) { + const layerId = event.pathParameters?.id; + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + const apiUrl = body.apiUrl; + const memberId = String(claims?.sub || ''); + + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + if (!isAuthorized('moderators', layer, memberId)) { + return forbidden('Only the layer creator or a moderator can manage moderators'); + } + + const moderators = sanitizeModerators(body.moderators); + const now = new Date().toISOString(); + layer.moderators = moderators; + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.moderators = moderators; + entry.lastUsedAt = now; + } + }); + + return json(200, { moderators }); +}; diff --git a/lambda/map-layers-v2/handlers/upsertFeature.js b/lambda/map-layers-v2/handlers/upsertFeature.js index 63deef9b..d1612bcd 100644 --- a/lambda/map-layers-v2/handlers/upsertFeature.js +++ b/lambda/map-layers-v2/handlers/upsertFeature.js @@ -3,6 +3,7 @@ const crypto = require('crypto'); const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { markerMode, isAuthorized } = require('../lib/permissions'); // Must stay in sync with the icon keys in // src/pages/tasking/components/collab_marker_icons.js (MARKER_ICON_GROUPS). @@ -51,12 +52,11 @@ module.exports = async function upsertFeature(event, claims) { const layer = await getLayerObject(apiUrl, layerId); if (!layer) return notFound('Layer not found'); - // On a read-only layer, only the layer's creator may create/edit markers - // -- everyone else is limited to commenting (see addMarkerComment.js). // Authorized against the verified token's memberId, not the - // client-supplied actorId. - if (layer.readOnly && memberId !== layer.createdByMemberId) { - return forbidden('Only the layer creator can add or edit markers on this read-only layer'); + // client-supplied actorId. See lib/permissions.js for the markerMode / + // isAuthorized rules (anyone / creator-only / creator+moderators). + if (!isAuthorized(markerMode(layer), layer, memberId)) { + return forbidden('You do not have permission to add or edit markers on this layer'); } const now = new Date().toISOString(); diff --git a/lambda/map-layers-v2/index.js b/lambda/map-layers-v2/index.js index 71066848..28413425 100644 --- a/lambda/map-layers-v2/index.js +++ b/lambda/map-layers-v2/index.js @@ -9,6 +9,7 @@ const upsertFeature = require('./handlers/upsertFeature'); const deleteFeature = require('./handlers/deleteFeature'); const addMarkerComment = require('./handlers/addMarkerComment'); const deleteLayer = require('./handlers/deleteLayer'); +const updateLayerModerators = require('./handlers/updateLayerModerators'); // Single Lambda fronting all /lad_v2/map-layers routes via API Gateway HTTP // API (payload format 2.0) Lambda proxy integration. Routed by @@ -22,6 +23,7 @@ const ROUTES = { 'POST /lad_v2/map-layers': createLayer, 'GET /lad_v2/map-layers/{id}': getLayer, 'DELETE /lad_v2/map-layers/{id}': deleteLayer, + 'PUT /lad_v2/map-layers/{id}/moderators': updateLayerModerators, 'PUT /lad_v2/map-layers/{id}/features': upsertFeature, 'DELETE /lad_v2/map-layers/{id}/features/{markerId}': deleteFeature, 'POST /lad_v2/map-layers/{id}/features/{markerId}/comments': addMarkerComment, diff --git a/lambda/map-layers-v2/lib/permissions.js b/lambda/map-layers-v2/lib/permissions.js new file mode 100644 index 00000000..6e1d735e --- /dev/null +++ b/lambda/map-layers-v2/lib/permissions.js @@ -0,0 +1,45 @@ +'use strict'; + +// Each of the three collaborative-layer permission axes (who can write +// markers, who can delete the layer, who can comment) is a 3-way mode: +// 'anyone' | 'creator' | 'moderators'. 'moderators' always additionally +// allows the creator -- a "moderators can" setting shouldn't lock the +// creator themselves out. +// +// Layers created before this feature only have the old boolean flags +// (readOnly / allowDeleteByOthers / disableComments) and no `moderators` +// list -- the *Mode() readers below fall back to deriving the equivalent +// mode from those booleans so old layers keep behaving exactly as they did. +// disableComments=true had no direct 3-way equivalent (it blocked everyone, +// including the creator); 'creator' is the closest available mode and is +// what new layers get if a user picks "Only I can comment". +const VALID_MODES = new Set(['anyone', 'creator', 'moderators']); + +function normalizeMode(value) { + return VALID_MODES.has(value) ? value : null; +} + +function markerMode(layer) { + return layer.markerMode || (layer.readOnly ? 'creator' : 'anyone'); +} + +function deleteMode(layer) { + return layer.deleteMode || (layer.allowDeleteByOthers === false ? 'creator' : 'anyone'); +} + +function commentMode(layer) { + return layer.commentMode || (layer.disableComments ? 'creator' : 'anyone'); +} + +/** Is `memberId` allowed to perform an action gated by `mode` on `layer`? */ +function isAuthorized(mode, layer, memberId) { + if (mode === 'anyone') return true; + if (!memberId) return false; + if (memberId === layer.createdByMemberId) return true; + if (mode === 'moderators') { + return Array.isArray(layer.moderators) && layer.moderators.some((m) => m?.id === memberId); + } + return false; +} + +module.exports = { VALID_MODES, normalizeMode, markerMode, deleteMode, commentMode, isAuthorized }; diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index a7f3bff1..60baf139 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -1425,9 +1425,16 @@ function VM() { }); }, fetchAllSectors: (hqs) => self.fetchAllSectors(hqs), + searchMembers: (q) => self.searchMembers(q), + searchEvents: (q) => self.searchEvents(q), getToken: () => getToken(), apiUrl: sourceUrl, userId: params.userId, + // The Beacon entity id of the HQ this Lighthouse instance was + // launched for (?hq= in the URL) -- every collaborative layer + // must be attached to an HQ (Config.js), and the layer list defaults + // to showing just this HQ's layers, both seeded from this id. + defaultHqId: params.hq || null, // Collaborative-layer actions (create/delete layer) are attributed // (for display/audit only) using the same identity as every // marker/comment op on that layer (markerActorId, i.e. @@ -2244,6 +2251,35 @@ function VM() { }); } + // Searches Beacon members by name or member number (Username) -- used + // by the collaborative-layer moderator picker (Config.js). Returns raw + // Users/Search result rows; Config.js maps each row's Username to the + // same member-id space as getMemberId()/createdByMemberId above. + self.searchMembers = async function (query) { + const t = await getToken(); // blocks here until token is ready + return new Promise((resolve) => { + BeaconClient.users.search(query, apiHost, params.userId, t, function (data) { + resolve(data?.Results || []); + }, function () { + resolve([]); + }); + }); + } + + // Searches Beacon events by name or identifier -- used by the + // collaborative-layer "attach to event" picker (Config.js). Returns raw + // Events/Search result rows. + self.searchEvents = async function (query) { + const t = await getToken(); // blocks here until token is ready + return new Promise((resolve) => { + BeaconClient.events.search(query, apiHost, params.userId, t, function (data) { + resolve(data?.Results || []); + }, function () { + resolve([]); + }); + }); + } + self.sendSMS = async function (recipients, jobId = '', message, isOperational) { const t = await getToken(); // blocks here until token is ready return new Promise((resolve, reject) => { diff --git a/src/pages/tasking/mapLayers/collabLayer.js b/src/pages/tasking/mapLayers/collabLayer.js index b67e71dc..dd6de290 100644 --- a/src/pages/tasking/mapLayers/collabLayer.js +++ b/src/pages/tasking/mapLayers/collabLayer.js @@ -4,10 +4,15 @@ import { listLayers, createLayer, deleteLayer, + updateLayerModerators, fetchLayerMarkers, upsertMarker, deleteMarker, addMarkerComment, + getSubscribedLayerIds, + subscribeLayer, + unsubscribeLayer, + migrateLegacyVisibleLayersToSubscriptions, } from "../utils/collabLayerSync.js"; const REFRESH_MS = 10000; // polling only fires while the layer is visible (registerPollingLayer's hasLayer gate) @@ -88,26 +93,62 @@ function visibleCollabLayers(vm) { // ── Permissions ────────────────────────────────────────────────────── // -// A layer's readOnly / allowDeleteByOthers / disableComments flags are set -// once at creation time (see Config.js's createCollabLayer form) and never -// change afterward, so there's no staleness concern reading them straight -// off whatever layer object is already in hand. The Lambda enforces all -// three independently and authoritatively (see lambda/map-layers-v2) -- -// this client-side gating only decides what to show/hide so a user isn't -// invited to attempt something that will just come back as a 403. +// A layer's markerMode / deleteMode / commentMode are set once at creation +// time (see Config.js's createCollabLayer form) and never change afterward, +// so there's no staleness concern reading them straight off whatever layer +// object is already in hand. `moderators` (who counts as a moderator under +// the 'moderators' mode) *can* change later -- the creator can add/remove +// moderators after creation (see Config.js's per-row "Manage moderators") +// -- but it's still read straight off the layer object already in hand, +// same as the modes; a stale moderator list here just means the UI is +// briefly out of date until the next poll, not a security gap (the Lambda +// enforces authoritatively, see lambda/map-layers-v2). This client-side +// gating only decides what to show/hide so a user isn't invited to attempt +// something that will just come back as a 403. +// +// Each mode is one of 'anyone' | 'creator' | 'moderators'. Layers created +// before this feature only carry the old readOnly / allowDeleteByOthers / +// disableComments booleans -- the effective*Mode() helpers below derive the +// equivalent mode from those so old layers keep behaving as they did. // // `getMemberId` is a sync () => string|null, the current user's Beacon // member id decoded from their own access token (main.js) -- the same // identity the Lambda authorizes against via the token's verified `sub` // claim, which is why it's what's compared to a layer's -// `createdByMemberId` rather than the actorId/personId used for -// createdBy/updatedBy bookkeeping elsewhere in this feature. +// `createdByMemberId`/`moderators` rather than the actorId/personId used +// for createdBy/updatedBy bookkeeping elsewhere in this feature. + +function effectiveMarkerMode(layer) { + return layer.markerMode || (layer.readOnly ? 'creator' : 'anyone'); +} + +function effectiveCommentMode(layer) { + return layer.commentMode || (layer.disableComments ? 'creator' : 'anyone'); +} + +/** Is `memberId` the creator or a listed moderator of `layer`? */ +function isCreatorOrModerator(layer, memberId) { + if (!memberId) return false; + if (memberId === layer.createdByMemberId) return true; + return Array.isArray(layer.moderators) && layer.moderators.some((m) => m?.id === memberId); +} + +function isAuthorizedForMode(mode, layer, getMemberId) { + if (mode === 'anyone') return true; + const memberId = getMemberId?.(); + if (mode === 'creator') return !!memberId && memberId === layer.createdByMemberId; + if (mode === 'moderators') return isCreatorOrModerator(layer, memberId); + return false; +} /** Whether the current user may create/edit/delete markers on `layer`. */ function canWriteMarkers(layer, getMemberId) { - if (!layer?.readOnly) return true; - const memberId = getMemberId?.(); - return !!memberId && memberId === layer.createdByMemberId; + return isAuthorizedForMode(effectiveMarkerMode(layer), layer, getMemberId); +} + +/** Whether the current user may comment on `layer`'s markers. */ +function canCommentOnLayer(layer, getMemberId) { + return isAuthorizedForMode(effectiveCommentMode(layer), layer, getMemberId); } /** @@ -131,12 +172,19 @@ function registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId) } /** - * Fetch the list of collaborative layers for the org, store it on the - * MapVM for the config modal to bind to, and register/refresh polling - * layers for any layer not already registered. + * Fetch every layer for the org (unfiltered -- subscriptions can span any + * HQ, so there's no single hqId to scope this fetch to) and narrow it down + * client-side to the ones the user is subscribed to. This -- not any HQ + * filter -- is what populates vm.mapVM.collabLayers()/Config.js's "My + * layers" list, so a subscribed layer stays listed (and unsubscribe-able) + * regardless of whatever HQ the separate "Find a layer" search is scoped + * to. Registers/refreshes polling for any subscribed layer not already + * registered. */ -export async function refreshCollabLayerList(vm, apiUrl, actorId, getToken, getMemberId) { - const layers = await listLayers(apiUrl, await getToken()); +export async function refreshSubscribedLayers(vm, apiUrl, actorId, getToken, getMemberId) { + const subscribedIds = getSubscribedLayerIds(); + const all = await listLayers(apiUrl, await getToken()); + const layers = all.filter((layer) => subscribedIds.has(String(layer.id))); vm.mapVM.collabLayers(layers); // Only register layers we haven't seen yet -- re-registering an already // visible layer would hand it a brand new (empty) layerGroup that never @@ -149,6 +197,17 @@ export async function refreshCollabLayerList(vm, apiUrl, actorId, getToken, getM return layers; } +/** + * Fetch layers for a single HQ -- backs Config.js's "Find a layer" search + * (browsing to discover/subscribe to a layer), never touches + * vm.mapVM.collabLayers or registers anything. Thin pass-through kept here + * (rather than Config.js importing collabLayerSync.js's listLayers + * directly) so every org-layer fetch funnels through one module. + */ +export async function searchLayersForHq(apiUrl, getToken, hqId) { + return listLayers(apiUrl, await getToken(), hqId); +} + /** * Called once at startup (main.js), alongside the other register*Layer * calls. The right-click "Add marker" trigger itself is wired up @@ -157,20 +216,27 @@ export async function refreshCollabLayerList(vm, apiUrl, actorId, getToken, getM * above) rather than a second contextmenu listener here. */ export async function registerCollabLayers(vm, apiUrl, actorId, getToken, getMemberId) { - await refreshCollabLayerList(vm, apiUrl, actorId, getToken, getMemberId); + migrateLegacyVisibleLayersToSubscriptions(); + await refreshSubscribedLayers(vm, apiUrl, actorId, getToken, getMemberId); } -/** Create a new named layer, register its polling layer immediately, and refresh the drawer. */ +/** + * Create a new named layer, subscribe its creator to it, register its + * polling layer immediately, and refresh the drawer. + */ export async function createCollabLayer(vm, apiUrl, name, actorId, getToken, permissions, getMemberId) { const layer = await createLayer(apiUrl, name, actorId, await getToken(), permissions); if (!layer) return null; + subscribeLayer(layer.id); const list = vm.mapVM.collabLayers(); vm.mapVM.collabLayers([...list, layer]); const key = layerKeyFor(layer.id); registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId); // Auto-show layers the user just created -- matches the 'ov.' - // flag both the layers drawer (main.js) and the Config modal's View - // switch (Config.js collabLayerRows) read to decide initial visibility. + // flag the layers drawer (main.js) reads to decide initial visibility. + // The one deliberate exception to "Config subscribes, LayersDrawer + // shows/hides": whoever just made a layer almost certainly wants to + // see it immediately, without a second trip to the Layers control. localStorage.setItem(`ov.online-${key}`, '1'); vm.mapVM.layersDrawer?.refresh?.(); vm.mapVM.refreshPollingLayer(key); @@ -179,10 +245,10 @@ export async function createCollabLayer(vm, apiUrl, name, actorId, getToken, per /** * Delete a collaborative layer: fires the remote (soft-)delete, then tears - * down its polling registration/map presence and drops it from the list - * the config modal binds to. No-op (returns false) if the delete itself - * failed, e.g. a 403 from a since-changed allowDeleteByOthers=false -- - * Config.js's row stays in place in that case rather than disappearing + * down its polling registration/map presence, drops the subscription, and + * drops it from the list the config modal binds to. No-op (returns false) + * if the delete itself failed, e.g. a 403 from a since-changed deleteMode + * -- Config.js's row stays in place in that case rather than disappearing * client-side while still existing server-side. */ export async function deleteCollabLayer(vm, apiUrl, layerId, actorId, getToken) { @@ -192,11 +258,62 @@ export async function deleteCollabLayer(vm, apiUrl, layerId, actorId, getToken) const key = layerKeyFor(layerId); vm.mapVM.unregisterPollingLayer(key); localStorage.removeItem(`ov.online-${key}`); + unsubscribeLayer(layerId); vm.mapVM.collabLayers(vm.mapVM.collabLayers().filter((l) => l.id !== layerId)); vm.mapVM.layersDrawer?.refresh?.(); return true; } +/** + * Subscribe to an already-existing layer (found via Config.js's "Find a + * layer" search) -- adds it to "My layers", registers it for + * polling/LayersDrawer, but leaves it hidden until explicitly shown via + * the Layers control (see createCollabLayer's comment for the one + * exception to that rule). + */ +export function subscribeToLayer(vm, apiUrl, layer, actorId, getToken, getMemberId) { + subscribeLayer(layer.id); + const list = vm.mapVM.collabLayers(); + if (!list.some((l) => l.id === layer.id)) { + vm.mapVM.collabLayers([...list, layer]); + } + if (!vm.mapVM.onlineLayers.has(layerKeyFor(layer.id))) { + registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId); + } + vm.mapVM.layersDrawer?.refresh?.(); +} + +/** + * Unsubscribe from a layer: drops it from "My layers" and tears down its + * polling/map presence entirely (unlike hiding it, which leaves it + * registered -- see registerPollingLayer's doc comment). Local-only, no + * remote call -- subscriptions aren't org data (see collabLayerSync.js). + */ +export function unsubscribeFromLayer(vm, layerId) { + const key = layerKeyFor(layerId); + vm.mapVM.unregisterPollingLayer(key); + localStorage.removeItem(`ov.online-${key}`); + unsubscribeLayer(layerId); + vm.mapVM.collabLayers(vm.mapVM.collabLayers().filter((l) => l.id !== layerId)); + vm.mapVM.layersDrawer?.refresh?.(); +} + +/** + * Replace a layer's moderator list (creator-only, enforced server-side -- + * see lambda updateLayerModerators.js). Updates the in-memory layer object + * (shared by reference with vm.mapVM.collabLayers()'s entry, same as every + * other layer field) on success so Config.js's row immediately reflects the + * new list without waiting for the next poll/refresh. + */ +export async function updateCollabLayerModerators(vm, apiUrl, layerId, moderators, getToken) { + const saved = await updateLayerModerators(apiUrl, layerId, moderators, await getToken()); + if (saved == null) return null; + + const layer = vm.mapVM.collabLayers().find((l) => l.id === layerId); + if (layer) layer.moderators = saved; + return saved; +} + // ── Drawing ────────────────────────────────────────────────────────── function drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId) { @@ -236,7 +353,11 @@ function drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, ge function buildMarkerPopupEl(vm, apiUrl, layer, key, marker, actorId, getToken, leafletMarker, getMemberId) { const layerId = layer.id; const canWrite = canWriteMarkers(layer, getMemberId); - const canComment = !layer.disableComments; + const canComment = canCommentOnLayer(layer, getMemberId); + const commentMode = effectiveCommentMode(layer); + const commentsRestrictedMessage = commentMode === 'moderators' + ? "Only the layer creator and moderators can comment on this layer" + : "Only the layer creator can comment on this layer"; const el = document.createElement("div"); el.className = "collab-marker-popup"; @@ -252,7 +373,7 @@ function buildMarkerPopupEl(vm, apiUrl, layer, key, marker, actorId, getToken, l
- ` : `
Comments are disabled on this layer
`} + ` : `
${escHtml(commentsRestrictedMessage)}
`} ${canWrite ? `
@@ -433,7 +554,15 @@ function stripSubjectPrefix(subject) { return sepIdx === -1 ? "" : subject.slice(sepIdx + 3); } -function createOpsLogAuditEntry(vm, subject, text) { +/** + * `eventId`, when the marker's layer has one attached (see Config.js's + * "attach to event" picker), is threaded through as the Ops Log entry's own + * EventId -- ties every marker/comment logged against that layer back to + * the same Beacon event, same as logging it by hand from that event's own + * Ops Log tab would. Omitted entirely (not sent as a blank field) for + * layers with no event attached, same as before this existed. + */ +function createOpsLogAuditEntry(vm, subject, text, eventId) { if (typeof vm.createOpsLogEntry !== "function") return Promise.resolve(null); const payload = { @@ -445,6 +574,7 @@ function createOpsLogAuditEntry(vm, subject, text) { TagIds: [MARKER_AUDIT_TAG_ID], TimeLogged: new Date().toISOString(), }; + if (eventId) payload.EventId = eventId; return new Promise((resolve) => { vm.createOpsLogEntry(payload, (result) => resolve(result?.Id ?? null)); @@ -464,10 +594,11 @@ function createOpsLogAuditEntry(vm, subject, text) { * so this never needs to truncate. */ function logMarkerAudit(vm, action, layerId, marker, title, description) { - const layerName = vm.mapVM.collabLayers().find((l) => l.id === layerId)?.name || layerId; + const layer = vm.mapVM.collabLayers().find((l) => l.id === layerId); + const layerName = layer?.name || layerId; const subject = title ? `${markerSubjectLead(action)} - ${title}` : markerSubjectLead(action); const text = `${description || ""}${buildMarkerAuditFooter(action, layerName, marker)}`; - return createOpsLogAuditEntry(vm, subject, text); + return createOpsLogAuditEntry(vm, subject, text, layer?.eventId); } /** @@ -476,9 +607,10 @@ function logMarkerAudit(vm, action, layerId, marker, title, description) { * marker's commentOpsLogIds. */ function logMarkerComment(vm, layerId, marker, commentText) { - const layerName = vm.mapVM.collabLayers().find((l) => l.id === layerId)?.name || layerId; + const layer = vm.mapVM.collabLayers().find((l) => l.id === layerId); + const layerName = layer?.name || layerId; const text = `${commentText}${buildMarkerAuditFooter("commented on", layerName, marker)}`; - return createOpsLogAuditEntry(vm, `${SUBJECT_PREFIX} comment`, text); + return createOpsLogAuditEntry(vm, `${SUBJECT_PREFIX} comment`, text, layer?.eventId); } /** Fetch a single Ops Log entry by id, resolving null if unavailable/failed. */ diff --git a/src/pages/tasking/utils/collabLayerSync.js b/src/pages/tasking/utils/collabLayerSync.js index 5930e1c1..4c29e6ed 100644 --- a/src/pages/tasking/utils/collabLayerSync.js +++ b/src/pages/tasking/utils/collabLayerSync.js @@ -51,6 +51,68 @@ function saveCachedLayer(layerId, layer) { localStorage.setItem(layerCacheKey(layerId), JSON.stringify(layer)); } +// ── Subscriptions ──────────────────────────────────────────────────── +// +// Which collaborative layers a user tracks (shown in Config's "My layers" +// list, and registered for polling/LayersDrawer) is a client-side +// preference, not org data -- never sent to the Lambda. Kept independent +// of HQ so a subscribed layer stays manageable (visible in the list, +// unsubscribe-able) no matter which HQ the separate "Find a layer" search +// is currently scoped to -- that's the whole point: unsubscribing from a +// layer shouldn't require first knowing/re-selecting the HQ it came from. +const LS_SUBSCRIPTIONS_KEY = 'lh_collabLayer_subscriptions'; + +/** @returns {Set} */ +export function getSubscribedLayerIds() { + try { + return new Set(JSON.parse(localStorage.getItem(LS_SUBSCRIPTIONS_KEY)) || []); + } catch { + return new Set(); + } +} + +function saveSubscribedLayerIds(ids) { + localStorage.setItem(LS_SUBSCRIPTIONS_KEY, JSON.stringify([...ids])); +} + +export function isSubscribed(layerId) { + return getSubscribedLayerIds().has(String(layerId)); +} + +export function subscribeLayer(layerId) { + const ids = getSubscribedLayerIds(); + ids.add(String(layerId)); + saveSubscribedLayerIds(ids); +} + +export function unsubscribeLayer(layerId) { + const ids = getSubscribedLayerIds(); + ids.delete(String(layerId)); + saveSubscribedLayerIds(ids); +} + +/** + * One-time migration from the pre-subscriptions model, where every layer + * ever fetched got auto-registered and shown/hidden state was tracked + * purely by per-layer `ov.online-collab-` flags (see collabLayer.js / + * Config.js). Guarded by LS_SUBSCRIPTIONS_KEY already existing (real + * subscriptions, even an empty set, always leaves that key set) so this + * only ever runs once per browser -- otherwise a layer someone explicitly + * unsubscribed from would keep reappearing as long as its old `ov.*` flag + * was still '1'. + */ +export function migrateLegacyVisibleLayersToSubscriptions() { + if (localStorage.getItem(LS_SUBSCRIPTIONS_KEY) !== null) return; + + const ids = new Set(); + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + const match = key && key.match(/^ov\.online-collab-(.+)$/); + if (match && localStorage.getItem(key) === '1') ids.add(match[1]); + } + saveSubscribedLayerIds(ids); +} + // ── List / create layers ──────────────────────────────────────────── /** @@ -58,24 +120,37 @@ function saveCachedLayer(layerId, layer) { * excluded server-side, not deleted). * @param {string} apiUrl * @param {string} token Beacon access token (Authorization: Bearer). + * @param {string} [hqId] If given, restricts the list to layers attached + * to this HQ server-side (see lambda listLayers.js) -- omit for "All HQs". * @returns {Promise>} */ -export async function listLayers(apiUrl, token) { - if (!apiUrl) return loadCachedLayerIndex(); +export async function listLayers(apiUrl, token, hqId) { + // Only the unfiltered "All HQs" list is a complete enough picture of + // the org's layers to serve as the offline cache -- an HQ-scoped + // response would otherwise silently shrink it for every other HQ. So + // an HQ-scoped call reads/writes nothing but a client-side filter over + // that same full cache, both as its "no apiUrl yet" fallback below and + // on a failed fetch. + const cached = () => { + const all = loadCachedLayerIndex(); + return hqId ? all.filter((l) => l.hqId === hqId) : all; + }; + + if (!apiUrl) return cached(); try { - const url = `${LAMBDA_BASE}?apiUrl=${encodeURIComponent(apiUrl)}`; + const url = `${LAMBDA_BASE}?apiUrl=${encodeURIComponent(apiUrl)}${hqId ? `&hqId=${encodeURIComponent(hqId)}` : ''}`; const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }); if (!res.ok) { console.warn('[collabLayerSync] list failed:', res.status); - return loadCachedLayerIndex(); + return cached(); } const { layers } = await res.json(); - saveCachedLayerIndex(layers || []); + if (!hqId) saveCachedLayerIndex(layers || []); return layers || []; } catch (err) { console.warn('[collabLayerSync] list error:', err); - return loadCachedLayerIndex(); + return cached(); } } @@ -85,13 +160,20 @@ export async function listLayers(apiUrl, token) { * @param {string} name * @param {string} actorId * @param {string} token Beacon access token (Authorization: Bearer). - * @param {{readOnly?: boolean, allowDeleteByOthers?: boolean, disableComments?: boolean}} [permissions] - * Fixed for the layer's lifetime -- there's no later "edit layer settings" flow. + * @param {{markerMode?: string, deleteMode?: string, commentMode?: string, moderators?: Array<{id: string, name: string}>, event?: {id: string, name: string}|null, hq: {id: string, name: string}}} permissions + * Each mode is one of 'anyone' | 'creator' | 'moderators' (default 'anyone'). + * Modes are fixed for the layer's lifetime -- there's no later "edit layer + * settings" flow for them -- but `moderators` itself can be changed later + * by the creator via updateLayerModerators() below. `event`, if given, is + * the optional Beacon event this layer is attached to -- also fixed at + * creation, purely for display (see Config.js's layer list). `hq` is + * required -- every layer must belong to an HQ (also fixed at creation); + * the Lambda rejects the request if it's missing. * @returns {Promise} the created layer summary, or null on failure */ export async function createLayer(apiUrl, name, actorId, token, permissions = {}) { const trimmed = (name || '').trim(); - if (!apiUrl || !trimmed) return null; + if (!apiUrl || !trimmed || !permissions.hq?.id) return null; try { const res = await fetch(LAMBDA_BASE, { @@ -101,9 +183,12 @@ export async function createLayer(apiUrl, name, actorId, token, permissions = {} apiUrl, name: trimmed, createdBy: String(actorId), - readOnly: !!permissions.readOnly, - allowDeleteByOthers: permissions.allowDeleteByOthers !== false, - disableComments: !!permissions.disableComments, + markerMode: permissions.markerMode || 'anyone', + deleteMode: permissions.deleteMode || 'anyone', + commentMode: permissions.commentMode || 'anyone', + moderators: Array.isArray(permissions.moderators) ? permissions.moderators : [], + event: permissions.event || null, + hq: permissions.hq, }), }); if (!res.ok) { @@ -156,6 +241,51 @@ export async function deleteLayer(apiUrl, layerId, actorId, token) { } } +/** + * Replace a layer's moderator list. Only the layer's creator is authorized + * server-side (see lambda updateLayerModerators.js) -- calling this as + * anyone else fails with a 403 and the local cache is left untouched. + * @param {string} apiUrl + * @param {string} layerId + * @param {Array<{id: string, name: string}>} moderators + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise|null>} the saved moderator list, or null on failure + */ +export async function updateLayerModerators(apiUrl, layerId, moderators, token) { + if (!apiUrl || !layerId) return null; + + try { + const res = await fetch(`${LAMBDA_BASE}/${encodeURIComponent(layerId)}/moderators`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ apiUrl, moderators: moderators || [] }), + }); + if (!res.ok) { + throw new Error(`updateLayerModerators failed with status ${res.status}`); + } + const { moderators: saved } = await res.json(); + + // Reconcile the cached index entry (if present) so a page reload + // before the next refreshCollabLayerList() still shows the update. + const index = loadCachedLayerIndex(); + const entry = index.find((l) => l.id === layerId); + if (entry) { + entry.moderators = saved; + saveCachedLayerIndex(index); + } + const cachedLayer = loadCachedLayer(layerId); + if (cachedLayer) { + cachedLayer.moderators = saved; + saveCachedLayer(layerId, cachedLayer); + } + + return saved; + } catch (err) { + console.warn('[collabLayerSync] updateLayerModerators error:', err); + return null; + } +} + // ── Layer markers ──────────────────────────────────────────────────── /** diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 0096bbca..ecff0eb6 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -3,12 +3,303 @@ import ko from 'knockout'; import * as bootstrap from 'bootstrap5'; // Modal, Tooltip, etc. import { Enum } from '../utils/enum.js'; -import { createCollabLayer, refreshCollabLayerList, deleteCollabLayer } from '../mapLayers/collabLayer.js'; +import { + createCollabLayer, deleteCollabLayer, updateCollabLayerModerators, + refreshSubscribedLayers, searchLayersForHq, subscribeToLayer, unsubscribeFromLayer, +} from '../mapLayers/collabLayer.js'; const FUNCTION_URL = "https://lambda.lighthouse-extension.com/lad_v2/share"; +/** + * Reusable search-and-pick-multiple-members widget backing a layer's + * moderator list -- used both for the "new layer" form and, later, for + * editing an existing layer's moderators per row (only the layer creator + * can, mirroring the server-side check in updateLayerModerators.js). + * Mirrors the recipient search/picker pattern in SMSTeamModalVM.js. + * + * Each picked entry is `{ id, name }`, `id` being the Beacon member id + * (Username) -- the same identity space as getMemberId()/createdByMemberId + * (see mapLayers/collabLayer.js's permissions section for why), resolved + * via `searchMembers` (Config.js's deps.searchMembers -> + * BeaconClient.users.search) rather than the PersonId space + * resolvePersonName()/getSimplePerson() use elsewhere on this page. + */ +function makeModeratorPicker(searchMembers, initial = []) { + const picker = {}; + picker.moderators = ko.observableArray(initial.map(m => ({ ...m }))); + picker.searchQuery = ko.observable(''); + picker.searchResults = ko.observableArray([]); + picker.dropdownOpen = ko.observable(false); + picker.loading = ko.observable(false); + picker.hasFocus = ko.observable(false); + + let searchTimer = null; + + picker.clearSearch = () => { + picker.searchQuery(''); + picker.searchResults([]); + picker.dropdownOpen(false); + }; + picker.closeDropdown = () => { + // Delay lets a click on a dropdown item fire before it's hidden. + setTimeout(() => picker.dropdownOpen(false), 150); + }; + picker.onSearchKeydown = (_vm, e) => { + if (e.key === 'Escape') { picker.clearSearch(); return true; } + if (e.key === 'Enter') { + const first = picker.searchResults()[0]; + if (first) picker.addFromSearch(first); + return false; + } + return true; + }; + picker.runSearch = async () => { + const q = (picker.searchQuery() || '').trim(); + if (q.length < 2) { + picker.searchResults([]); + picker.dropdownOpen(false); + return; + } + // Opens immediately (loading state) rather than waiting for the + // response and gating on hasFocus() at that point -- matches the + // proven locationSearch pattern elsewhere in this file (self.query's + // subscribe), which found that fragile: a focus/blur timing quirk + // could leave a real, non-empty result list stuck hidden (reported + // as "the HQ picker doesn't drop down when there's only 1 result", + // same bug in every picker built from this same shape). + picker.searchResults([]); + picker.dropdownOpen(true); + picker.loading(true); + try { + const rows = await searchMembers(q); + // Username is required -- it's the member-id space moderator + // checks are authorized against (see collabLayer.js's + // permissions section), so a result without one can't actually + // be added as a moderator. Disabled accounts are still shown + // (just filtering them silently made real results disappear + // when a Disabled flag was set on training/test accounts). + const cleaned = (rows || []) + .filter(r => r.Username) + .map(r => ({ + id: String(r.Username), + name: [r.Firstname, r.Lastname].filter(Boolean).join(' ') || String(r.Username), + detail: String(r.Username), + })); + picker.searchResults(cleaned); + } catch (err) { + console.error('Member search failed:', err); + picker.searchResults([]); + } finally { + picker.loading(false); + } + }; + picker.searchQuery.subscribe(() => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(picker.runSearch, 250); + }); + picker.addFromSearch = (result) => { + if (!result) return; + if (!picker.moderators().some(m => m.id === result.id)) { + picker.moderators.push({ id: result.id, name: result.name }); + } + picker.clearSearch(); + }; + picker.removeModerator = (moderator) => { + picker.moderators.remove(m => m.id === moderator.id); + }; + /** Discards any in-progress edits, restoring the picker to `next`. */ + picker.reset = (next = []) => { + picker.moderators(next.map(m => ({ ...m }))); + picker.clearSearch(); + }; + return picker; +} + +/** + * Single-select counterpart to makeModeratorPicker, backing a layer's + * optional "attach to event" field (see createCollabLayer's `event` + * param). Same search/debounce shape, but holds at most one picked + * `{id, name, identifier}` rather than a list. + */ +function makeEventPicker(searchEvents) { + const picker = {}; + picker.selected = ko.observable(null); + picker.searchQuery = ko.observable(''); + picker.searchResults = ko.observableArray([]); + picker.dropdownOpen = ko.observable(false); + picker.loading = ko.observable(false); + picker.hasFocus = ko.observable(false); + // Precomputed (rather than a ternary in the data-bind attribute) + // because knockout-secure-binding's expression grammar doesn't support + // the conditional (?:) operator. Shows the identifier alongside the + // name (e.g. "6/1718 — Flood response") rather than name alone, since + // the name is often generic (see the create-layer form's chip and + // -- once the identifier is round-tripped through the layer object, + // see createLayer.js's eventIdentifier -- the layer list's own badge). + picker.selectedLabel = ko.pureComputed(() => { + const s = picker.selected(); + if (!s) return ''; + return s.identifier ? `${s.identifier} — ${s.name}` : s.name; + }); + + let searchTimer = null; + + picker.clearSearch = () => { + picker.searchQuery(''); + picker.searchResults([]); + picker.dropdownOpen(false); + }; + picker.closeDropdown = () => { + setTimeout(() => picker.dropdownOpen(false), 150); + }; + picker.onSearchKeydown = (_vm, e) => { + if (e.key === 'Escape') { picker.clearSearch(); return true; } + if (e.key === 'Enter') { + const first = picker.searchResults()[0]; + if (first) picker.selectFromSearch(first); + return false; + } + return true; + }; + picker.runSearch = async () => { + const q = (picker.searchQuery() || '').trim(); + if (q.length < 2) { + picker.searchResults([]); + picker.dropdownOpen(false); + return; + } + // Opens immediately (loading state) rather than waiting for the + // response and gating on hasFocus() at that point -- see + // makeModeratorPicker above for why (same bug, same fix, shared + // across every picker built from this shape). + picker.searchResults([]); + picker.dropdownOpen(true); + picker.loading(true); + try { + const rows = await searchEvents(q); + const cleaned = (rows || []) + .filter(r => r.Id != null) + .map(r => ({ + id: String(r.Id), + name: r.Name || `Event ${r.Id}`, + identifier: r.Identifier || '', + })); + picker.searchResults(cleaned); + } catch (err) { + console.error('Event search failed:', err); + picker.searchResults([]); + } finally { + picker.loading(false); + } + }; + picker.searchQuery.subscribe(() => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(picker.runSearch, 250); + }); + picker.selectFromSearch = (result) => { + if (!result) return; + picker.selected(result); + picker.clearSearch(); + }; + picker.clearSelection = () => picker.selected(null); + /** Discards any in-progress edits, restoring the picker to `next`. */ + picker.reset = (next = null) => { + picker.selected(next ? { ...next } : null); + picker.clearSearch(); + }; + return picker; +} + +/** + * Single-select entity picker scoped to Headquarters-type entities, backing + * both a layer's required HQ attachment and the layer list's HQ filter + * (Config.js). Same search/debounce shape as makeEventPicker, but searches + * Beacon entities (deps.entitiesSearch, i.e. BeaconClient.entities.search) + * rather than events. + * + * Filtered to results carrying a HeadquartersStatusTypeId -- confirmed + * against a live Entities/Search response as the actual "this entity is an + * HQ" signal (a real HQ came back with EntityTypeId: 2, e.g. a Zone HQ + * under State Headquarters' EntityTypeId: 1 -- EntityTypeId varies by + * level in the org hierarchy and is *not* a reliable "is this an HQ" check + * on its own, unlike HeadquartersStatusTypeId which only ever appears on + * HQ-type entities). + */ +function makeHqPicker(searchEntities, initial = null) { + const picker = {}; + picker.selected = ko.observable(initial ? { ...initial } : null); + picker.searchQuery = ko.observable(''); + picker.searchResults = ko.observableArray([]); + picker.dropdownOpen = ko.observable(false); + picker.loading = ko.observable(false); + picker.hasFocus = ko.observable(false); + + let searchTimer = null; + + picker.clearSearch = () => { + picker.searchQuery(''); + picker.searchResults([]); + picker.dropdownOpen(false); + }; + picker.closeDropdown = () => { + setTimeout(() => picker.dropdownOpen(false), 150); + }; + picker.onSearchKeydown = (_vm, e) => { + if (e.key === 'Escape') { picker.clearSearch(); return true; } + if (e.key === 'Enter') { + const first = picker.searchResults()[0]; + if (first) picker.selectFromSearch(first); + return false; + } + return true; + }; + picker.runSearch = async () => { + const q = (picker.searchQuery() || '').trim(); + if (q.length < 2) { + picker.searchResults([]); + picker.dropdownOpen(false); + return; + } + // Opens immediately (loading state) rather than waiting for the + // response and gating on hasFocus() at that point -- see + // makeModeratorPicker above for why (same bug, same fix, shared + // across every picker built from this shape). + picker.searchResults([]); + picker.dropdownOpen(true); + picker.loading(true); + try { + const rows = await searchEntities(q); + const cleaned = (rows || []) + .filter(r => r.Id != null && r.HeadquartersStatusTypeId != null) + .map(r => ({ id: String(r.Id), name: r.Name || `HQ ${r.Id}` })); + picker.searchResults(cleaned); + } catch (err) { + console.error('HQ search failed:', err); + picker.searchResults([]); + } finally { + picker.loading(false); + } + }; + picker.searchQuery.subscribe(() => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(picker.runSearch, 250); + }); + picker.selectFromSearch = (result) => { + if (!result) return; + picker.selected(result); + picker.clearSearch(); + }; + picker.clearSelection = () => picker.selected(null); + /** Discards any in-progress edits, restoring the picker to `next`. */ + picker.reset = (next = null) => { + picker.selected(next ? { ...next } : null); + picker.clearSearch(); + }; + return picker; +} + export function ConfigVM(root, deps) { @@ -202,22 +493,114 @@ export function ConfigVM(root, deps) { }; } // ── Collaborative map layers ── + // + // Two independent concerns, deliberately kept apart rather than merged + // into one HQ-scoped list-with-a-view-toggle (the earlier design here, + // which made a layer impossible to find/unsubscribe from once its HQ + // fell outside whatever filter happened to be selected): + // - "My layers" (self.collabLayers/collabLayerRows below) -- every + // layer this user is *subscribed* to (see collabLayerSync.js's + // localStorage-backed subscription helpers; subscribing is a + // client preference, not org data). Always lists every + // subscription regardless of HQ, so managing/unsubscribing from + // one never requires knowing which HQ it came from. You get here + // either by creating a layer (auto-subscribes you) or by + // subscribing to one found via "Find a layer" below. + // - "Find a layer" (self.discoverHqPicker/discoverRows below) -- + // search one HQ at a time to discover layers to subscribe to. Pure + // browse/discovery; never touches My layers except via an explicit + // Subscribe click. + // Whether a subscribed layer is actually drawn on the map is the map's + // own Layers control's job entirely (LayersDrawer, main.js) -- this + // panel has no View/show-hide toggle of its own anymore. The one + // exception: creating a layer auto-shows it (see collabLayer.js's + // createCollabLayer) since whoever just made one almost certainly + // wants to see it immediately. self.collabLayers = root.mapVM?.collabLayers || ko.observableArray([]); + // The whole "create a layer" flow (name, HQ, advanced options) is + // collapsed behind a single "+ New layer" toggle by default -- it's a + // lot of controls (mandatory HQ, optional event, 3 permission modes, + // moderators) to have permanently on-screen above what's usually the + // more-often-used layer list below. + self.showCreateLayerForm = ko.observable(false); + self.toggleCreateLayerForm = () => self.showCreateLayerForm(!self.showCreateLayerForm()); self.newLayerName = ko.observable(''); - // Fixed for a layer's whole lifetime once created -- there's no later - // "edit layer settings" flow, by design (see mapLayers/collabLayer.js). - // Each backed by a radio pair in tasking.html, all following the same - // "Anyone can ___" / "Only I can/I've disabled ___" shape for a - // consistent mental model across the three permissions. - self.newLayerReadOnly = ko.observable(false); // marker permissions: "Anyone can add markers" / "Only I can add markers" - self.newLayerAllowDeleteByOthers = ko.observable(true); // delete permissions: "Anyone can delete this layer" / "Only I can delete this layer" - self.newLayerDisableComments = ko.observable(false); // comment permissions: "Anyone can comment" / "Comments are disabled" + // Every layer must belong to an HQ -- defaults to whatever HQ this + // Lighthouse instance was launched for (?hq=, resolved below + // into self.defaultHq), but can be changed via search before creating. + // Required (unlike the event picker below), enforced both here + // (createCollabLayer) and server-side (createLayer.js). + self.newLayerHqPicker = makeHqPicker(deps.entitiesSearch); + // Optional Beacon event this layer relates to -- purely display + // metadata (like createdBy), fixed at creation same as the permission + // modes below, no later "attach/detach event" flow. + self.newLayerEventPicker = makeEventPicker(deps.searchEvents); + // Each mode is fixed for a layer's whole lifetime once created -- there's + // no later "edit permissions" flow, by design (see + // mapLayers/collabLayer.js). Each backed by a 3-way radio group in + // tasking.html: 'anyone' | 'creator' | 'moderators', all following the + // same "Anyone can ___" / "Only I can ___" / "Moderators can ___" shape + // for a consistent mental model across the three permissions. The + // moderator *list* itself is the one thing that's editable later (by + // the creator) -- see newLayerModeratorPicker below and each row's own + // moderatorPicker in collabLayerRows. + self.newLayerMarkerMode = ko.observable('anyone'); // who can add/edit/delete markers + self.newLayerDeleteMode = ko.observable('anyone'); // who can delete the layer itself + self.newLayerCommentMode = ko.observable('anyone'); // who can comment on markers + self.newLayerModeratorPicker = makeModeratorPicker(deps.searchMembers); + // Shown only once at least one permission above is set to 'moderators' + // -- the moderator list is meaningless (and hidden) otherwise. + self.showNewLayerModeratorPicker = ko.pureComputed(() => + self.newLayerMarkerMode() === 'moderators' || + self.newLayerDeleteMode() === 'moderators' || + self.newLayerCommentMode() === 'moderators'); self.creatingCollabLayer = ko.observable(false); self.collabLayerError = ko.observable(''); - self.collabLayerSearch = ko.observable(''); // filters the (possibly long) layer list below + self.collabLayerSearch = ko.observable(''); // filters "My layers" by name -- mainly useful once you've subscribed to a lot of them self.refreshingCollabLayers = ko.observable(false); self.deletingCollabLayerId = ko.observable(null); // id of the row currently mid-delete, if any + // ── Find a layer (discover/subscribe) ── + // Collapsed behind its own toggle by default, same reasoning as + // showCreateLayerForm above. + self.showDiscoverForm = ko.observable(false); + self.toggleDiscoverForm = () => { + self.showDiscoverForm(!self.showDiscoverForm()); + // Fetch on first open (rather than requiring a search/HQ pick + // first) so opening this immediately shows something -- no HQ + // picked means "all HQs", not "nothing", see runDiscoverSearch. + if (self.showDiscoverForm() && self.discoverResults().length === 0 && !self.discoverLoading()) { + self.runDiscoverSearch(); + } + }; + // Browses one HQ's layers at a time to find something to subscribe to + // -- deliberately not the same picker as newLayerHqPicker above (that + // one's "what HQ does my new layer belong to", this one's "what HQ am + // I browsing"), even though both default to the same launch HQ. + self.discoverHqPicker = makeHqPicker(deps.entitiesSearch); + self.discoverSearch = ko.observable(''); // filters the picked HQ's results by name + self.discoverLoading = ko.observable(false); + self.discoverResults = ko.observableArray([]); // raw layer objects for the picked HQ + self.discoverError = ko.observable(''); + + // Resolves ?hq= (deps.defaultHqId) once at startup and seeds + // both HQ pickers below with it -- the "new layer" picker so creating a + // layer defaults to this HQ, the "Find a layer" picker so discovery + // defaults to browsing this HQ's layers. Kept separately (self.defaultHq) + // so createCollabLayer can reset newLayerHqPicker back to it after each + // creation instead of clearing it to nothing (users creating several + // layers in a row are almost always doing it for the same HQ). + self.defaultHq = ko.observable(null); + if (deps.defaultHqId) { + Promise.resolve(deps.entity(deps.defaultHqId)).then(entity => { + if (!entity?.Id) return; + const hq = { id: String(entity.Id), name: entity.Name || String(entity.Id) }; + self.defaultHq(hq); + self.newLayerHqPicker.reset(hq); + self.discoverHqPicker.reset(hq); + }).catch(err => console.warn('Failed to resolve default HQ:', err)); + } + function relativeTime(iso) { if (!iso) return 'never'; const ms = Date.now() - new Date(iso).getTime(); @@ -230,26 +613,23 @@ export function ConfigVM(root, deps) { return `${Math.round(hrs / 24)}d ago`; } - // Applies the actual show/hide side-effect when a row's View switch - // changes. `row.key` is the unprefixed registry key used by - // mapVM.onlineLayers; `row.drawerKey` is the 'online-'-prefixed key the - // layers drawer uses for its `ov.` localStorage visibility flag - // (see getOverlayDefsForControl in Map.js) — both must be kept in sync. - // Any user can add/edit/delete markers on a visible layer directly on - // the map (right-click to add, popup buttons to edit/delete) — there's - // no separate "edit mode" toggle here, just View. - self._applyCollabLayerView = (row, enabled) => { - const layerObj = root.mapVM.onlineLayers.get(row.key)?.layerGroup; - if (!layerObj) return; - if (enabled) { - root.mapVM.map.addLayer(layerObj); - localStorage.setItem(`ov.${row.drawerKey}`, '1'); - } else { - root.mapVM.map.removeLayer(layerObj); - localStorage.setItem(`ov.${row.drawerKey}`, '0'); - } - root.mapVM.layersDrawer?.refresh?.(); - }; + // Layers created before the moderators feature only carry the old + // readOnly / allowDeleteByOthers / disableComments booleans and no mode + // fields -- derive the equivalent mode so old layers display and behave + // the same as before (mirrors lambda/map-layers-v2/lib/permissions.js + // and collabLayer.js's effective*Mode() helpers, which every + // permission-enforcing Lambda handler and the map popup gating also + // fall back to). + function effectiveMarkerMode(layer) { + return layer.markerMode || (layer.readOnly ? 'creator' : 'anyone'); + } + function effectiveDeleteMode(layer) { + return layer.deleteMode || (layer.allowDeleteByOthers === false ? 'creator' : 'anyone'); + } + function effectiveCommentMode(layer) { + return layer.commentMode || (layer.disableComments ? 'creator' : 'anyone'); + } + const MODE_LABELS = { anyone: 'Anyone', creator: 'Only the creator', moderators: 'The creator and moderators' }; // Sorted alphabetically so a long list stays scannable; filtered by // collabLayerSearch below for the same reason. @@ -257,33 +637,85 @@ export function ConfigVM(root, deps) { .slice() .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map(layer => { - const key = `collab-${layer.id}`; - const drawerKey = `online-${key}`; const memberId = deps.getMemberId?.(); const isCreator = !!memberId && memberId === layer.createdByMemberId; + const moderators = Array.isArray(layer.moderators) ? layer.moderators : []; + const isModerator = !!memberId && moderators.some(m => m?.id === memberId); + const markerMode = effectiveMarkerMode(layer); + const deleteMode = effectiveDeleteMode(layer); + const commentMode = effectiveCommentMode(layer); + const canDeleteByMode = deleteMode === 'anyone' || isCreator || (deleteMode === 'moderators' && isModerator); + // Creator or any current moderator can manage the moderator + // list (see lambda updateLayerModerators.js's isAuthorized + // check) -- a moderator can add/remove others, including + // themselves. + const canManageModerators = isCreator || isModerator; const row = { layer, - key, - drawerKey, name: layer.name, markerCount: layer.markerCount || 0, lastUsedLabel: relativeTime(layer.lastUsedAt), - viewEnabled: ko.observable(localStorage.getItem(`ov.${drawerKey}`) === '1'), - readOnly: !!layer.readOnly, - disableComments: !!layer.disableComments, - // allowDeleteByOthers defaults true server-side, so an - // absent/undefined value (older layers) reads as deletable. - canDelete: layer.allowDeleteByOthers !== false || isCreator, + markerMode, + commentMode, + markerRestricted: markerMode !== 'anyone', + commentRestricted: commentMode !== 'anyone', + markerModeTitle: `${MODE_LABELS[markerMode]} can add, edit or delete markers`, + commentModeTitle: `${MODE_LABELS[commentMode]} can comment`, + moderators, + moderatorCount: moderators.length, + // Precomputed (rather than a ternary in the data-bind + // attribute) because knockout-secure-binding's expression + // grammar doesn't support the conditional (?:) operator. + moderatorCountLabel: `${moderators.length} moderator${moderators.length === 1 ? '' : 's'}`, + moderatorNamesLabel: moderators.map(m => m.name).join(', '), + eventName: layer.eventName || null, + // Precomputed (rather than a ternary in the data-bind + // attribute) because knockout-secure-binding's expression + // grammar doesn't support the conditional (?:) operator. + eventLabel: layer.eventName + ? (layer.eventIdentifier ? `${layer.eventIdentifier} — ${layer.eventName}` : layer.eventName) + : null, + hqId: layer.hqId || null, + hqName: layer.hqName || null, + isCreator, + canDelete: canDeleteByMode, confirmingDelete: ko.observable(false), + // layer.createdBy is a raw Beacon person id -- resolved + // asynchronously (and cached) to a display name via + // root.resolvePersonName, same as marker/comment authorship + // elsewhere on this page. + authorName: ko.observable(''), + // Creator or any current moderator can manage moderators + // (enforced server-side too, see updateLayerModerators.js) + // -- everyone else doesn't get the "Manage moderators" + // control at all. + canManageModerators, + editingModerators: ko.observable(false), + savingModerators: ko.observable(false), + moderatorsError: ko.observable(''), + moderatorPicker: canManageModerators ? makeModeratorPicker(deps.searchMembers, moderators) : null, }; + if (layer.createdBy && root.resolvePersonName) { + root.resolvePersonName(layer.createdBy).then(name => row.authorName(name)); + } // Precomputed here (rather than a ternary in the data-bind // attribute) because knockout-secure-binding's expression // grammar doesn't support the conditional (?:) operator. - row.deleteTitle = row.canDelete ? 'Delete layer' : 'Only the layer creator can delete this layer'; - row.viewEnabled.subscribe((v) => self._applyCollabLayerView(row, v)); + row.deleteTitle = row.canDelete ? 'Delete layer' : `${MODE_LABELS[deleteMode]} can delete this layer`; + row.unsubscribe = () => self.unsubscribeLayer(row); row.requestDeleteLayer = () => row.confirmingDelete(true); row.cancelDeleteLayer = () => row.confirmingDelete(false); row.confirmDeleteLayer = () => self.deleteCollabLayer(row); + row.toggleEditModerators = () => { + row.moderatorsError(''); + row.moderatorPicker?.reset(row.moderators); + row.editingModerators(!row.editingModerators()); + }; + row.cancelEditModerators = () => { + row.moderatorPicker?.reset(row.moderators); + row.editingModerators(false); + }; + row.saveModerators = () => self.saveRowModerators(row); return row; })); @@ -294,24 +726,153 @@ export function ConfigVM(root, deps) { return rows.filter(row => row.name.toLowerCase().includes(q)); }); + // Precomputed (rather than a ternary in the data-bind attribute) + // because knockout-secure-binding's expression grammar doesn't support + // the conditional (?:) operator. + self.noLayersMessage = ko.pureComputed(() => + self.collabLayers().length === 0 + ? "You haven't subscribed to any layers yet — create one above, or find one to subscribe to below." + : ''); + + // Single computed driving both the visible condition and the message + // text -- avoids two separate bindings on the same element (a compound + // `visible` expression plus a `text:` interpolation) rendering + // inconsistently with each other for a frame. + self.noSearchMatchMessage = ko.pureComputed(() => { + const q = self.collabLayerSearch().trim(); + if (!q || self.collabLayers().length === 0 || self.filteredCollabLayerRows().length > 0) return ''; + return `No layers match "${q}".`; + }); + + // ── Find a layer (discover/subscribe) ── + // No HQ picked means "search all HQs" -- not "search nothing" -- so + // this always fetches something, scoped server-side when an HQ is + // picked (see lambda listLayers.js's hqId param) or unfiltered when not. + self.runDiscoverSearch = async () => { + if (!deps.apiUrl) { + self.discoverResults([]); + return; + } + self.discoverError(''); + self.discoverLoading(true); + try { + const hqId = self.discoverHqPicker.selected()?.id; + self.discoverResults(await searchLayersForHq(deps.apiUrl, deps.getToken, hqId)); + } catch (err) { + console.error('Error searching layers:', err); + self.discoverError('Failed to search layers. Try again later.'); + self.discoverResults([]); + } finally { + self.discoverLoading(false); + } + }; + self.discoverHqPicker.selected.subscribe(() => self.runDiscoverSearch()); + + self.discoverRows = ko.pureComputed(() => { + const subscribedIds = new Set(self.collabLayers().map(l => l.id)); + const q = self.discoverSearch().trim().toLowerCase(); + return self.discoverResults() + .filter(layer => !q || (layer.name || '').toLowerCase().includes(q)) + .slice() + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .map(layer => { + const drow = { + layer, + name: layer.name, + markerCount: layer.markerCount || 0, + lastUsedLabel: relativeTime(layer.lastUsedAt), + // hqName is shown per-row since browsing can span every + // HQ at once (no HQ picked -- see runDiscoverSearch above). + hqName: layer.hqName || null, + eventName: layer.eventName || null, + // Precomputed (rather than a ternary in the data-bind + // attribute) because knockout-secure-binding's + // expression grammar doesn't support the conditional + // (?:) operator. + eventLabel: layer.eventName + ? (layer.eventIdentifier ? `${layer.eventIdentifier} — ${layer.eventName}` : layer.eventName) + : null, + alreadySubscribed: subscribedIds.has(layer.id), + subscribing: ko.observable(false), + // layer.createdBy is a raw Beacon person id -- resolved + // asynchronously (and cached) to a display name via + // root.resolvePersonName, same as collabLayerRows above. + authorName: ko.observable(''), + }; + if (layer.createdBy && root.resolvePersonName) { + root.resolvePersonName(layer.createdBy).then(name => drow.authorName(name)); + } + return drow; + }); + }); + + // Precomputed (rather than a ternary in the data-bind attribute) + // because knockout-secure-binding's expression grammar doesn't support + // the conditional (?:) operator. + self.discoverEmptyMessage = ko.pureComputed(() => { + if (self.discoverLoading() || self.discoverRows().length > 0) return ''; + const hq = self.discoverHqPicker.selected(); + return hq ? `No layers found for ${hq.name}.` : 'No layers found.'; + }); + + self.subscribeToDiscoverRow = (discoverRow) => { + if (discoverRow.alreadySubscribed || discoverRow.subscribing()) return; + discoverRow.subscribing(true); + try { + subscribeToLayer(root, deps.apiUrl, discoverRow.layer, deps.actorId, deps.getToken, deps.getMemberId); + } finally { + discoverRow.subscribing(false); + } + }; + + self.unsubscribeLayer = (row) => { + unsubscribeFromLayer(root, row.layer.id); + }; + + // Shared by a successful create and an explicit Cancel -- puts the form + // back to its just-opened state. HQ resets to the resolved default (not + // empty), since creating several layers in a row, or reopening the form + // later, is almost always for the same HQ; everything else resets to + // its "no customisation" default. + function resetNewLayerForm() { + self.newLayerName(''); + self.newLayerMarkerMode('anyone'); + self.newLayerDeleteMode('anyone'); + self.newLayerCommentMode('anyone'); + self.newLayerModeratorPicker.reset([]); + self.newLayerEventPicker.reset(null); + self.newLayerHqPicker.reset(self.defaultHq()); + } + + self.cancelCreateLayer = () => { + self.collabLayerError(''); + resetNewLayerForm(); + self.showCreateLayerForm(false); + }; + self.createCollabLayer = async () => { const name = self.newLayerName().trim(); + const hq = self.newLayerHqPicker.selected(); if (!name || !deps.apiUrl) return; + if (!hq) { + self.collabLayerError('An HQ is required -- search for one above.'); + return; + } self.collabLayerError(''); self.creatingCollabLayer(true); try { const permissions = { - readOnly: self.newLayerReadOnly(), - allowDeleteByOthers: self.newLayerAllowDeleteByOthers(), - disableComments: self.newLayerDisableComments(), + markerMode: self.newLayerMarkerMode(), + deleteMode: self.newLayerDeleteMode(), + commentMode: self.newLayerCommentMode(), + moderators: self.newLayerModeratorPicker.moderators(), + event: self.newLayerEventPicker.selected(), + hq, }; const layer = await createCollabLayer(root, deps.apiUrl, name, deps.actorId, deps.getToken, permissions, deps.getMemberId); if (!layer) throw new Error('Create failed'); - self.newLayerName(''); - self.newLayerReadOnly(false); - self.newLayerAllowDeleteByOthers(true); - self.newLayerDisableComments(false); + resetNewLayerForm(); } catch (err) { console.error('Error creating collaborative layer:', err); self.collabLayerError('Failed to create layer. Try again later.'); @@ -320,16 +881,17 @@ export function ConfigVM(root, deps) { } }; - // Re-pulls the org's layer list from the server -- picks up layers - // created by other users since this page loaded (createCollabLayer - // above only accounts for layers *this* session created). + // Re-pulls "My layers" from the server -- picks up any changes to + // layers this user is subscribed to since this page loaded + // (createCollabLayer above only accounts for layers *this* session + // created/subscribed to). self.refreshCollabLayers = async () => { if (!deps.apiUrl || self.refreshingCollabLayers()) return; self.collabLayerError(''); self.refreshingCollabLayers(true); try { - await refreshCollabLayerList(root, deps.apiUrl, deps.actorId, deps.getToken, deps.getMemberId); + await refreshSubscribedLayers(root, deps.apiUrl, deps.actorId, deps.getToken, deps.getMemberId); } catch (err) { console.error('Error refreshing collaborative layers:', err); self.collabLayerError('Failed to refresh layer list. Try again later.'); @@ -358,6 +920,35 @@ export function ConfigVM(root, deps) { } }; + // Saves a row's in-progress moderator picker as the layer's new + // moderator list, fired from row.saveModerators above. Only rows the + // creator or a current moderator can manage ever get a moderatorPicker + // (see collabLayerRows' canManageModerators), so there's no separate + // authorization check needed here -- the Lambda enforces it + // authoritatively either way. + self.saveRowModerators = async (row) => { + if (!deps.apiUrl || !row.moderatorPicker || row.savingModerators()) return; + + row.moderatorsError(''); + row.savingModerators(true); + try { + const moderators = row.moderatorPicker.moderators(); + const saved = await updateCollabLayerModerators(root, deps.apiUrl, row.layer.id, moderators, deps.getToken); + if (saved == null) throw new Error('Update failed'); + // updateCollabLayerModerators mutates row.layer.moderators in + // place (it's the same object reference held in + // self.collabLayers()) -- force collabLayerRows to recompute so + // this row (and its canDelete/moderator badges) reflect the new + // list immediately, same as a poll-driven refresh would. + self.collabLayers.valueHasMutated(); + } catch (err) { + console.error('Error updating layer moderators:', err); + row.moderatorsError('Failed to save moderators. Try again later.'); + } finally { + row.savingModerators(false); + } + }; + // Named methods (rather than inline functions in data-bind attributes) // because knockout-secure-binding's restricted grammar doesn't support // control-flow statements like `if` inside inline function literals. diff --git a/src/shared/BeaconClient.js b/src/shared/BeaconClient.js index ab3c0ca8..461b3f73 100644 --- a/src/shared/BeaconClient.js +++ b/src/shared/BeaconClient.js @@ -22,11 +22,13 @@ import * as suppliers from './BeaconClient/suppliers.js'; import * as images from './BeaconClient/images.js'; import * as icems from './BeaconClient/icems.js'; import * as people from './BeaconClient/people.js'; +import * as users from './BeaconClient/users.js'; +import * as events from './BeaconClient/events.js'; -export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people }; +export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people, users, events }; // re-export functions -export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people, toFormUrlEncoded }; +export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, people, users, events, toFormUrlEncoded }; export function toFormUrlEncoded(obj) { const params = []; for (const key in obj) { diff --git a/src/shared/BeaconClient/events.js b/src/shared/BeaconClient/events.js new file mode 100644 index 00000000..ad24546b --- /dev/null +++ b/src/shared/BeaconClient/events.js @@ -0,0 +1,32 @@ +import $ from 'jquery'; + +// A single free-text `query` is sent against both EventName and Identifier +// simultaneously (same "OR across fields" shape as Users/Search) so callers +// don't need to guess whether the user typed an event name or its +// identifier (e.g. "6/1718"). ViewModelType=2 mirrors Beacon's own event +// picker requests. +export function search(query, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'GET', + url: host + '/Api/v1/Events/Search?EventName=' + encodeURIComponent(query) + + '&Identifier=' + encodeURIComponent(query) + + '&ViewModelType=2&PageSize=10&SortField=identifier&SortOrder=asc' + + '&LighthouseFunction=SearchEvents&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); +} diff --git a/src/shared/BeaconClient/users.js b/src/shared/BeaconClient/users.js new file mode 100644 index 00000000..6c02086d --- /dev/null +++ b/src/shared/BeaconClient/users.js @@ -0,0 +1,53 @@ +import $ from 'jquery'; + +/** + * Builds the FirstName/LastName/Username/Email query params for a single + * free-text search box. A query containing a space is unambiguously a + * "firstname lastname" search (nobody's Username or Email has a space in + * it), so it's split -- everything before the last word as FirstName, + * the last word as LastName -- rather than sent as one blob to every field, + * which would rarely match anything. A single word (a name, member number, + * or partial email) is still sent against all four fields simultaneously + * (mirroring how Beacon's own admin UI searches this endpoint) so callers + * don't need to guess which kind of value the user typed. + */ +function buildSearchParams(query) { + const trimmed = String(query || '').trim(); + const words = trimmed.split(/\s+/).filter(Boolean); + + if (words.length > 1) { + const lastName = words[words.length - 1]; + const firstName = words.slice(0, -1).join(' '); + return 'FirstName=' + encodeURIComponent(firstName) + '&LastName=' + encodeURIComponent(lastName); + } + + return 'FirstName=' + encodeURIComponent(trimmed) + + '&LastName=' + encodeURIComponent(trimmed) + + '&Username=' + encodeURIComponent(trimmed) + + '&Email=' + encodeURIComponent(trimmed); +} + +export function search(query, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'GET', + url: host + '/Api/v1/Users/Search?' + buildSearchParams(query) + + '&External=false&IsDeleted=false&PageIndex=1&PageSize=10' + + '&LighthouseFunction=SearchUsers&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); +} diff --git a/static/pages/tasking.html b/static/pages/tasking.html index aeddb226..10dc0bb4 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -2805,103 +2805,379 @@

Shared marker layers that everyone in your organisation can see and - edit together. Enable View to show a layer on the map — once - visible, right-click anywhere on the map to drop a marker on it. - Layers unused for 120 days stop appearing here, but their data - isn't deleted. + edit together. Create one, or find one below and subscribe to add + it to your list. Use the Layers + button on the map to actually show/hide a layer you've subscribed + to — once visible, right-click anywhere on the map to drop a + marker on it. Layers unused for 120 days stop appearing in search, + but their data isn't deleted.

+ + + + +
- +
+ +
+
HQ (required)
+ +
+ + +
+ + + + + + + +
+
+
Attach to event (optional)
+ +
+ + +
+ + + + + + + + +
Marker permissions
- - + title="Everyone can add, edit and delete markers (everyone can also comment, unless restricted below)."> + +
- - + + +
+
+ +
Delete permissions
- - + +
- - + + +
+
+ +
Comment permissions
- - + +
- - + title="Only you will be able to comment on this layer's markers."> + + +
+
+ + +
+ +
+
Moderators
+
+ + + +
+
+ + + + +
+
+ + +
+
+ + + + + + +
+
+
Search HQ (optional — leave blank to search every HQ)
+ +
+ + +
+ + + + + + + + +
+
+ + +
+
    +
  • +
    +
    +
    + +
    +
    + by · + markers · + used + + + +
    +
    + + + Subscribed + +
  • +
+
+
+
+
+ data-bind="visible: config.collabLayers().length > 5"> -
    -
  • +
  • +
    +
    + +
    + by · markers · used - - + + + + + +
    -
    - - -
    - + + + @@ -2911,15 +3187,64 @@
    +
    +
    +
    + + + +
    +
    + + + + +
    +
    +
    + + +
    +
-
- No collaborative layers yet — create one above. +
-
- No layers match "". +
diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index bab5b25b..944d3dd2 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -2633,6 +2633,41 @@ overflow: hidden; line-height: 1.4; } +/* ── Moderator picker (new-layer form + per-row "Manage moderators") ── */ +.collab-moderator-picker { + width: 100%; +} +.collab-moderator-edit { + border-top: 1px solid #eee; + padding-top: 8px; +} +.collab-moderator-dropdown { + max-height: 200px; + overflow: auto; + position: absolute; + top: 100%; + z-index: 5; +} +.collab-moderator-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.collab-moderator-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: normal; +} +.collab-moderator-chip .btn-close { + font-size: 9px; + padding: 0; + opacity: 0.8; +} +.collab-moderator-chip .btn-close:hover { + opacity: 1; +} + /* ── Collaborative marker popup (view/edit) ── */ .collab-marker-popup { font-size: 12px; From 903797d379b2250ef0051bbf884e9f82e8fd21e4 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Thu, 6 Aug 2026 13:15:38 +1000 Subject: [PATCH 17/26] Let layer owners/moderators edit collaborative layer permissions after creation (#394) Previously markerMode/deleteMode/commentMode were fixed at creation. Adds a PUT /permissions Lambda route (creator-or-moderator authorized, same as moderator management) and a standalone Config modal to edit them, with poll-driven sync so other subscribers see permission changes without a manual refresh. Also includes the getWritableCollabLayers rename and Add-marker disabled state that were already uncommitted in the working tree and are required for the collab layer permission model to be consistent. --- lambda/map-layers-v2/handlers/createLayer.js | 17 +- .../handlers/updateLayerModerators.js | 14 +- .../handlers/updateLayerPermissions.js | 61 +++++++ lambda/map-layers-v2/index.js | 2 + .../tasking/components/mapContextMenu.js | 7 +- src/pages/tasking/main.js | 4 +- src/pages/tasking/mapLayers/collabLayer.js | 159 +++++++++++++----- src/pages/tasking/utils/collabLayerSync.js | 68 +++++++- src/pages/tasking/viewmodels/Config.js | 122 +++++++++++++- src/pages/tasking/viewmodels/Map.js | 1 + static/pages/tasking.html | 83 ++++++++- 11 files changed, 455 insertions(+), 83 deletions(-) create mode 100644 lambda/map-layers-v2/handlers/updateLayerPermissions.js diff --git a/lambda/map-layers-v2/handlers/createLayer.js b/lambda/map-layers-v2/handlers/createLayer.js index 330c0b03..a111b227 100644 --- a/lambda/map-layers-v2/handlers/createLayer.js +++ b/lambda/map-layers-v2/handlers/createLayer.js @@ -51,13 +51,11 @@ function sanitizeHq(input) { // POST /map-layers // body: { apiUrl, name, createdBy, hq, markerMode?, deleteMode?, commentMode?, moderators?, event? } // -// The permission modes and moderator list are fixed at creation time -- -// there's no "edit layer settings" flow for the modes themselves, so every -// handler that reads them back off the stored layer/summary can treat them -// as immutable for that layer's lifetime. The moderator list itself *is* -// editable later by the creator (see updateLayerModerators.js) since who -// should moderate a layer can change over an incident's lifetime even when -// the permission structure doesn't. +// The permission modes default to 'anyone' here at creation time, but -- +// like the moderator list -- can be changed later by the creator or a +// current moderator (see updateLayerPermissions.js / updateLayerModerators.js) +// since both who should moderate a layer and how open it should be can +// change over an incident's lifetime. // // Each of markerMode/deleteMode/commentMode is one of 'anyone' | 'creator' // | 'moderators' (default 'anyone' if omitted/invalid): @@ -78,8 +76,9 @@ function sanitizeHq(input) { // // `event` (optional): { id, name } of a Beacon event this layer relates to, // stored as eventId/eventName -- purely for display in the layer list -// (Config.js), same "fixed at creation" rule as the permission modes above, -// no later "attach/detach event" flow. +// (Config.js), fixed at creation with no later "attach/detach event" flow +// (unlike the permission modes above, which can be changed later -- see +// updateLayerPermissions.js). // // "The creator" for all of the above means `createdByMemberId` -- // `claims.sub`, the Beacon member id off the caller's own verified token -- diff --git a/lambda/map-layers-v2/handlers/updateLayerModerators.js b/lambda/map-layers-v2/handlers/updateLayerModerators.js index 7cbc42f8..7f8760ed 100644 --- a/lambda/map-layers-v2/handlers/updateLayerModerators.js +++ b/lambda/map-layers-v2/handlers/updateLayerModerators.js @@ -23,13 +23,13 @@ function sanitizeModerators(input) { // PUT /map-layers/{id}/moderators body: { apiUrl, moderators: [{id, name}] } // -// Unlike markerMode/deleteMode/commentMode (fixed at creation, see -// createLayer.js), the moderator list itself can be updated later -- who -// should moderate a layer changes over an incident's lifetime even when the -// permission structure doesn't. The creator or any *current* moderator may -// change it (isAuthorized('moderators', ...) -- same rule as the -// marker/delete/comment 'moderators' mode: creator plus anyone already on -// the list), so a stranger still can't silently add themselves. Replaces +// The moderator list, like markerMode/deleteMode/commentMode (see +// updateLayerPermissions.js), can be updated after creation -- who should +// moderate a layer changes over an incident's lifetime, same as how open it +// should be. The creator or any *current* moderator may change it +// (isAuthorized('moderators', ...) -- same rule as the marker/delete/comment +// 'moderators' mode: creator plus anyone already on the list), so a +// stranger still can't silently add themselves. Replaces // the full list rather than diffing (simpler, and the client always sends // its complete current list -- see collabLayerSync.js's // updateLayerModerators). A moderator removing themselves (or every other diff --git a/lambda/map-layers-v2/handlers/updateLayerPermissions.js b/lambda/map-layers-v2/handlers/updateLayerPermissions.js new file mode 100644 index 00000000..c84117f9 --- /dev/null +++ b/lambda/map-layers-v2/handlers/updateLayerPermissions.js @@ -0,0 +1,61 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { normalizeMode, markerMode, deleteMode, commentMode, isAuthorized } = require('../lib/permissions'); + +// PUT /map-layers/{id}/permissions body: { apiUrl, markerMode?, deleteMode?, commentMode? } +// +// Like the moderator list (updateLayerModerators.js), the three permission +// modes turn out not to be fixed for a layer's whole lifetime after all -- +// this is what lets the creator or a moderator loosen/tighten them later +// (e.g. opening up marker creation once an incident calms down). Same +// authorization rule as updateLayerModerators: creator or any *current* +// moderator (isAuthorized('moderators', ...)), so a stranger can't reduce +// their own restrictions. Any mode omitted from the body, or not one of the +// valid 'anyone' | 'creator' | 'moderators' values, is left unchanged rather +// than silently reset to 'anyone'. +module.exports = async function updateLayerPermissions(event, claims) { + const layerId = event.pathParameters?.id; + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + const apiUrl = body.apiUrl; + const memberId = String(claims?.sub || ''); + + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + if (!isAuthorized('moderators', layer, memberId)) { + return forbidden('Only the layer creator or a moderator can manage permissions'); + } + + const newMarkerMode = normalizeMode(body.markerMode) || markerMode(layer); + const newDeleteMode = normalizeMode(body.deleteMode) || deleteMode(layer); + const newCommentMode = normalizeMode(body.commentMode) || commentMode(layer); + + const now = new Date().toISOString(); + layer.markerMode = newMarkerMode; + layer.deleteMode = newDeleteMode; + layer.commentMode = newCommentMode; + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.markerMode = newMarkerMode; + entry.deleteMode = newDeleteMode; + entry.commentMode = newCommentMode; + entry.lastUsedAt = now; + } + }); + + return json(200, { markerMode: newMarkerMode, deleteMode: newDeleteMode, commentMode: newCommentMode }); +}; diff --git a/lambda/map-layers-v2/index.js b/lambda/map-layers-v2/index.js index 28413425..ddeb04ee 100644 --- a/lambda/map-layers-v2/index.js +++ b/lambda/map-layers-v2/index.js @@ -10,6 +10,7 @@ const deleteFeature = require('./handlers/deleteFeature'); const addMarkerComment = require('./handlers/addMarkerComment'); const deleteLayer = require('./handlers/deleteLayer'); const updateLayerModerators = require('./handlers/updateLayerModerators'); +const updateLayerPermissions = require('./handlers/updateLayerPermissions'); // Single Lambda fronting all /lad_v2/map-layers routes via API Gateway HTTP // API (payload format 2.0) Lambda proxy integration. Routed by @@ -24,6 +25,7 @@ const ROUTES = { 'GET /lad_v2/map-layers/{id}': getLayer, 'DELETE /lad_v2/map-layers/{id}': deleteLayer, 'PUT /lad_v2/map-layers/{id}/moderators': updateLayerModerators, + 'PUT /lad_v2/map-layers/{id}/permissions': updateLayerPermissions, 'PUT /lad_v2/map-layers/{id}/features': upsertFeature, 'DELETE /lad_v2/map-layers/{id}/features/{markerId}': deleteFeature, 'POST /lad_v2/map-layers/{id}/features/{markerId}/comments': addMarkerComment, diff --git a/src/pages/tasking/components/mapContextMenu.js b/src/pages/tasking/components/mapContextMenu.js index 4ca04978..ff64a860 100644 --- a/src/pages/tasking/components/mapContextMenu.js +++ b/src/pages/tasking/components/mapContextMenu.js @@ -83,7 +83,12 @@ export function installMapContextMenu({ lastLatLng = e.latlng; if (btnAddMarker) { - btnAddMarker.classList.toggle("d-none", !canAddMarker?.()); + const canAdd = !!canAddMarker?.(); + btnAddMarker.classList.toggle("disabled", !canAdd); + btnAddMarker.disabled = !canAdd; + btnAddMarker.title = canAdd + ? "" + : "Subscribe to a collaborative layer you can add markers to first"; } const p = map.latLngToContainerPoint(e.latlng); diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 60baf139..fda42fc7 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -66,7 +66,7 @@ import { registerWaterNSWBoundariesLayer, registerEPAContaminationSitesLayer } f import { registerNSWDeclaredDamsLayer } from "./mapLayers/dams.js"; import { registerBOMLandWarningsLayer } from "./mapLayers/bom.js"; import { registerRainRadarLayer } from "./mapLayers/rainviewer.js"; -import { registerCollabLayers, getVisibleCollabLayers, startAddMarkerFlow } from "./mapLayers/collabLayer.js"; +import { registerCollabLayers, getWritableCollabLayers, startAddMarkerFlow } from "./mapLayers/collabLayer.js"; import { registerBOMRainfallLayer, registerBOMRadarLayer, @@ -275,7 +275,7 @@ installMapContextMenu({ // existing `var myViewModel;` module-level pattern below) -- these // callbacks only run later, on an actual right-click, by which point // it's fully populated. - canAddMarker: () => getVisibleCollabLayers(myViewModel, getMemberId).length > 0, + canAddMarker: () => getWritableCollabLayers(myViewModel, getMemberId).length > 0, onAddMarker: (latlng) => startAddMarkerFlow(myViewModel, sourceUrl, markerActorId, latlng, getToken, getMemberId), }); diff --git a/src/pages/tasking/mapLayers/collabLayer.js b/src/pages/tasking/mapLayers/collabLayer.js index dd6de290..4502f352 100644 --- a/src/pages/tasking/mapLayers/collabLayer.js +++ b/src/pages/tasking/mapLayers/collabLayer.js @@ -5,6 +5,7 @@ import { createLayer, deleteLayer, updateLayerModerators, + updateLayerPermissions, fetchLayerMarkers, upsertMarker, deleteMarker, @@ -83,28 +84,21 @@ function wireCharCounter(inputEl, limit) { update(); } -/** Collaborative layers currently toggled visible on the map. */ -function visibleCollabLayers(vm) { - return (vm.mapVM.collabLayers() || []).filter((layer) => { - const entry = vm.mapVM.onlineLayers.get(layerKeyFor(layer.id)); - return entry && vm.mapVM.map.hasLayer(entry.layerGroup); - }); -} - // ── Permissions ────────────────────────────────────────────────────── // -// A layer's markerMode / deleteMode / commentMode are set once at creation -// time (see Config.js's createCollabLayer form) and never change afterward, -// so there's no staleness concern reading them straight off whatever layer -// object is already in hand. `moderators` (who counts as a moderator under -// the 'moderators' mode) *can* change later -- the creator can add/remove -// moderators after creation (see Config.js's per-row "Manage moderators") -// -- but it's still read straight off the layer object already in hand, -// same as the modes; a stale moderator list here just means the UI is -// briefly out of date until the next poll, not a security gap (the Lambda -// enforces authoritatively, see lambda/map-layers-v2). This client-side -// gating only decides what to show/hide so a user isn't invited to attempt -// something that will just come back as a 403. +// A layer's markerMode / deleteMode / commentMode default to 'anyone' at +// creation time (see Config.js's createCollabLayer form) but, like +// `moderators`, can be changed later by the creator or a current moderator +// (see Config.js's per-row "Manage permissions" and +// updateCollabLayerPermissions below). Both are read straight off whatever +// layer object is already in hand rather than re-fetched on every check -- +// registerLayerPolling's drawFn keeps that object's mode/moderator fields +// synced from each poll response (syncLayerPermissionFields below), so a +// stale read here just means the UI is briefly out of date until the next +// poll tick, not a security gap (the Lambda enforces authoritatively, see +// lambda/map-layers-v2). This client-side gating only decides what to +// show/hide so a user isn't invited to attempt something that will just +// come back as a 403. // // Each mode is one of 'anyone' | 'creator' | 'moderators'. Layers created // before this feature only carry the old readOnly / allowDeleteByOthers / @@ -151,6 +145,38 @@ function canCommentOnLayer(layer, getMemberId) { return isAuthorizedForMode(effectiveCommentMode(layer), layer, getMemberId); } +/** + * Copy the permission-relevant fields (markerMode/deleteMode/commentMode/ + * moderators) from a freshly-fetched layer (`data`, the full layer response + * from fetchLayerMarkers/getLayer.js) onto the long-lived `layer` object a + * subscribed user's session is holding. This is what lets someone else's + * "Manage permissions" or "Manage moderators" change (see + * updateCollabLayerPermissions/updateCollabLayerModerators) show up for + * every other subscriber -- not just the person who made it -- within one + * polling interval: `layer` is the same object reference closed over by + * this layer's drawFn (below) and held in vm.mapVM.collabLayers(), so + * mutating it here is immediately visible to both the next popup render + * and Config.js's per-row permission/moderator display. + */ +function syncLayerPermissionFields(vm, layer, data) { + if (!data) return; + const changed = layer.markerMode !== data.markerMode + || layer.deleteMode !== data.deleteMode + || layer.commentMode !== data.commentMode + || JSON.stringify(layer.moderators) !== JSON.stringify(data.moderators); + if (!changed) return; + + layer.markerMode = data.markerMode; + layer.deleteMode = data.deleteMode; + layer.commentMode = data.commentMode; + layer.moderators = data.moderators; + // Config.js's collabLayerRows is a pureComputed over collabLayers() -- + // mutating a field on an object already inside that observableArray + // doesn't itself trigger a recompute, same as updateCollabLayerModerators + // requiring Config.js's saveRowModerators to call this explicitly. + vm.mapVM.collabLayers.valueHasMutated?.(); +} + /** * Register the polling Leaflet layer for a single collaborative layer. * Visibility is controlled entirely by the existing layers drawer / @@ -160,13 +186,20 @@ function canCommentOnLayer(layer, getMemberId) { */ function registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId) { const key = layerKeyFor(layer.id); + // No stored preference yet (brand new subscription) defaults to shown; + // an explicit prior '0'/'1' from the layers drawer toggle always wins, + // so a layer someone has deliberately hidden stays hidden across reloads. + const stored = localStorage.getItem(`ov.online-${key}`); vm.mapVM.registerPollingLayer(key, { label: layer.name, menuGroup: "Collaborative Layers", refreshMs: REFRESH_MS, - visibleByDefault: false, + visibleByDefault: stored === null ? true : stored === "1", fetchFn: async () => fetchLayerMarkers(apiUrl, layer.id, await getToken()), - drawFn: (layerGroup, data) => drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId), + drawFn: (layerGroup, data) => { + syncLayerPermissionFields(vm, layer, data); + drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId); + }, skipIfBusy: () => busyLayerKeys.has(key), }); } @@ -212,7 +245,7 @@ export async function searchLayersForHq(apiUrl, getToken, hqId) { * Called once at startup (main.js), alongside the other register*Layer * calls. The right-click "Add marker" trigger itself is wired up * separately, into the app's existing map context menu (see - * components/mapContextMenu.js + startAddMarkerFlow/getVisibleCollabLayers + * components/mapContextMenu.js + startAddMarkerFlow/getWritableCollabLayers * above) rather than a second contextmenu listener here. */ export async function registerCollabLayers(vm, apiUrl, actorId, getToken, getMemberId) { @@ -233,10 +266,9 @@ export async function createCollabLayer(vm, apiUrl, name, actorId, getToken, per const key = layerKeyFor(layer.id); registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId); // Auto-show layers the user just created -- matches the 'ov.' - // flag the layers drawer (main.js) reads to decide initial visibility. - // The one deliberate exception to "Config subscribes, LayersDrawer - // shows/hides": whoever just made a layer almost certainly wants to - // see it immediately, without a second trip to the Layers control. + // flag the layers drawer (main.js) reads to decide initial visibility + // (see subscribeToLayer below, which does the same for an existing + // layer someone subscribes to). localStorage.setItem(`ov.online-${key}`, '1'); vm.mapVM.layersDrawer?.refresh?.(); vm.mapVM.refreshPollingLayer(key); @@ -267,9 +299,9 @@ export async function deleteCollabLayer(vm, apiUrl, layerId, actorId, getToken) /** * Subscribe to an already-existing layer (found via Config.js's "Find a * layer" search) -- adds it to "My layers", registers it for - * polling/LayersDrawer, but leaves it hidden until explicitly shown via - * the Layers control (see createCollabLayer's comment for the one - * exception to that rule). + * polling/LayersDrawer, and shows it immediately (same as createCollabLayer + * -- whoever just subscribed almost certainly wants to see it right away, + * without a second trip to the Layers control). */ export function subscribeToLayer(vm, apiUrl, layer, actorId, getToken, getMemberId) { subscribeLayer(layer.id); @@ -277,10 +309,13 @@ export function subscribeToLayer(vm, apiUrl, layer, actorId, getToken, getMember if (!list.some((l) => l.id === layer.id)) { vm.mapVM.collabLayers([...list, layer]); } - if (!vm.mapVM.onlineLayers.has(layerKeyFor(layer.id))) { + const key = layerKeyFor(layer.id); + if (!vm.mapVM.onlineLayers.has(key)) { registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId); } + localStorage.setItem(`ov.online-${key}`, '1'); vm.mapVM.layersDrawer?.refresh?.(); + vm.mapVM.refreshPollingLayer(key); } /** @@ -314,6 +349,31 @@ export async function updateCollabLayerModerators(vm, apiUrl, layerId, moderator return saved; } +/** + * Update a layer's markerMode/deleteMode/commentMode (creator-or-moderator + * only, enforced server-side -- see lambda updateLayerPermissions.js). + * Updates the in-memory layer object (shared by reference with + * vm.mapVM.collabLayers()'s entry, same as updateCollabLayerModerators + * above) on success so Config.js's row immediately reflects the new modes + * without waiting for the next poll/refresh -- and so this session's own + * marker popups (canWriteMarkers/canCommentOnLayer, read straight off this + * same object) pick up the change on their next open. Other sessions + * subscribed to this layer pick it up via syncLayerPermissionFields, once + * their own polling next ticks. + */ +export async function updateCollabLayerPermissions(vm, apiUrl, layerId, permissions, getToken) { + const saved = await updateLayerPermissions(apiUrl, layerId, permissions, await getToken()); + if (saved == null) return null; + + const layer = vm.mapVM.collabLayers().find((l) => l.id === layerId); + if (layer) { + layer.markerMode = saved.markerMode; + layer.deleteMode = saved.deleteMode; + layer.commentMode = saved.commentMode; + } + return saved; +} + // ── Drawing ────────────────────────────────────────────────────────── function drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId) { @@ -841,10 +901,15 @@ function openFloatingDropdown(vm, anchorEl, className, populate) { // ── Right-click "add marker" ───────────────────────────────────────── // -// Only available when at least one collaborative layer is currently -// visible. With exactly one visible layer, right-click opens the marker -// form immediately. With more than one visible, right-click shows a small -// picker so the user chooses which layer receives the new marker. +// Enabled whenever the user is subscribed to at least one collaborative +// layer they can write markers to -- independent of whether that layer's +// map overlay is currently toggled on, since most layers default to +// hidden (registerLayerPolling's visibleByDefault: false) and requiring +// visibility here would leave the item permanently disabled for anyone +// who hasn't also flipped the layers-drawer checkbox. With exactly one +// writable layer, right-click opens the marker form immediately. With +// more than one, right-click shows a small picker so the user chooses +// which layer receives the new marker. let openContextMenu = null; // cleanup for a currently-open picker menu, if any @@ -907,32 +972,34 @@ function showLayerPickerMenu(vm, apiUrl, actorId, layers, containerPoint, latlng } /** - * Visible collaborative layers the current user may add a marker to -- - * excludes read-only layers they didn't create. Drives whether the "Add - * marker" item in the map's right-click context menu is shown at all. + * Subscribed collaborative layers the current user may add a marker to -- + * excludes read-only layers they didn't create/moderate. Drives whether + * the "Add marker" item in the map's right-click context menu is enabled + * (it always shows, but is disabled when this comes back empty -- see + * components/mapContextMenu.js). */ -export function getVisibleCollabLayers(vm, getMemberId) { - return visibleCollabLayers(vm).filter((layer) => canWriteMarkers(layer, getMemberId)); +export function getWritableCollabLayers(vm, getMemberId) { + return (vm.mapVM.collabLayers() || []).filter((layer) => canWriteMarkers(layer, getMemberId)); } /** * Entry point for the "Add marker to shared layer" item in the app's * existing right-click context menu (components/mapContextMenu.js). With - * exactly one visible (writable) layer, opens the marker form immediately; + * exactly one writable subscribed layer, opens the marker form immediately; * with more than one, shows a small picker so the user chooses which layer * receives the new marker. */ export function startAddMarkerFlow(vm, apiUrl, actorId, latlng, getToken, getMemberId) { closeContextMenu(); - const visible = getVisibleCollabLayers(vm, getMemberId); - if (visible.length === 0) return; + const writable = getWritableCollabLayers(vm, getMemberId); + if (writable.length === 0) return; - if (visible.length === 1) { - openMarkerForm(vm, apiUrl, visible[0].id, layerKeyFor(visible[0].id), actorId, null, latlng, getToken); + if (writable.length === 1) { + openMarkerForm(vm, apiUrl, writable[0].id, layerKeyFor(writable[0].id), actorId, null, latlng, getToken); return; } const containerPoint = vm.mapVM.map.latLngToContainerPoint(latlng); - showLayerPickerMenu(vm, apiUrl, actorId, visible, containerPoint, latlng, getToken); + showLayerPickerMenu(vm, apiUrl, actorId, writable, containerPoint, latlng, getToken); } diff --git a/src/pages/tasking/utils/collabLayerSync.js b/src/pages/tasking/utils/collabLayerSync.js index 4c29e6ed..dc065e45 100644 --- a/src/pages/tasking/utils/collabLayerSync.js +++ b/src/pages/tasking/utils/collabLayerSync.js @@ -161,14 +161,14 @@ export async function listLayers(apiUrl, token, hqId) { * @param {string} actorId * @param {string} token Beacon access token (Authorization: Bearer). * @param {{markerMode?: string, deleteMode?: string, commentMode?: string, moderators?: Array<{id: string, name: string}>, event?: {id: string, name: string}|null, hq: {id: string, name: string}}} permissions - * Each mode is one of 'anyone' | 'creator' | 'moderators' (default 'anyone'). - * Modes are fixed for the layer's lifetime -- there's no later "edit layer - * settings" flow for them -- but `moderators` itself can be changed later - * by the creator via updateLayerModerators() below. `event`, if given, is - * the optional Beacon event this layer is attached to -- also fixed at - * creation, purely for display (see Config.js's layer list). `hq` is - * required -- every layer must belong to an HQ (also fixed at creation); - * the Lambda rejects the request if it's missing. + * Each mode is one of 'anyone' | 'creator' | 'moderators' (default 'anyone') + * at creation, but -- like `moderators` -- can be changed later by the + * creator or a current moderator via updateLayerPermissions()/ + * updateLayerModerators() below. `event`, if given, is the optional + * Beacon event this layer is attached to -- fixed at creation, purely for + * display (see Config.js's layer list). `hq` is required -- every layer + * must belong to an HQ (also fixed at creation); the Lambda rejects the + * request if it's missing. * @returns {Promise} the created layer summary, or null on failure */ export async function createLayer(apiUrl, name, actorId, token, permissions = {}) { @@ -286,6 +286,58 @@ export async function updateLayerModerators(apiUrl, layerId, moderators, token) } } +/** + * Update a layer's markerMode/deleteMode/commentMode. Only the creator or a + * current moderator is authorized server-side (see lambda + * updateLayerPermissions.js) -- calling this as anyone else fails with a + * 403 and the local cache is left untouched. Any mode omitted from + * `permissions` is left unchanged rather than reset to 'anyone'. + * @param {string} apiUrl + * @param {string} layerId + * @param {{markerMode?: string, deleteMode?: string, commentMode?: string}} permissions + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise<{markerMode: string, deleteMode: string, commentMode: string}|null>} the saved modes, or null on failure + */ +export async function updateLayerPermissions(apiUrl, layerId, permissions, token) { + if (!apiUrl || !layerId) return null; + + try { + const res = await fetch(`${LAMBDA_BASE}/${encodeURIComponent(layerId)}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ + apiUrl, + markerMode: permissions?.markerMode, + deleteMode: permissions?.deleteMode, + commentMode: permissions?.commentMode, + }), + }); + if (!res.ok) { + throw new Error(`updateLayerPermissions failed with status ${res.status}`); + } + const saved = await res.json(); + + // Reconcile the cached index entry (if present) so a page reload + // before the next refreshCollabLayerList() still shows the update. + const index = loadCachedLayerIndex(); + const entry = index.find((l) => l.id === layerId); + if (entry) { + Object.assign(entry, saved); + saveCachedLayerIndex(index); + } + const cachedLayer = loadCachedLayer(layerId); + if (cachedLayer) { + Object.assign(cachedLayer, saved); + saveCachedLayer(layerId, cachedLayer); + } + + return saved; + } catch (err) { + console.warn('[collabLayerSync] updateLayerPermissions error:', err); + return null; + } +} + // ── Layer markers ──────────────────────────────────────────────────── /** diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index ecff0eb6..3824fcd0 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -4,7 +4,7 @@ import ko from 'knockout'; import * as bootstrap from 'bootstrap5'; // Modal, Tooltip, etc. import { Enum } from '../utils/enum.js'; import { - createCollabLayer, deleteCollabLayer, updateCollabLayerModerators, + createCollabLayer, deleteCollabLayer, updateCollabLayerModerators, updateCollabLayerPermissions, refreshSubscribedLayers, searchLayersForHq, subscribeToLayer, unsubscribeFromLayer, } from '../mapLayers/collabLayer.js'; @@ -532,18 +532,21 @@ export function ConfigVM(root, deps) { // (createCollabLayer) and server-side (createLayer.js). self.newLayerHqPicker = makeHqPicker(deps.entitiesSearch); // Optional Beacon event this layer relates to -- purely display - // metadata (like createdBy), fixed at creation same as the permission - // modes below, no later "attach/detach event" flow. + // metadata (like createdBy), fixed at creation with no later + // "attach/detach event" flow (unlike the permission modes below, which + // -- like the moderator list -- can be changed later). self.newLayerEventPicker = makeEventPicker(deps.searchEvents); - // Each mode is fixed for a layer's whole lifetime once created -- there's - // no later "edit permissions" flow, by design (see - // mapLayers/collabLayer.js). Each backed by a 3-way radio group in + // Each mode defaults to 'anyone' here at creation time, but -- like the + // moderator list -- can be changed later by the creator or a current + // moderator, via each row's own "Manage permissions" control in + // collabLayerRows below (see mapLayers/collabLayer.js's + // updateCollabLayerPermissions). Each backed by a 3-way radio group in // tasking.html: 'anyone' | 'creator' | 'moderators', all following the // same "Anyone can ___" / "Only I can ___" / "Moderators can ___" shape // for a consistent mental model across the three permissions. The - // moderator *list* itself is the one thing that's editable later (by - // the creator) -- see newLayerModeratorPicker below and each row's own - // moderatorPicker in collabLayerRows. + // moderator *list* itself is edited separately -- see + // newLayerModeratorPicker below and each row's own moderatorPicker in + // collabLayerRows. self.newLayerMarkerMode = ko.observable('anyone'); // who can add/edit/delete markers self.newLayerDeleteMode = ko.observable('anyone'); // who can delete the layer itself self.newLayerCommentMode = ko.observable('anyone'); // who can comment on markers @@ -560,6 +563,37 @@ export function ConfigVM(root, deps) { self.refreshingCollabLayers = ko.observable(false); self.deletingCollabLayerId = ko.observable(null); // id of the row currently mid-delete, if any + // The row currently being edited in the standalone #collabPermissionsModal + // (tasking.html), or null when it's closed. A single shared observable + // (rather than a per-row "editing" flag rendered inline) so editing + // permissions doesn't nest one scroll area inside another -- the row + // list this modal is opened from is itself a small scrolling box + // (.collab-layer-list-scroll), and an expanding-in-place panel there + // forced a scrollbar-within-a-scrollbar. `with: config.permissionsModalRow` + // in the modal's markup means its contents simply don't exist in the DOM + // while this is null. + self.permissionsModalRow = ko.observable(null); + self.openPermissionsModal = (row) => { + row.permissionsError(''); + row.editMarkerMode(row.markerMode); + row.editDeleteMode(row.deleteMode); + row.editCommentMode(row.commentMode); + self.permissionsModalRow(row); + const modalEl = document.getElementById('collabPermissionsModal'); + if (!modalEl) return; + // Attached lazily on first open (rather than at Config() construction + // time) since that's the first point this element is guaranteed to + // exist -- guarded so a second open doesn't stack a duplicate + // listener. Clears permissionsModalRow on every close, however it + // was triggered (Save, Cancel, the X button, backdrop click, Esc), + // so a row's draft state doesn't leak into the next layer opened. + if (!modalEl.dataset.permissionsListenerAttached) { + modalEl.dataset.permissionsListenerAttached = 'true'; + modalEl.addEventListener('hidden.bs.modal', () => self.permissionsModalRow(null)); + } + bootstrap.Modal.getOrCreateInstance(modalEl).show(); + }; + // ── Find a layer (discover/subscribe) ── // Collapsed behind its own toggle by default, same reasoning as // showCreateLayerForm above. @@ -656,6 +690,7 @@ export function ConfigVM(root, deps) { markerCount: layer.markerCount || 0, lastUsedLabel: relativeTime(layer.lastUsedAt), markerMode, + deleteMode, commentMode, markerRestricted: markerMode !== 'anyone', commentRestricted: commentMode !== 'anyone', @@ -694,6 +729,21 @@ export function ConfigVM(root, deps) { savingModerators: ko.observable(false), moderatorsError: ko.observable(''), moderatorPicker: canManageModerators ? makeModeratorPicker(deps.searchMembers, moderators) : null, + // Same authorization as moderator management -- creator or + // any current moderator (see lambda + // updateLayerPermissions.js) -- so this reuses + // canManageModerators rather than a second computed flag. + canManagePermissions: canManageModerators, + savingPermissions: ko.observable(false), + permissionsError: ko.observable(''), + // Separate observables (rather than binding the radios + // straight to row.markerMode/deleteMode/commentMode above) + // so opening the modal doesn't retroactively change what the + // row displays until Save is actually clicked -- same + // "draft, then commit" shape as moderatorPicker. + editMarkerMode: ko.observable(markerMode), + editDeleteMode: ko.observable(deleteMode), + editCommentMode: ko.observable(commentMode), }; if (layer.createdBy && root.resolvePersonName) { root.resolvePersonName(layer.createdBy).then(name => row.authorName(name)); @@ -716,6 +766,8 @@ export function ConfigVM(root, deps) { row.editingModerators(false); }; row.saveModerators = () => self.saveRowModerators(row); + row.openPermissionsModal = () => self.openPermissionsModal(row); + row.savePermissions = () => self.saveRowPermissions(row); return row; })); @@ -768,6 +820,19 @@ export function ConfigVM(root, deps) { }; self.discoverHqPicker.selected.subscribe(() => self.runDiscoverSearch()); + // Debounced re-poll on every name filter keystroke too (same 250ms + // shape as makeEventPicker/makeHqPicker above) -- discoverResults is a + // point-in-time snapshot, so without this, a layer someone else creates + // or renames mid-search stays invisible/stale until the HQ picker is + // touched again. discoverRows below still does the actual name + // narrowing client-side (the lambda has no name param), this just keeps + // the underlying snapshot fresh while the user types. + let discoverSearchTimer = null; + self.discoverSearch.subscribe(() => { + if (discoverSearchTimer) clearTimeout(discoverSearchTimer); + discoverSearchTimer = setTimeout(self.runDiscoverSearch, 250); + }); + self.discoverRows = ko.pureComputed(() => { const subscribedIds = new Set(self.collabLayers().map(l => l.id)); const q = self.discoverSearch().trim().toLowerCase(); @@ -949,6 +1014,45 @@ export function ConfigVM(root, deps) { } }; + // Saves a row's in-progress marker/delete/comment mode radios (edited in + // #collabPermissionsModal) as the layer's new permissions, fired from + // row.savePermissions above. Only rows the creator or a current + // moderator can manage ever get the "Manage permissions" control exposed + // (see collabLayerRows' canManagePermissions), so there's no separate + // authorization check needed here -- the Lambda enforces it + // authoritatively either way. + self.saveRowPermissions = async (row) => { + if (!deps.apiUrl || !row.canManagePermissions || row.savingPermissions()) return; + + row.permissionsError(''); + row.savingPermissions(true); + try { + const permissions = { + markerMode: row.editMarkerMode(), + deleteMode: row.editDeleteMode(), + commentMode: row.editCommentMode(), + }; + const saved = await updateCollabLayerPermissions(root, deps.apiUrl, row.layer.id, permissions, deps.getToken); + if (saved == null) throw new Error('Update failed'); + // updateCollabLayerPermissions mutates row.layer's mode fields + // in place (it's the same object reference held in + // self.collabLayers()) -- force collabLayerRows to recompute so + // this row (and its canDelete/lock badges) reflect the new + // modes immediately, same as a poll-driven refresh would. + self.collabLayers.valueHasMutated(); + // Only close on success -- an error leaves the modal open (with + // permissionsError shown) so the user can see what went wrong + // and retry, rather than the failure vanishing along with the + // modal's content. + bootstrap.Modal.getOrCreateInstance(document.getElementById('collabPermissionsModal')).hide(); + } catch (err) { + console.error('Error updating layer permissions:', err); + row.permissionsError('Failed to save permissions. Try again later.'); + } finally { + row.savingPermissions(false); + } + }; + // Named methods (rather than inline functions in data-bind attributes) // because knockout-secure-binding's restricted grammar doesn't support // control-flow statements like `if` inside inline function literals. diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index 32b3b37a..90d246e2 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -630,6 +630,7 @@ export function MapVM(Lmap, root) { label: entry.label || k, layer: entry.layerGroup, group: entry.menuGroup || null, + visibleByDefault: entry.visibleByDefault, }); } } diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 10dc0bb4..29854f80 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -3172,6 +3172,10 @@
Unsubscribe above) so these administrative/destructive actions read as secondary and don't get confused with the primary Unsubscribe action. --> +
+ + + - +
- +
- +
- + +
Actions on this marker are logged to the Ops Log.
`; const previewEl = el.querySelector(".collab-style-preview"); diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 431253f1..88464e4c 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -565,6 +565,9 @@ export function ConfigVM(root, deps) { self.newLayerDeleteMode() === 'moderators' || self.newLayerCommentMode() === 'moderators'); self.creatingCollabLayer = ko.observable(false); + // Held true just long enough for the Create button to flash its + // success state before the form closes -- see createCollabLayer below. + self.collabLayerCreated = ko.observable(false); self.collabLayerError = ko.observable(''); self.collabLayerSearch = ko.observable(''); // filters "My layers" by name -- mainly useful once you've subscribed to a lot of them self.refreshingCollabLayers = ko.observable(false); @@ -952,11 +955,17 @@ export function ConfigVM(root, deps) { }; const layer = await createCollabLayer(root, deps.apiUrl, name, deps.actorId, deps.getToken, permissions, deps.getMemberId); if (!layer) throw new Error('Create failed'); + self.creatingCollabLayer(false); + // Hold the button in its success state briefly so the user + // actually sees it succeed, rather than the form vanishing the + // instant the request resolves. + self.collabLayerCreated(true); + await new Promise((resolve) => setTimeout(resolve, 900)); + self.collabLayerCreated(false); resetNewLayerForm(); } catch (err) { console.error('Error creating collaborative layer:', err); self.collabLayerError('Failed to create layer. Try again later.'); - } finally { self.creatingCollabLayer(false); } }; diff --git a/static/pages/tasking.html b/static/pages/tasking.html index d8ed2ab8..aabcb0a4 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -3035,13 +3035,23 @@
-
diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index 268b3503..cdcbdab2 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -2960,17 +2960,10 @@ overflow: hidden; font-size: 12px; } .collab-marker-audit-notice { - display: flex; - align-items: flex-start; - gap: 5px; - color: #888; - font-size: 10.5px; - line-height: 1.35; - margin-bottom: 8px; -} -.collab-marker-audit-notice .fa-info-circle { - margin-top: 1px; - flex-shrink: 0; + color: #9aa4ad; + font-size: 10px; + line-height: 1.3; + margin-top: 6px; } .collab-form-actions { display: flex; From 6c98aa0f7ac068d17d77a479f372e038325be299 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 10 Aug 2026 15:51:43 +1000 Subject: [PATCH 23/26] Fix SMS send request built from the SMS modal (#402) - SMSRecipient no longer discards the beaconContact passed to its constructor, so recipients added via the recipient search box keep their contact info instead of always ending up empty. - Search-added recipients now build a beaconContact entry with the real Id/Detail/ContactTypeId/Description fields instead of the lossy {id, name, detail} row used for display. - messages.js now derives the Recipients[i][Description] field from either FirstName/LastName (team-member contacts) or Description (search-added contacts), since the two contact shapes differ. - attachSendSMSModal's job branch now sets taskId, fixing the empty JobId on the "Task & SMS Details" button. - Team-member recipients start as loading so the Send button is disabled until each one's SMS number finishes fetching, instead of allowing a send before beaconContact is populated; a failed lookup now clears loading too so it can't lock the button permanently. - Recipient search results now show which headquarters each contact belongs to. Co-authored-by: Claude Sonnet 5 --- src/pages/tasking/main.js | 1 + src/pages/tasking/models/SMSRecipient.js | 2 +- src/pages/tasking/viewmodels/SMSTeamModalVM.js | 17 ++++++++++++++--- src/shared/BeaconClient/messages.js | 4 +++- static/pages/tasking.html | 1 + 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index a7d274b0..6a152be6 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -1576,6 +1576,7 @@ function VM() { // if a job was provided, use its info to prefill and assume its a new tasking if (job) { + taskId = job.id(); headerLabel = `Send SMS - Incident: ${job.identifier()}`; initialText = [ job.priorityName(), diff --git a/src/pages/tasking/models/SMSRecipient.js b/src/pages/tasking/models/SMSRecipient.js index 51e350a7..55fcf45b 100644 --- a/src/pages/tasking/models/SMSRecipient.js +++ b/src/pages/tasking/models/SMSRecipient.js @@ -7,5 +7,5 @@ this.isTeamLeader = data.isTeamLeader || false; this.selected = ko.observable(data.selected !== undefined ? data.selected : true); this.loading = ko.observable(data.loading || false); this.displayLabel = data.displayLabel && ko.observable(data.displayLabel); -this.beaconContact = []; +this.beaconContact = data.beaconContact || []; } \ No newline at end of file diff --git a/src/pages/tasking/viewmodels/SMSTeamModalVM.js b/src/pages/tasking/viewmodels/SMSTeamModalVM.js index 05e0a5e2..99e4bf50 100644 --- a/src/pages/tasking/viewmodels/SMSTeamModalVM.js +++ b/src/pages/tasking/viewmodels/SMSTeamModalVM.js @@ -63,7 +63,8 @@ export function SendSMSModalVM(parentVM) { name: name, isTeamLeader: isTL, selected: ko.observable(true), - displayLabel: name + displayLabel: name, + loading: true }) }); @@ -84,6 +85,9 @@ export function SendSMSModalVM(parentVM) { recipient.loading(false); } catch (err) { console.error("Failed to fetch contact numbers for recipient:", recipient.id, err); + recipient.displayLabel(`${recipient.name} (Failed to load SMS number)`); + recipient.selected(false); + recipient.loading(false); } }); @@ -239,7 +243,9 @@ export function SendSMSModalVM(parentVM) { return { id: r.Id, name: r.Description, - detail: detail + detail: detail, + location: r.Location || "", + raw: r }; }); self.recipientSearchResults(cleanedRows); @@ -273,7 +279,12 @@ export function SendSMSModalVM(parentVM) { isTeamLeader: false, selected: true, displayLabel: `${match.name} (${match.detail})`, - beaconContact: [match], + beaconContact: [{ + Id: match.raw.Id, + Detail: match.raw.Detail, + ContactTypeId: match.raw.ContactTypeId, + Description: match.raw.Description + }], loading: false })); diff --git a/src/shared/BeaconClient/messages.js b/src/shared/BeaconClient/messages.js index 1b86afff..8622fba5 100644 --- a/src/shared/BeaconClient/messages.js +++ b/src/shared/BeaconClient/messages.js @@ -10,7 +10,9 @@ const data = { recipients.forEach((recipient, index) => { data[`Recipients[${index}][Recipient]`] = recipient.Detail; - data[`Recipients[${index}][Description]`] = `${recipient.FirstName} ${recipient.LastName}`; + data[`Recipients[${index}][Description]`] = recipient.FirstName + ? `${recipient.FirstName} ${recipient.LastName}` + : recipient.Description; data[`Recipients[${index}][ContactId]`] = recipient.Id; data[`Recipients[${index}][ContactTypeId]`] = recipient.ContactTypeId; }); diff --git a/static/pages/tasking.html b/static/pages/tasking.html index aabcb0a4..d3fb6b89 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -4012,6 +4012,7 @@