diff --git a/.gitignore b/.gitignore index ab88ddd9..1e9bc406 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ dist package *.swp .env - Lighthouse.zip .DS_Store *.DS_Store diff --git a/lambda/default-assets-v2/index.mjs b/lambda/default-assets-v2/index.mjs new file mode 100644 index 00000000..dc2a67d8 --- /dev/null +++ b/lambda/default-assets-v2/index.mjs @@ -0,0 +1,177 @@ +/** + * AWS Lambda — Default-Asset Mapping Store (v2, auth-gated) + * + * Stores each team's chosen default-asset as a small JSON file in S3, + * namespaced by the Beacon API URL so different environments + * (train / dev / prod) never collide. + * + * S3 structure: + * s3://{BUCKET}/{CONFIG_PREFIX}/{urlHash}/{teamId}.json + * + * Old objects are automatically expired by S3 Lifecycle policy. + * + * ── Routes ── + * + * GET /default-assets?apiUrl=…&teamIds=1,2,3 + * → { "mapping": { "1": "assetA", "3": "assetC" } } + * Teams with no stored default are simply omitted from mapping. + * + * PUT /default-assets + * body: { "apiUrl": "…", "teamId": "…", "assetId": "…" } + * → { "saved": true, "teamId": "…", "assetId": "…" } + * + * Both routes require `Authorization: Bearer `. + */ + +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import crypto from 'crypto'; +import { verifyBeaconToken } from './verifyBeaconToken.mjs'; + +// ── Config ────────────────────────────────────────────────────────── +const BUCKET = process.env.BUCKET_NAME || 'lighthouse-default-assets'; +const CONFIG_PREFIX = process.env.CONFIG_PREFIX || ''; + +const s3 = new S3Client({}); + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Deterministic short hash of the API URL (namespace key). */ +function hashApiUrl(apiUrl) { + return crypto.createHash('sha256').update(apiUrl.trim().toLowerCase()).digest('hex').slice(0, 16); +} + +/** Build the S3 object key for one team. */ +function s3Key(urlHash, teamId) { + const base = CONFIG_PREFIX ? `${CONFIG_PREFIX}/${urlHash}` : urlHash; + return `${base}/${teamId}.json`; +} + +/** Read a single object; returns parsed JSON or null if missing. */ +async function getObject(key) { + try { + const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await streamToString(res.Body); + return JSON.parse(body); + } catch (err) { + const code = err.name || err.Code || ''; + const status = err.$metadata?.httpStatusCode; + if (code === 'NoSuchKey' || code === 'NotFound' || status === 404 || status === 403) { + return null; + } + // Unexpected error — log but don't throw; treat as missing + console.warn('getObject unexpected error for key:', key, err); + return null; + } +} + +/** Convert a readable stream to a string. */ +function streamToString(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', (c) => chunks.push(c)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + }); +} + +/** Standard JSON response helper. */ +function respond(statusCode, body) { + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + body: JSON.stringify(body), + }; +} + +// ── Handler ───────────────────────────────────────────────────────── + +export const handler = async (event) => { + const method = event.httpMethod || event.requestContext?.http?.method || 'GET'; + + // CORS preflight + if (method === "OPTIONS") { + return respond(204, ''); + } + + let claims; + try { + claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); + } catch (err) { + return respond(401, { error: 'Unauthorized', message: err?.message || String(err) }); + } + console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'default-assets-v2', userId: claims.sub || claims.client_id || 'unknown', method })); + + try { + // ---------- GET: Bulk fetch for a list of team IDs ---------- + if (method === 'GET') { + const qs = event.queryStringParameters || {}; + const apiUrl = qs.apiUrl; + const teamIds = qs.teamIds; + + if (!apiUrl || !teamIds) { + return respond(400, { error: 'Missing required query params: apiUrl, teamIds' }); + } + + const urlHash = hashApiUrl(apiUrl); + const ids = teamIds.split(',').map(s => s.trim()).filter(Boolean); + + if (ids.length === 0) { + return respond(200, { mapping: {} }); + } + + // Fan-out reads (S3 handles concurrency well at this scale) + const entries = await Promise.all( + ids.map(async (id) => { + const data = await getObject(s3Key(urlHash, id)); + return data ? [id, data.assetId] : null; + }) + ); + + const mapping = {}; + for (const entry of entries) { + if (entry) mapping[entry[0]] = entry[1]; + } + + return respond(200, { mapping }); + } + + // ---------- PUT: Save a single mapping ---------- + if (method === 'PUT') { + const body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body; + const { apiUrl, teamId, assetId } = body || {}; + + if (!apiUrl || !teamId || !assetId) { + return respond(400, { error: 'Missing required fields: apiUrl, teamId, assetId' }); + } + + const urlHash = hashApiUrl(apiUrl); + const key = s3Key(urlHash, teamId); + + const payload = { + teamId: String(teamId), + assetId: String(assetId), + updatedAt: new Date().toISOString(), + }; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(payload), + ContentType: 'application/json', + })); + + return respond(200, { saved: true, teamId, assetId }); + } + + return respond(405, { error: `Method ${method} not allowed` }); + + } catch (err) { + console.error('Lambda error:', err); + return respond(500, { error: 'Internal server error' }); + } +}; diff --git a/lambda/geocode-v2/index.mjs b/lambda/geocode-v2/index.mjs new file mode 100644 index 00000000..1ddc1e73 --- /dev/null +++ b/lambda/geocode-v2/index.mjs @@ -0,0 +1,103 @@ +import pkg from "@aws-sdk/client-geo-places"; +import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; + +const { GeoPlacesClient, GeocodeCommand, ReverseGeocodeCommand } = pkg; + +const client = new GeoPlacesClient({ + region: process.env.AWS_REGION, // required for SigV4 endpoints; Lambda sets AWS_REGION +}); + +const corsHeaders = { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,OPTIONS", + "access-control-allow-headers": "content-type,authorization", +}; + +const json = (statusCode, obj) => ({ + statusCode, + headers: corsHeaders, + body: JSON.stringify(obj), +}); + +export const handler = async (event) => { + // CORS preflight + if (event?.httpMethod === "OPTIONS" || event.requestContext?.http?.method === "OPTIONS") { + return { statusCode: 204, headers: corsHeaders, body: "" }; + } + + let claims; + try { + claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); + } catch (err) { + return json(401, { error: "Unauthorized", message: err?.message || String(err) }); + } + console.log(JSON.stringify({ msg: "beacon_auth", fn: "geocode-v2", userId: claims.sub || claims.client_id || "unknown" })); + + const qsp = event?.queryStringParameters || {}; + + // Forward geocode: ?q=... + const q = typeof qsp.q === "string" ? qsp.q.trim() : ""; + + // Reverse geocode: ?lat=&lon= + const lat = Number(qsp.lat); + const lon = Number(qsp.lon); + + const maxResults = (() => { + const mr = Number(qsp.maxResults); + return Number.isFinite(mr) && mr > 0 ? Math.min(mr, 100) : 10; + })(); + + try { + if (q) { + // Places v2 Geocode request fields: QueryText, BiasPosition, Filter.IncludeCountries, MaxResults, etc. :contentReference[oaicite:1]{index=1} + const cmd = new GeocodeCommand({ + QueryText: q, + MaxResults: maxResults, + BiasPosition: [151.2093, -33.8688], // [lng, lat] + Filter: { + IncludeCountries: ["AUS"], + }, + IntendedUse: "SingleUse", + }); + + const resp = await client.send(cmd); + + return json(200, { + input: { q }, + results: resp.ResultItems ?? [], + }); + } + + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return json(400, { error: "Provide either q=... or lat/lon numbers" }); + } + + // Places v2 ReverseGeocode request fields: QueryPosition, MaxResults, etc. :contentReference[oaicite:2]{index=2} + const cmd = new ReverseGeocodeCommand({ + QueryPosition: [lon, lat], // [lng, lat] + MaxResults: maxResults, + Filter : { + IncludePlaceTypes: [ + "PointAddress", + "Street", + "InterpolatedAddress" + ] + }, + IntendedUse: "SingleUse", + }); + + const resp = await client.send(cmd); + + return json(200, { + input: { lat, lon }, + results: resp.ResultItems ?? [], + }); + } catch (err) { + const message = + err && typeof err === "object" && "message" in err + ? String(err.message) + : "Unknown error"; + return json(500, { error: "Places v2 request failed", message }); + } +}; diff --git a/lambda/map-layers-v2/handlers/addMarkerComment.js b/lambda/map-layers-v2/handlers/addMarkerComment.js new file mode 100644 index 00000000..003d7f39 --- /dev/null +++ b/lambda/map-layers-v2/handlers/addMarkerComment.js @@ -0,0 +1,60 @@ +'use strict'; + +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 } +// +// Appends the id of a client-side-created Operations Log entry to the +// 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, claims) { + const layerId = event.pathParameters?.id; + const markerId = event.pathParameters?.markerId; + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + 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'); + } + if (!Number.isFinite(opsLogId)) return badRequest('opsLogId must be a number'); + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const marker = layer.markers.find((m) => m.id === markerId); + if (!marker) return notFound('Marker not found'); + + 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 : []; + marker.commentOpsLogIds.push(opsLogId); + + const now = new Date().toISOString(); + marker.updatedBy = actorId || marker.updatedBy; + marker.updatedAt = now; + + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) entry.lastUsedAt = now; + }); + + return json(200, marker); +}; diff --git a/lambda/map-layers-v2/handlers/createLayer.js b/lambda/map-layers-v2/handlers/createLayer.js new file mode 100644 index 00000000..87a48568 --- /dev/null +++ b/lambda/map-layers-v2/handlers/createLayer.js @@ -0,0 +1,104 @@ +'use strict'; + +const crypto = require('crypto'); +const { updateIndex, putLayerObject } = require('../lib/s3Store'); +const { json, badRequest } = require('../lib/response'); +const { normalizeMode } = require('../lib/permissions'); +const { sanitizeEvent, sanitizeHq } = require('../lib/attachment'); + +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; +} + +// POST /map-layers +// body: { apiUrl, name, createdBy, hq, markerMode?, deleteMode?, commentMode?, moderators?, event? } +// +// 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): +// - 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. Like the +// permission modes above, can be changed later by the creator or a current +// moderator (see updateLayerAttachment.js). 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". +// +// `event` (optional): { id, name, identifier } of a Beacon event this layer +// relates to, stored as eventId/eventName/eventIdentifier -- purely for +// display in the layer list (Config.js), also changeable later via +// updateLayerAttachment.js. +// +// "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. 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; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + const apiUrl = body.apiUrl; + const name = String(body.name || '').trim().slice(0, 200); + const createdBy = String(body.createdBy || '').slice(0, 100); + const createdByMemberId = String(claims?.sub || ''); + 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, + markerMode, deleteMode, commentMode, moderators, eventId, eventName, eventIdentifier, hqId, hqName, + }; + const layer = { + id, apiUrl, name, createdBy, createdByMemberId, createdAt: now, lastUsedAt: now, markers: [], + markerMode, deleteMode, commentMode, moderators, eventId, eventName, eventIdentifier, hqId, hqName, + }; + + await putLayerObject(apiUrl, id, layer); + await updateIndex(apiUrl, (index) => { + index.apiUrl = apiUrl; + index.layers.push(summary); + }); + + return json(201, summary); +}; diff --git a/lambda/map-layers-v2/handlers/deleteFeature.js b/lambda/map-layers-v2/handlers/deleteFeature.js new file mode 100644 index 00000000..c14397fa --- /dev/null +++ b/lambda/map-layers-v2/handlers/deleteFeature.js @@ -0,0 +1,54 @@ +'use strict'; + +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 +// a concurrent stale edit can't resurrect a corrupted record and deletes +// are recoverable by a human editing S3 directly if needed. +module.exports = async function deleteFeature(event, claims) { + const layerId = event.pathParameters?.id; + const markerId = event.pathParameters?.markerId; + const apiUrl = event.queryStringParameters?.apiUrl; + // actorId is client-supplied bookkeeping (stamped as updatedBy) -- never + // used for authorization, see memberId below. + const actorId = String(event.queryStringParameters?.actorId || '').slice(0, 100); + const memberId = String(claims?.sub || ''); + + if (!apiUrl || !layerId || !markerId) { + return badRequest('apiUrl, layer id and marker id are required'); + } + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const marker = layer.markers.find((m) => m.id === markerId); + if (!marker) return notFound('Marker not found'); + + // 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(); + marker.deleted = true; + marker.updatedBy = actorId || marker.updatedBy; + marker.updatedAt = now; + + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + const markerCount = layer.markers.filter((m) => !m.deleted).length; + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.lastUsedAt = now; + entry.markerCount = markerCount; + } + }); + + return json(204, null); +}; diff --git a/lambda/map-layers-v2/handlers/deleteLayer.js b/lambda/map-layers-v2/handlers/deleteLayer.js new file mode 100644 index 00000000..f2deb7c3 --- /dev/null +++ b/lambda/map-layers-v2/handlers/deleteLayer.js @@ -0,0 +1,43 @@ +'use strict'; + +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=... +// +// Soft-deletes the layer: marks it `deleted: true` on the S3 object (so +// getLayerObject treats it as not-found everywhere -- get/upsert/delete +// feature, add comment) and drops it from the org's index so it stops +// appearing in listLayers(). The object itself is never removed from S3, +// matching the same "never truly delete" recoverability the marker +// soft-delete and the listLayers staleness filter already rely on. +module.exports = async function deleteLayer(event, claims) { + const layerId = event.pathParameters?.id; + const apiUrl = event.queryStringParameters?.apiUrl; + // actorId is client-supplied bookkeeping (stamped as deletedBy) -- never + // used for authorization, see memberId below. + const actorId = String(event.queryStringParameters?.actorId || '').slice(0, 100); + 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(deleteMode(layer), layer, memberId)) { + return forbidden('You do not have permission to delete this layer'); + } + + const now = new Date().toISOString(); + layer.deleted = true; + layer.deletedBy = actorId; + layer.deletedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + await updateIndex(apiUrl, (index) => { + index.layers = (index.layers || []).filter((l) => l.id !== layerId); + }); + + return json(204, null); +}; diff --git a/lambda/map-layers-v2/handlers/getLayer.js b/lambda/map-layers-v2/handlers/getLayer.js new file mode 100644 index 00000000..3ec7837c --- /dev/null +++ b/lambda/map-layers-v2/handlers/getLayer.js @@ -0,0 +1,27 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound } = require('../lib/response'); + +// GET /map-layers/{id}?apiUrl=... +// Returns the full layer (including markers) and bumps lastUsedAt -- +// viewing a layer counts as "use" so actively-watched-but-not-edited +// layers don't age out of listLayers() mid-incident. +module.exports = async function getLayer(event) { + const apiUrl = event.queryStringParameters?.apiUrl; + const layerId = event.pathParameters?.id; + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const now = new Date().toISOString(); + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) entry.lastUsedAt = now; + }); + + return json(200, layer); +}; diff --git a/lambda/map-layers-v2/handlers/listLayers.js b/lambda/map-layers-v2/handlers/listLayers.js new file mode 100644 index 00000000..96c502be --- /dev/null +++ b/lambda/map-layers-v2/handlers/listLayers.js @@ -0,0 +1,30 @@ +'use strict'; + +const { getJson, indexKey } = require('../lib/s3Store'); +const { json, badRequest } = require('../lib/response'); + +const STALE_MS = 120 * 24 * 60 * 60 * 1000; // 120 days + +// 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. `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(); + 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/updateLayerAttachment.js b/lambda/map-layers-v2/handlers/updateLayerAttachment.js new file mode 100644 index 00000000..0d629445 --- /dev/null +++ b/lambda/map-layers-v2/handlers/updateLayerAttachment.js @@ -0,0 +1,69 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound, forbidden } = require('../lib/response'); +const { isAuthorized } = require('../lib/permissions'); +const { sanitizeEvent, sanitizeHq } = require('../lib/attachment'); + +// PUT /map-layers/{id}/attachment body: { apiUrl, hq: {id, name}, event: {id, name, identifier}|null } +// +// The HQ and event a layer's attached to turn out not to be fixed for its +// whole lifetime after all (same story as the moderator list and the three +// permission modes, see updateLayerModerators.js/updateLayerPermissions.js) +// -- a layer created against the wrong HQ, or one that should follow an +// incident from one Beacon event to the next, needs a way to be +// reassigned. Same authorization rule as those two: creator or any +// *current* moderator (isAuthorized('moderators', ...)). +// +// `hq` is required, exactly as at creation (createLayer.js) -- a layer can +// never end up with no HQ. `event`, if omitted or null, clears any existing +// event attachment; a given event replaces it outright (no partial-update +// shape, same as createLayer.js). +module.exports = async function updateLayerAttachment(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 change the HQ or event'); + } + + const { hqId, hqName } = sanitizeHq(body.hq); + if (!hqId) return badRequest('hq is required'); + const { eventId, eventName, eventIdentifier } = sanitizeEvent(body.event); + + const now = new Date().toISOString(); + layer.hqId = hqId; + layer.hqName = hqName; + layer.eventId = eventId; + layer.eventName = eventName; + layer.eventIdentifier = eventIdentifier; + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.hqId = hqId; + entry.hqName = hqName; + entry.eventId = eventId; + entry.eventName = eventName; + entry.eventIdentifier = eventIdentifier; + entry.lastUsedAt = now; + } + }); + + return json(200, { hqId, hqName, eventId, eventName, eventIdentifier }); +}; diff --git a/lambda/map-layers-v2/handlers/updateLayerModerators.js b/lambda/map-layers-v2/handlers/updateLayerModerators.js new file mode 100644 index 00000000..7f8760ed --- /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}] } +// +// 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 +// 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/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/handlers/upsertFeature.js b/lambda/map-layers-v2/handlers/upsertFeature.js new file mode 100644 index 00000000..d1612bcd --- /dev/null +++ b/lambda/map-layers-v2/handlers/upsertFeature.js @@ -0,0 +1,122 @@ +'use strict'; + +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). +const ICON_KEYS = new Set([ + 'exclamation-triangle', 'fire-alt', 'cloud-showers-heavy', 'wind', 'snowflake', 'water', 'gas-pump', + 'ambulance', 'car-side', 'truck-monster', 'shuttle-van', 'helicopter', 'ship', 'plane', + 'users', 'dog', + 'utensils', 'shopping-cart', + 'eye', 'camera', 'comments', + 'flag', 'thumbtack', 'times', 'minus-circle', 'question-circle', +]); +const DEFAULT_ICON = 'thumbtack'; +const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/; + +// PUT /map-layers/{id}/features body: { apiUrl, marker: {id?, lat, lng, icon, fill, opsLogId}, actorId } +// Missing/unknown marker.id creates a new marker; a known id overwrites it +// (last-write-wins, same convention as the existing default-assets Lambda). +// +// The marker record itself only holds GPS position, style (icon/fill), and +// pointers into the Operations Log -- opsLogId for the title/description +// entry and commentOpsLogIds for the comment thread. The Ops Log is the +// source of truth for all of that text; the client resolves the pointers +// via BeaconClient.operationslog.get() when rendering a marker. +module.exports = async function upsertFeature(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; + // actorId is client-supplied bookkeeping (stamped onto the marker as + // createdBy/updatedBy for display) -- never used for authorization, see + // memberId below. + const actorId = String(body.actorId || '').slice(0, 100); + const memberId = String(claims?.sub || ''); + const input = body.marker || {}; + + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + if (typeof input.lat !== 'number' || typeof input.lng !== 'number') { + return badRequest('marker.lat and marker.lng must be numbers'); + } + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + // Authorized against the verified token's memberId, not the + // 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(); + const icon = ICON_KEYS.has(input.icon) ? input.icon : DEFAULT_ICON; + const fill = HEX_COLOR.test(input.fill || '') ? input.fill : '#2b7bbb'; + + // Id of the Operations Log entry logged (client-side) for this drop/edit, + // stamped onto the marker so the title/description can be looked up + // later via BeaconClient.operationslog.get(). A Beacon Ops Log entry + // can't be edited by anyone but its author, so editing a marker always + // creates a *new* entry client-side and points opsLogId at it rather + // than mutating the old one. Omitted/invalid values leave the marker's + // existing opsLogId (if any) untouched. + const opsLogId = Number.isFinite(Number(input.opsLogId)) && input.opsLogId !== '' ? Number(input.opsLogId) : undefined; + + const existingIdx = input.id ? layer.markers.findIndex((m) => m.id === input.id) : -1; + let marker; + + if (existingIdx >= 0) { + marker = { + ...layer.markers[existingIdx], + lat: input.lat, + lng: input.lng, + icon, + fill, + updatedBy: actorId, + updatedAt: now, + deleted: false, + }; + if (opsLogId !== undefined) marker.opsLogId = opsLogId; + layer.markers[existingIdx] = marker; + } else { + marker = { + id: crypto.randomUUID(), + lat: input.lat, + lng: input.lng, + icon, + fill, + commentOpsLogIds: [], + createdBy: actorId, + createdAt: now, + updatedBy: actorId, + updatedAt: now, + deleted: false, + }; + if (opsLogId !== undefined) marker.opsLogId = opsLogId; + layer.markers.push(marker); + } + + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + const markerCount = layer.markers.filter((m) => !m.deleted).length; + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.lastUsedAt = now; + entry.markerCount = markerCount; + } + }); + + return json(200, marker); +}; diff --git a/lambda/map-layers-v2/index.js b/lambda/map-layers-v2/index.js new file mode 100644 index 00000000..b8f69818 --- /dev/null +++ b/lambda/map-layers-v2/index.js @@ -0,0 +1,68 @@ +'use strict'; + +const { json, serverError } = require('./lib/response'); +const { verifyBeaconToken } = require('./verifyBeaconToken'); +const listLayers = require('./handlers/listLayers'); +const createLayer = require('./handlers/createLayer'); +const getLayer = require('./handlers/getLayer'); +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'); +const updateLayerPermissions = require('./handlers/updateLayerPermissions'); +const updateLayerAttachment = require('./handlers/updateLayerAttachment'); + +// Single Lambda fronting all /lad_v2/map-layers routes via API Gateway HTTP +// API (payload format 2.0) Lambda proxy integration. Routed by +// event.routeKey, which API Gateway sets to " " for +// whichever route matched (e.g. "GET /lad_v2/map-layers/{id}"). Every route +// except OPTIONS requires a valid `Authorization: Bearer ` +// header, verified against SES's identity server (see +// ./verifyBeaconToken.js). +const ROUTES = { + 'GET /lad_v2/map-layers': listLayers, + '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}/permissions': updateLayerPermissions, + 'PUT /lad_v2/map-layers/{id}/attachment': updateLayerAttachment, + '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, +}; + +exports.handler = async (event) => { + const method = event.requestContext?.http?.method; + + if (method === 'OPTIONS') return json(204, null); + + const routeKey = event.requestContext?.routeKey; + const handler = ROUTES[routeKey]; + + if (!handler) { + return json(404, { error: 'Not found', routeKey }); + } + + let claims; + try { + claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); + } catch (err) { + return json(401, { error: 'Unauthorized', message: err?.message || String(err) }); + } + console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'map-layers-v2', userId: claims.sub || claims.client_id || 'unknown', route: routeKey })); + + try { + // `claims` (the verified token payload) is passed through so + // permission-sensitive handlers (createLayer, upsertFeature, + // deleteFeature, deleteLayer) can authorize against claims.sub -- the + // Beacon member id, tamper-proof since it comes from a signature- + // verified JWT -- rather than any client-supplied actorId field, which + // a caller could set to whatever it wants. + return await handler(event, claims); + } catch (err) { + console.error('map-layers-v2 handler error:', err, JSON.stringify({ routeKey })); + return serverError(); + } +}; diff --git a/lambda/map-layers-v2/lib/attachment.js b/lambda/map-layers-v2/lib/attachment.js new file mode 100644 index 00000000..c0c3912a --- /dev/null +++ b/lambda/map-layers-v2/lib/attachment.js @@ -0,0 +1,34 @@ +'use strict'; + +// Shared by createLayer.js (fixed at creation) and updateLayerAttachment.js +// (changed later, see that handler's doc comment for why a layer's HQ/event +// turned out not to be fixed for its whole lifetime after all). + +/** + * Sanitize the optional event attachment: { id, name, identifier } -> + * { eventId, eventName, eventIdentifier }, all null if no event (or an + * incomplete one) was given. eventId/eventName/eventIdentifier 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 -- + * 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 }; +} + +module.exports = { sanitizeEvent, sanitizeHq }; 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/lambda/map-layers-v2/lib/response.js b/lambda/map-layers-v2/lib/response.js new file mode 100644 index 00000000..28f93db4 --- /dev/null +++ b/lambda/map-layers-v2/lib/response.js @@ -0,0 +1,26 @@ +'use strict'; + +// Locked down to the extension's own origin at deploy time is possible by +// replacing '*' below, but the request itself is already scoped by apiUrl +// (the org's Beacon source URL) so '*' matches the permissiveness of the +// existing share / default-assets Lambdas this feature mirrors. +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', +}; + +function json(statusCode, body) { + return { + statusCode, + headers: { 'Content-Type': 'application/json', ...CORS_HEADERS }, + body: body === null || body === undefined ? '' : JSON.stringify(body), + }; +} + +const badRequest = (message) => json(400, { error: message }); +const forbidden = (message) => json(403, { error: message }); +const notFound = (message) => json(404, { error: message }); +const serverError = (message) => json(500, { error: message || 'Internal server error' }); + +module.exports = { json, badRequest, forbidden, notFound, serverError, CORS_HEADERS }; diff --git a/lambda/map-layers-v2/lib/s3Store.js b/lambda/map-layers-v2/lib/s3Store.js new file mode 100644 index 00000000..cc921f0a --- /dev/null +++ b/lambda/map-layers-v2/lib/s3Store.js @@ -0,0 +1,96 @@ +'use strict'; + +const crypto = require('crypto'); +const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const S3_PREFIX = 'shared_layers'; + +/** Namespace every org's layers under a hash of its Beacon apiUrl. */ +function orgPrefix(apiUrl) { + const hash = crypto.createHash('sha256').update(String(apiUrl)).digest('hex').slice(0, 24); + return `${S3_PREFIX}/${hash}`; +} + +function indexKey(apiUrl) { + return `${orgPrefix(apiUrl)}/index.json`; +} + +function layerKey(apiUrl, layerId) { + return `${orgPrefix(apiUrl)}/${layerId}.json`; +} + +async function streamToString(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +/** Read a JSON object from S3. Returns { data: null, etag: null } if it doesn't exist yet. */ +async function getJson(key) { + try { + const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await streamToString(res.Body); + return { data: JSON.parse(body), etag: res.ETag }; + } catch (err) { + if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) { + return { data: null, etag: null }; + } + throw err; + } +} + +async function putJson(key, data, { ifMatch, ifNoneMatch } = {}) { + const params = { + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(data), + ContentType: 'application/json', + }; + if (ifMatch) params.IfMatch = ifMatch; + if (ifNoneMatch) params.IfNoneMatch = ifNoneMatch; + await s3.send(new PutObjectCommand(params)); +} + +/** + * Read-modify-write the small per-org index.json with optimistic + * concurrency (S3 conditional writes) + a short retry loop, since it's the + * one object concurrent requests (e.g. two users creating a layer at the + * same moment) could race on. Individual layer objects are only ever + * written by requests for that one layer, so they don't need this. + */ +async function updateIndex(apiUrl, mutate, { retries = 3 } = {}) { + const key = indexKey(apiUrl); + for (let attempt = 0; attempt <= retries; attempt++) { + const { data, etag } = await getJson(key); + const index = data || { apiUrl, layers: [] }; + const result = mutate(index); + try { + await putJson(key, index, etag ? { ifMatch: etag } : { ifNoneMatch: '*' }); + return result; + } catch (err) { + const status = err.$metadata?.httpStatusCode; + // S3's conditional-write feature (IfMatch/IfNoneMatch on PutObject) + // reports a lost race as 409 ConditionalRequestConflict, not the 412 + // Precondition Failed other conditional S3 operations use. + if ((status === 409 || status === 412) && attempt < retries) continue; // lost the race, retry + throw err; + } + } + throw new Error(`updateIndex: exhausted retries for ${key}`); +} + +async function getLayerObject(apiUrl, layerId) { + const { data } = await getJson(layerKey(apiUrl, layerId)); + // Not found, belongs to a different org, or soft-deleted (see deleteLayer + // handler) -- all treated identically as "not found" by every caller. + if (!data || data.apiUrl !== apiUrl || data.deleted) return null; + return data; +} + +async function putLayerObject(apiUrl, layerId, layer) { + await putJson(layerKey(apiUrl, layerId), layer); +} + +module.exports = { orgPrefix, indexKey, layerKey, getJson, putJson, updateIndex, getLayerObject, putLayerObject }; diff --git a/lambda/map-layers/.gitignore b/lambda/map-layers/.gitignore new file mode 100644 index 00000000..3a77e8ce --- /dev/null +++ b/lambda/map-layers/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.zip diff --git a/lambda/map-layers/README.md b/lambda/map-layers/README.md new file mode 100644 index 00000000..d6e8a604 --- /dev/null +++ b/lambda/map-layers/README.md @@ -0,0 +1,306 @@ +# Map Layers Lambda + +Backend for the tasking map's collaborative map layers feature: lists/creates +layers and reads/writes their markers, all stored as JSON objects in S3. No +database — this mirrors the existing `share` and `default-assets` Lambdas the +extension already calls at `https://lambda.lighthouse-extension.com/lad/...`. + +Frontend code that calls this: `src/pages/tasking/utils/collabLayerSync.js` +(`LAMBDA_BASE = 'https://lambda.lighthouse-extension.com/lad/map-layers'`). + +## Routes + +| Method | Path | Purpose | +|--------|------------------------------------|---------------------------------------------| +| GET | `/lad/map-layers?apiUrl=` | List an org's layers (excludes 120+ day idle ones) | +| POST | `/lad/map-layers` | Create a new named layer | +| GET | `/lad/map-layers/{id}?apiUrl=` | Get a layer + its markers, bumps `lastUsedAt` | +| PUT | `/lad/map-layers/{id}/features` | Create or edit a marker (last-write-wins) | +| DELETE | `/lad/map-layers/{id}/features/{markerId}?apiUrl=&actorId=` | Soft-delete a marker | + +The `/lad` prefix is part of the route paths themselves (not added by a +custom domain base path mapping) — see the API Gateway section below. + +All five are served by **one** Lambda function (`index.js` routes internally +on `event.requestContext.routeKey`). Data is namespaced per org under a hash +of `apiUrl` (the org's Beacon source URL) — see `lib/s3Store.js`. + +Nothing here ever hard-deletes a layer or deletes S3 objects; the 120-day +rule only excludes stale layers from the list response. + +**No AWS CLI needed** — everything below is done through the AWS Management +Console in your browser. The one convenient shortcut: Lambda's Node.js 20.x +and 22.x managed runtimes ship with the AWS SDK for JavaScript v3 +pre-installed, so `@aws-sdk/client-s3` doesn't need to be bundled — you can +paste the source files straight into the Lambda console's built-in code +editor with no zip/upload step. + +## 1. Create the S3 bucket + +1. Open the **S3 console** → **Create bucket**. +2. **Bucket name**: something globally unique, e.g. `lighthouse-map-layers` + (write it down — you'll need it as an environment variable later). +3. **AWS Region**: pick the same region you'll use for the Lambda/API Gateway + (match whatever region `lambda.lighthouse-extension.com` already runs in). +4. **Block Public Access settings**: leave all four boxes checked (the + default) — this bucket is only ever read/written by the Lambda, never + public. +5. **Bucket Versioning**: optional but recommended — turning it on gives you + an undo button if a bug ever corrupts a layer's JSON. Not required for + the app to work. +6. Leave everything else default and click **Create bucket**. + +**Do not** add a lifecycle rule that expires/deletes objects under +`shared_layers/` — the 120-day rule is a *listing* filter enforced in +`handlers/listLayers.js`, not an S3-level delete. "Data not deleted" is a +hard product requirement. + +## 2. Create the Lambda function + +1. Open the **Lambda console** → **Create function**. +2. Choose **Author from scratch**. +3. **Function name**: `lighthouse-map-layers`. +4. **Runtime**: **Node.js 20.x** (or 22.x if offered). +5. **Architecture**: leave as `x86_64`. +6. Under **Permissions**, leave "Create a new role with basic Lambda + permissions" selected — this auto-creates an execution role with just + CloudWatch Logs access; you'll add S3 access to it in step 3. +7. Click **Create function**. + +### Add the code + +The console's **Code source** panel starts with a single `index.js`. You +need to recreate this project's file layout inside it: + +``` +index.js +lib/response.js +lib/s3Store.js +handlers/listLayers.js +handlers/createLayer.js +handlers/getLayer.js +handlers/upsertFeature.js +handlers/deleteFeature.js +``` + +For each file other than `index.js`: + +1. In the file tree, click the **File** menu (or right-click the file list) + → **New File**. +2. Type the full path including folders, e.g. `lib/response.js` — the + console creates the `lib` folder automatically. +3. Paste in the contents of that file from this repo (`lambda/map-layers/`). + +For `index.js`, delete the placeholder content and paste in this repo's +`index.js`. + +Once all 7 files are in place, click the orange **Deploy** button above the +code editor — nothing you paste takes effect until you deploy. + +### Environment variable + +1. Go to the **Configuration** tab → **Environment variables** → **Edit**. +2. **Add environment variable**: key `BUCKET_NAME`, value = the bucket name + from step 1 (e.g. `lighthouse-map-layers`). +3. **Save**. + +### Timeout and memory (optional but recommended) + +1. **Configuration** tab → **General configuration** → **Edit**. +2. Set **Timeout** to `10 sec` and **Memory** to `256 MB` (defaults of 3 sec + / 128 MB are tight once S3 round-trips are involved). +3. **Save**. + +## 3. Give the function's role S3 access + +1. Still in the Lambda function, go to **Configuration** → **Permissions**. +2. Click the **Role name** link (opens IAM in a new tab). +3. On the role's page, **Add permissions** → **Create inline policy**. +4. Switch to the **JSON** tab and paste (replace `YOUR_BUCKET_NAME`): + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "MapLayersS3Access", + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/shared_layers/*" + }, + { + "Sid": "MapLayersS3List", + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME", + "Condition": { + "StringLike": { "s3:prefix": "shared_layers/*" } + } + } + ] + } + ``` + + The `ListBucket` statement is easy to skip but is required: when + `GetObject` is called on a key that doesn't exist yet (e.g. the very + first request for a brand-new org, before its `index.json` has been + created), S3 returns `403 AccessDenied` instead of `404 NoSuchKey` + *unless* the caller also has `s3:ListBucket` on the bucket — otherwise it + won't tell you whether the object is missing or you're just not allowed + to see it. Without this statement you'll see errors like + `AccessDenied: ... not authorized to perform: s3:ListBucket` in + CloudWatch Logs the first time any org is used. `ListBucket` is a + bucket-level action so it needs the **bucket's** ARN (no trailing + `/shared_layers/*`), scoped back down to just that prefix via the + `s3:prefix` condition so the role still can't list the rest of the + bucket. + + (CloudWatch Logs permissions are already on the role from the + auto-created "basic Lambda permissions" policy — no need to add those.) +5. Click **Next**, give the policy a name like `map-layers-s3-access`, then + **Create policy**. + +## 4. Wire it into API Gateway + +You already have an HTTP API serving `lambda.lighthouse-extension.com/lad/...` +for the `share` and `default-assets` routes. Add these five routes to that +**same API** so `map-layers` shares the existing custom domain and +certificate, rather than standing up a second one. + +1. Open the **API Gateway console** → find your existing API (the one + behind `share`/`default-assets`) → open it. +2. In the left nav, go to **Integrations** → **Create**. +3. Choose **Lambda** as the integration type, pick the region and select + `lighthouse-map-layers` as the function, then **Create**. (API Gateway + automatically grants this integration permission to invoke the + function — no separate permissions step needed, unlike the CLI/SDK + path.) +4. Go to **Routes** → **Create** and add each of these five routes one at a + time (method + path exactly as shown, **including** the `/lad` prefix — + these routes live under `/lad/` directly, it isn't added later by a base + path mapping): + - `GET /lad/map-layers` + - `POST /lad/map-layers` + - `GET /lad/map-layers/{id}` + - `PUT /lad/map-layers/{id}/features` + - `DELETE /lad/map-layers/{id}/features/{markerId}` +5. For **each** route, click it, then **Attach integration** and pick the + Lambda integration you created in step 3 (all five routes share the one + integration — that's expected, `index.js` internally routes by + `event.requestContext.routeKey`). +6. If the API's default stage has **Auto-deploy** enabled (check under + **Stages** → `$default`), the new routes go live immediately. If not, + go to **Deploy** and deploy to `$default`. + +Since `/lad` is baked into the route paths, check the API's **Custom domain +names** → `lambda.lighthouse-extension.com` → **API mappings**: the mapping +for this API should have an **empty/root Path** (not `lad`), otherwise +requests would need to go to `/lad/lad/map-layers`. If the existing mapping +already has a `lad` path in front of routes defined *without* the prefix +(i.e. `share`'s route is just `/share`), that mapping is serving a different +routing convention than these new routes use — in that case, add a +**second** API mapping for this API with an empty Path instead of reusing +the existing one. + +### If you don't have an existing `/lad` API yet + +1. **API Gateway console** → **Create API** → **HTTP API** → **Build**. +2. **Add integration**: Lambda, select `lighthouse-map-layers` → **Next**. +3. Add the five routes (method + path from the table above, including the + `/lad` prefix) → **Next**. +4. Stage: keep `$default` with **Auto-deploy** enabled → **Next** → **Create**. +5. **Custom domain names** → **Create** → domain name + `lambda.lighthouse-extension.com`, select or request an ACM certificate + for that domain in the same region → **Create domain name**. +6. On the new domain name's **API mappings** tab → **Configure API mappings** + → **Add new mapping** → select your API, stage `$default`, leave **Path** + empty (the `/lad` prefix is already part of the routes) → **Save**. +7. Point your DNS (Route 53 or wherever the domain is hosted) at the domain + name's **API Gateway domain name** (regional endpoint), shown on the + custom domain's **Configurations** tab — usually an A/ALIAS record in + Route 53, or a CNAME elsewhere. + +### CORS + +Enable CORS at the API level rather than relying on the Lambda's own CORS +headers (which are still there as a harmless fallback): + +1. Open your API in **API Gateway** → **CORS** (left nav) → **Configure**. +2. **Access-Control-Allow-Origin**: `*` +3. **Access-Control-Allow-Headers**: `Content-Type` +4. **Access-Control-Allow-Methods**: `GET, POST, PUT, DELETE, OPTIONS` +5. **Save**. + +With this enabled, API Gateway answers `OPTIONS` preflight requests itself +— you don't need to create explicit `OPTIONS` routes. + +## 5. Test it + +**Quickest option — Lambda console's built-in Test feature**, which invokes +the function directly without going through API Gateway at all: + +1. On the function's page, go to the **Test** tab. +2. **Create new event**, paste an event JSON shaped like this (this example + tests `createLayer`): + + ```json + { + "requestContext": { + "http": { "method": "POST" }, + "routeKey": "POST /lad/map-layers" + }, + "body": "{\"apiUrl\":\"https://your-org.beacon.example.org\",\"name\":\"Test Layer\",\"createdBy\":\"tester\"}" + } + ``` + +3. **Test** → check the returned JSON has `statusCode: 201` and a `body` + containing the new layer's `id`. +4. To test `listLayers`, use: + + ```json + { + "requestContext": { + "http": { "method": "GET" }, + "routeKey": "GET /lad/map-layers" + }, + "queryStringParameters": { "apiUrl": "https://your-org.beacon.example.org" } + } + ``` + + and confirm the layer you created shows up in `body`. + +**Through the real URL**, once routes are deployed: + +- `GET` requests can be tested by pasting the URL straight into a browser + tab, e.g.: + `https://lambda.lighthouse-extension.com/lad/map-layers?apiUrl=https://your-org.beacon.example.org` +- For `POST`/`PUT`/`DELETE` (which need a JSON body), use a tool like + [Postman](https://www.postman.com/) or Insomnia rather than a browser — + point it at the same URL, method, and a JSON body matching the shapes in + the routes table above. + +## Notes + +- **Concurrency**: the per-org `index.json` (layer list/summaries) uses S3 + conditional writes (`IfMatch`/`IfNoneMatch`) with a 3-attempt retry to + survive two users creating/updating layers at the same moment. Individual + layer/marker writes are last-write-wins (no retry needed — only one + request at a time touches a given layer object in the update path). +- **Validation**: handlers clamp string lengths (name ≤200 chars, + description ≤2000 chars) and validate `icon` against a fixed allow-list + (`ICON_KEYS` in `handlers/upsertFeature.js`, kept in sync with + `src/pages/tasking/components/collab_marker_icons.js`) and `fill` against + a hex-color pattern, but there's no hard cap on markers-per-layer or + layers-per-org — add one in `handlers/createLayer.js` / + `handlers/upsertFeature.js` if you + need it. +- **SDK version**: relying on the Lambda runtime's pre-installed AWS SDK + (rather than bundling your own `node_modules`) means the SDK version can + shift when AWS updates the managed runtime. Fine for this workload; if you + ever want a pinned version, you'd need to package `node_modules` into a + zip and upload that instead of using the inline code editor. +- **Logs**: `console.error` calls land in the function's CloudWatch Logs — + from the function's page, **Monitor** tab → **View CloudWatch logs**. +- **Cost**: this is a low-traffic, tiny-payload workload (S3 GET/PUT + a + small Lambda) — expect it to sit well within the AWS free tier for any + realistic number of concurrent incidents/users. diff --git a/lambda/map-layers/handlers/createLayer.js b/lambda/map-layers/handlers/createLayer.js new file mode 100644 index 00000000..bf642417 --- /dev/null +++ b/lambda/map-layers/handlers/createLayer.js @@ -0,0 +1,34 @@ +'use strict'; + +const crypto = require('crypto'); +const { updateIndex, putLayerObject } = require('../lib/s3Store'); +const { json, badRequest } = require('../lib/response'); + +// POST /map-layers body: { apiUrl, name, createdBy } +module.exports = async function createLayer(event) { + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + const apiUrl = body.apiUrl; + const name = String(body.name || '').trim().slice(0, 200); + const createdBy = String(body.createdBy || '').slice(0, 100); + + if (!apiUrl || !name) return badRequest('apiUrl and name are required'); + + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const summary = { id, name, createdBy, createdAt: now, lastUsedAt: now, markerCount: 0 }; + const layer = { id, apiUrl, name, createdBy, createdAt: now, lastUsedAt: now, markers: [] }; + + await putLayerObject(apiUrl, id, layer); + await updateIndex(apiUrl, (index) => { + index.apiUrl = apiUrl; + index.layers.push(summary); + }); + + return json(201, summary); +}; diff --git a/lambda/map-layers/handlers/deleteFeature.js b/lambda/map-layers/handlers/deleteFeature.js new file mode 100644 index 00000000..cb2d342d --- /dev/null +++ b/lambda/map-layers/handlers/deleteFeature.js @@ -0,0 +1,44 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound } = require('../lib/response'); + +// DELETE /map-layers/{id}/features/{markerId}?apiUrl=...&actorId=... +// Soft-deletes the marker (sets deleted: true) rather than removing it, so +// a concurrent stale edit can't resurrect a corrupted record and deletes +// are recoverable by a human editing S3 directly if needed. +module.exports = async function deleteFeature(event) { + const layerId = event.pathParameters?.id; + const markerId = event.pathParameters?.markerId; + const apiUrl = event.queryStringParameters?.apiUrl; + const actorId = String(event.queryStringParameters?.actorId || '').slice(0, 100); + + if (!apiUrl || !layerId || !markerId) { + return badRequest('apiUrl, layer id and marker id are required'); + } + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const marker = layer.markers.find((m) => m.id === markerId); + if (!marker) return notFound('Marker not found'); + + const now = new Date().toISOString(); + marker.deleted = true; + marker.updatedBy = actorId || marker.updatedBy; + marker.updatedAt = now; + + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + const markerCount = layer.markers.filter((m) => !m.deleted).length; + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.lastUsedAt = now; + entry.markerCount = markerCount; + } + }); + + return json(204, null); +}; diff --git a/lambda/map-layers/handlers/getLayer.js b/lambda/map-layers/handlers/getLayer.js new file mode 100644 index 00000000..3ec7837c --- /dev/null +++ b/lambda/map-layers/handlers/getLayer.js @@ -0,0 +1,27 @@ +'use strict'; + +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound } = require('../lib/response'); + +// GET /map-layers/{id}?apiUrl=... +// Returns the full layer (including markers) and bumps lastUsedAt -- +// viewing a layer counts as "use" so actively-watched-but-not-edited +// layers don't age out of listLayers() mid-incident. +module.exports = async function getLayer(event) { + const apiUrl = event.queryStringParameters?.apiUrl; + const layerId = event.pathParameters?.id; + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const now = new Date().toISOString(); + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) entry.lastUsedAt = now; + }); + + return json(200, layer); +}; diff --git a/lambda/map-layers/handlers/listLayers.js b/lambda/map-layers/handlers/listLayers.js new file mode 100644 index 00000000..b26112ca --- /dev/null +++ b/lambda/map-layers/handlers/listLayers.js @@ -0,0 +1,23 @@ +'use strict'; + +const { getJson, indexKey } = require('../lib/s3Store'); +const { json, badRequest } = require('../lib/response'); + +const STALE_MS = 120 * 24 * 60 * 60 * 1000; // 120 days + +// GET /map-layers?apiUrl=... +// 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. +module.exports = async function listLayers(event) { + const apiUrl = event.queryStringParameters?.apiUrl; + 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; + }); + + return json(200, { layers }); +}; diff --git a/lambda/map-layers/handlers/upsertFeature.js b/lambda/map-layers/handlers/upsertFeature.js new file mode 100644 index 00000000..6285c4cc --- /dev/null +++ b/lambda/map-layers/handlers/upsertFeature.js @@ -0,0 +1,98 @@ +'use strict'; + +const crypto = require('crypto'); +const { getLayerObject, putLayerObject, updateIndex } = require('../lib/s3Store'); +const { json, badRequest, notFound } = require('../lib/response'); + +// Must stay in sync with the icon keys in +// src/pages/tasking/components/collab_marker_icons.js (MARKER_ICON_GROUPS). +const ICON_KEYS = new Set([ + 'fire', 'fire-extinguisher', 'water', 'house-damage', 'exclamation-triangle', + 'skull-crossbones', 'biohazard', 'radiation', 'bolt', 'wind', 'smog', + 'car-crash', 'tree', 'gas-pump', 'ban', + 'ambulance', 'first-aid', 'hospital', 'user-md', 'user-injured', 'syringe', + 'user', 'users', 'wheelchair', 'baby-carriage', 'paw', + 'campground', 'home', 'warehouse', 'tint', 'shower', + 'road', 'route', 'broadcast-tower', 'plug', + 'truck', 'helicopter', 'ship', 'life-ring', + 'map-marker-alt', 'flag', 'check-circle', 'question-circle', +]); +const DEFAULT_ICON = 'map-marker-alt'; +const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/; + +// PUT /map-layers/{id}/features body: { apiUrl, marker: {id?, lat, lng, icon, fill, description}, actorId } +// Missing/unknown marker.id creates a new marker; a known id overwrites it +// (last-write-wins, same convention as the existing default-assets Lambda). +module.exports = async function upsertFeature(event) { + const layerId = event.pathParameters?.id; + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return badRequest('Invalid JSON body'); + } + + const apiUrl = body.apiUrl; + const actorId = String(body.actorId || '').slice(0, 100); + const input = body.marker || {}; + + if (!apiUrl || !layerId) return badRequest('apiUrl and layer id are required'); + if (typeof input.lat !== 'number' || typeof input.lng !== 'number') { + return badRequest('marker.lat and marker.lng must be numbers'); + } + + const layer = await getLayerObject(apiUrl, layerId); + if (!layer) return notFound('Layer not found'); + + const now = new Date().toISOString(); + const icon = ICON_KEYS.has(input.icon) ? input.icon : DEFAULT_ICON; + const fill = HEX_COLOR.test(input.fill || '') ? input.fill : '#2b7bbb'; + const description = String(input.description || '').slice(0, 2000); + + const existingIdx = input.id ? layer.markers.findIndex((m) => m.id === input.id) : -1; + let marker; + + if (existingIdx >= 0) { + marker = { + ...layer.markers[existingIdx], + lat: input.lat, + lng: input.lng, + icon, + fill, + description, + updatedBy: actorId, + updatedAt: now, + deleted: false, + }; + layer.markers[existingIdx] = marker; + } else { + marker = { + id: crypto.randomUUID(), + lat: input.lat, + lng: input.lng, + icon, + fill, + description, + createdBy: actorId, + createdAt: now, + updatedBy: actorId, + updatedAt: now, + deleted: false, + }; + layer.markers.push(marker); + } + + layer.lastUsedAt = now; + await putLayerObject(apiUrl, layerId, layer); + + const markerCount = layer.markers.filter((m) => !m.deleted).length; + await updateIndex(apiUrl, (index) => { + const entry = index.layers.find((l) => l.id === layerId); + if (entry) { + entry.lastUsedAt = now; + entry.markerCount = markerCount; + } + }); + + return json(200, marker); +}; diff --git a/lambda/map-layers/index.js b/lambda/map-layers/index.js new file mode 100644 index 00000000..e629ba03 --- /dev/null +++ b/lambda/map-layers/index.js @@ -0,0 +1,44 @@ +'use strict'; + +const { json, serverError } = require('./lib/response'); +const listLayers = require('./handlers/listLayers'); +const createLayer = require('./handlers/createLayer'); +const getLayer = require('./handlers/getLayer'); +const upsertFeature = require('./handlers/upsertFeature'); +const deleteFeature = require('./handlers/deleteFeature'); + +// Single Lambda fronting all /lad/map-layers routes via API Gateway HTTP +// API (payload format 2.0) Lambda proxy integration. Routed by +// event.routeKey, which API Gateway sets to " " for +// whichever route matched (e.g. "GET /lad/map-layers/{id}") -- see the +// README for how these routes are created. The routes themselves are +// defined with the "/lad" prefix (matching the other Lighthouse Lambdas at +// lambda.lighthouse-extension.com/lad/...), not added by a base path +// mapping, so the custom domain's API mapping should not add another one. +const ROUTES = { + 'GET /lad/map-layers': listLayers, + 'POST /lad/map-layers': createLayer, + 'GET /lad/map-layers/{id}': getLayer, + 'PUT /lad/map-layers/{id}/features': upsertFeature, + 'DELETE /lad/map-layers/{id}/features/{markerId}': deleteFeature, +}; + +exports.handler = async (event) => { + const method = event.requestContext?.http?.method; + + if (method === 'OPTIONS') return json(204, null); + + const routeKey = event.requestContext?.routeKey; + const handler = ROUTES[routeKey]; + + if (!handler) { + return json(404, { error: 'Not found', routeKey }); + } + + try { + return await handler(event); + } catch (err) { + console.error('map-layers handler error:', err, JSON.stringify({ routeKey })); + return serverError(); + } +}; diff --git a/lambda/map-layers/lib/response.js b/lambda/map-layers/lib/response.js new file mode 100644 index 00000000..e23c52f0 --- /dev/null +++ b/lambda/map-layers/lib/response.js @@ -0,0 +1,25 @@ +'use strict'; + +// Locked down to the extension's own origin at deploy time is possible by +// replacing '*' below, but the request itself is already scoped by apiUrl +// (the org's Beacon source URL) so '*' matches the permissiveness of the +// existing share / default-assets Lambdas this feature mirrors. +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', +}; + +function json(statusCode, body) { + return { + statusCode, + headers: { 'Content-Type': 'application/json', ...CORS_HEADERS }, + body: body === null || body === undefined ? '' : JSON.stringify(body), + }; +} + +const badRequest = (message) => json(400, { error: message }); +const notFound = (message) => json(404, { error: message }); +const serverError = (message) => json(500, { error: message || 'Internal server error' }); + +module.exports = { json, badRequest, notFound, serverError, CORS_HEADERS }; diff --git a/lambda/map-layers/lib/s3Store.js b/lambda/map-layers/lib/s3Store.js new file mode 100644 index 00000000..5683da20 --- /dev/null +++ b/lambda/map-layers/lib/s3Store.js @@ -0,0 +1,94 @@ +'use strict'; + +const crypto = require('crypto'); +const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const S3_PREFIX = 'shared_layers'; + +/** Namespace every org's layers under a hash of its Beacon apiUrl. */ +function orgPrefix(apiUrl) { + const hash = crypto.createHash('sha256').update(String(apiUrl)).digest('hex').slice(0, 24); + return `${S3_PREFIX}/${hash}`; +} + +function indexKey(apiUrl) { + return `${orgPrefix(apiUrl)}/index.json`; +} + +function layerKey(apiUrl, layerId) { + return `${orgPrefix(apiUrl)}/${layerId}.json`; +} + +async function streamToString(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +/** Read a JSON object from S3. Returns { data: null, etag: null } if it doesn't exist yet. */ +async function getJson(key) { + try { + const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await streamToString(res.Body); + return { data: JSON.parse(body), etag: res.ETag }; + } catch (err) { + if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) { + return { data: null, etag: null }; + } + throw err; + } +} + +async function putJson(key, data, { ifMatch, ifNoneMatch } = {}) { + const params = { + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(data), + ContentType: 'application/json', + }; + if (ifMatch) params.IfMatch = ifMatch; + if (ifNoneMatch) params.IfNoneMatch = ifNoneMatch; + await s3.send(new PutObjectCommand(params)); +} + +/** + * Read-modify-write the small per-org index.json with optimistic + * concurrency (S3 conditional writes) + a short retry loop, since it's the + * one object concurrent requests (e.g. two users creating a layer at the + * same moment) could race on. Individual layer objects are only ever + * written by requests for that one layer, so they don't need this. + */ +async function updateIndex(apiUrl, mutate, { retries = 3 } = {}) { + const key = indexKey(apiUrl); + for (let attempt = 0; attempt <= retries; attempt++) { + const { data, etag } = await getJson(key); + const index = data || { apiUrl, layers: [] }; + const result = mutate(index); + try { + await putJson(key, index, etag ? { ifMatch: etag } : { ifNoneMatch: '*' }); + return result; + } catch (err) { + const status = err.$metadata?.httpStatusCode; + // S3's conditional-write feature (IfMatch/IfNoneMatch on PutObject) + // reports a lost race as 409 ConditionalRequestConflict, not the 412 + // Precondition Failed other conditional S3 operations use. + if ((status === 409 || status === 412) && attempt < retries) continue; // lost the race, retry + throw err; + } + } + throw new Error(`updateIndex: exhausted retries for ${key}`); +} + +async function getLayerObject(apiUrl, layerId) { + const { data } = await getJson(layerKey(apiUrl, layerId)); + if (!data || data.apiUrl !== apiUrl) return null; // not found, or belongs to a different org + return data; +} + +async function putLayerObject(apiUrl, layerId, layer) { + await putJson(layerKey(apiUrl, layerId), layer); +} + +module.exports = { orgPrefix, indexKey, layerKey, getJson, putJson, updateIndex, getLayerObject, putLayerObject }; diff --git a/lambda/map-layers/package.json b/lambda/map-layers/package.json new file mode 100644 index 00000000..e2f12949 --- /dev/null +++ b/lambda/map-layers/package.json @@ -0,0 +1,13 @@ +{ + "name": "lighthouse-map-layers-lambda", + "version": "1.0.0", + "private": true, + "description": "Lambda backend for the Lighthouse tasking map's collaborative map layers feature (list/create layers, read/write markers) backed by S3.", + "main": "index.js", + "scripts": { + "package": "npm ci --omit=dev && rm -rf dist && mkdir -p dist && zip -r dist/function.zip index.js lib handlers node_modules package.json" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.658.0" + } +} diff --git a/lambda/route-v2/index.mjs b/lambda/route-v2/index.mjs new file mode 100644 index 00000000..54da51c9 --- /dev/null +++ b/lambda/route-v2/index.mjs @@ -0,0 +1,97 @@ +import { GeoRoutesClient, CalculateRoutesCommand } from "@aws-sdk/client-geo-routes"; +import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; + +const client = new GeoRoutesClient({}); + +const CORS_HEADERS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "OPTIONS,POST", + "access-control-allow-headers": "content-type,authorization", +}; + +function json(statusCode, obj) { + return { + statusCode, + headers: { "content-type": "application/json", ...CORS_HEADERS }, + body: JSON.stringify(obj), + }; +} + +function mapTravelMode(v) { + // v2 valid values: Car | Pedestrian | Scooter | Truck :contentReference[oaicite:3]{index=3} + if (!v) return "Car"; + const x = String(v).toLowerCase(); + if (x === "walking" || x === "walk" || x === "pedestrian") return "Pedestrian"; + if (x === "car") return "Car"; + if (x === "truck") return "Truck"; + if (x === "scooter") return "Scooter"; + return "Car"; +} + +export const handler = async (event) => { + if (event.requestContext?.http?.method === "OPTIONS") { + return { statusCode: 204, headers: CORS_HEADERS, body: "" }; + } + + let claims; + try { + claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); + } catch (err) { + return json(401, { error: "Unauthorized", message: err?.message || String(err) }); + } + console.log(JSON.stringify({ msg: "beacon_auth", fn: "route-v2", userId: claims.sub || claims.client_id || "unknown" })); + + let body; + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch { + return json(400, { error: "Invalid JSON body" }); + } + + const coordinates = body.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return json(400, { error: "coordinates must be an array of at least 2 [lng,lat] points" }); + } + for (const c of coordinates) { + if (!Array.isArray(c) || c.length !== 2 || typeof c[0] !== "number" || typeof c[1] !== "number") { + return json(400, { error: "each coordinate must be [lng:number, lat:number]" }); + } + } + + const origin = coordinates[0]; + const destination = coordinates[coordinates.length - 1]; + const waypoints = coordinates.slice(1, -1).map((pos) => ({ Position: pos })); + + const travelMode = mapTravelMode(body.travelMode); + + // Request: + // - leg geometry so you can draw the line (Simple => LineString is returned) :contentReference[oaicite:4]{index=4} + // - travel step instructions (gives Instruction strings) :contentReference[oaicite:5]{index=5} + // - span features Distance/Names/RouteNumbers for "via M1" computation :contentReference[oaicite:6]{index=6} + const cmd = new CalculateRoutesCommand({ + Origin: origin, + Destination: destination, + ...(waypoints.length ? { Waypoints: waypoints } : {}), + TravelMode: travelMode, + + DepartNow: body.departNow === true ? true : undefined, + DepartureTime: body.departureTime || undefined, + + LegGeometryFormat: "Simple", + //TravelStepType: "Default", + MaxAlternatives: body.maxAlternatives || 0, + LegAdditionalFeatures: [], + //SpanAdditionalFeatures: ["Distance", "Names", "RouteNumbers"], + }); + + try { + const resp = await client.send(cmd); + return json(200, resp?.Routes) + } catch (err) { + console.error(err); + return json(500, { + error: "Route calculation failed", + detail: err?.name || err?.message || "unknown", + }); + } +}; diff --git a/lambda/share-v2/index.mjs b/lambda/share-v2/index.mjs new file mode 100644 index 00000000..5579647f --- /dev/null +++ b/lambda/share-v2/index.mjs @@ -0,0 +1,208 @@ +// index.mjs +import { + S3Client, + PutObjectCommand, + GetObjectCommand +} from "@aws-sdk/client-s3"; +import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; + +const s3 = new S3Client({}); +const BUCKET_NAME = process.env.BUCKET_NAME; +const CONFIG_PREFIX = process.env.CONFIG_PREFIX || ""; + +const SHARED_PREFS_PREFIX = buildBasePrefix(CONFIG_PREFIX, "shared_prefs"); + +export const handler = async (event) => { + try { + const method = event.requestContext?.http?.method || "GET"; + const query = event.queryStringParameters || {}; + const rawBody = event.body; + + if (method === "OPTIONS") { + const reqHeaders = + event.headers?.["access-control-request-headers"] || + event.headers?.["Access-Control-Request-Headers"]; + + return { + statusCode: 204, + headers: { + ...corsHeaders(reqHeaders), + "Access-Control-Max-Age": "86400" + }, + body: "" + }; + } + + let claims; + try { + claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); + } catch (err) { + return { + statusCode: 401, + headers: corsHeaders(), + body: JSON.stringify({ message: "Unauthorized", error: err?.message || String(err) }) + }; + } + console.log(JSON.stringify({ msg: "beacon_auth", fn: "share-v2", userId: claims.sub || claims.client_id || "unknown", method })); + + if (method === "POST" && !query.id) { + return await handleCreateConfig(rawBody); + } + + if (method === "GET" && query.id) { + return await handleGetConfig(query.id); + } + + return { + statusCode: 400, + headers: corsHeaders(), + body: JSON.stringify({ message: "Unsupported request" }) + }; + } catch (err) { + console.error(err); + return { + statusCode: 500, + headers: corsHeaders(), + body: JSON.stringify({ + message: "Internal Server Error", + error: err?.message || String(err) + }) + }; + } +}; + +function corsHeaders(requestedHeaders) { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "OPTIONS,GET,POST", + "Access-Control-Allow-Headers": requestedHeaders || "Content-Type, Authorization" + }; +} + +function buildBasePrefix(...parts) { + return parts + .filter(Boolean) + .map((p) => String(p).replace(/^\/+|\/+$/g, "")) + .filter(Boolean) + .join("/") + "/"; +} + +const ALPHABET = "234679ACDEFGHJKMNPQRTUVWXYZ"; + +function generateId() { + let out = ""; + for (let i = 0; i < 4; i++) { + out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + } + return out; +} + +function buildConfigKey(id) { + return `${SHARED_PREFS_PREFIX}${id}.json`; +} + +async function handleCreateConfig(rawBody) { + if (!rawBody) { + return { + statusCode: 400, + headers: corsHeaders(), + body: JSON.stringify({ message: "Missing body" }) + }; + } + + let config; + try { + const parsed = JSON.parse(rawBody); + config = parsed.config ?? parsed; + } catch { + return { + statusCode: 400, + headers: corsHeaders(), + body: JSON.stringify({ message: "Invalid JSON body" }) + }; + } + + if (!config || typeof config !== "object" || Array.isArray(config)) { + return { + statusCode: 400, + headers: corsHeaders(), + body: JSON.stringify({ message: "Missing or invalid config" }) + }; + } + + const id = generateId(); + const key = buildConfigKey(id); + + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: JSON.stringify(config), + ContentType: "application/json" + }) + ); + + return { + statusCode: 201, + headers: { ...corsHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ id }) + }; +} + +async function handleGetConfig(id) { + const key = buildConfigKey(id); + + try { + const result = await s3.send( + new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key + }) + ); + + const bodyString = await streamToString(result.Body); + + let config; + try { + config = JSON.parse(bodyString); + } catch { + return { + statusCode: 500, + headers: corsHeaders(), + body: JSON.stringify({ message: "Stored config is invalid JSON" }) + }; + } + + return { + statusCode: 200, + headers: { ...corsHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ config }) + }; + } catch (err) { + if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) { + return { + statusCode: 404, + headers: corsHeaders(), + body: JSON.stringify({ message: "Config not found" }) + }; + } + + console.error(err); + return { + statusCode: 500, + headers: corsHeaders(), + body: JSON.stringify({ message: "Error fetching config" }) + }; + } +} + +function streamToString(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("error", reject); + stream.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + }); +} diff --git a/lambda/shared/verifyBeaconToken.js b/lambda/shared/verifyBeaconToken.js new file mode 100644 index 00000000..51da918c --- /dev/null +++ b/lambda/shared/verifyBeaconToken.js @@ -0,0 +1,74 @@ +'use strict'; + +// SES's Beacon identity server (Duende/IdentityServer4) runs one instance +// per environment (prod, train, ...), each issuing tokens with a different +// `iss` and its own signing keys at `/.well-known/jwks`. The allowed +// issuers are configured via a deployment env var, never derived from the +// token itself, so a caller can't point verification at a JWKS they control. +const TRUSTED_ISS = (process.env.TRUSTED_ISS || '') + .split(',') + .map((iss) => iss.trim()) + .filter(Boolean); +const REQUIRED_SCOPE = 'beaconApi'; + +// jose ships ESM-only; a CommonJS module has to load it via dynamic +// import(), which Node supports from CJS too. Cached in module scope so it +// only happens once per warm Lambda instance. +let josePromise; +function loadJose() { + if (!josePromise) josePromise = import('jose'); + return josePromise; +} + +// One remote JWKS per trusted issuer, cached across warm invocations. +const jwksByIssuer = new Map(); +async function getJwks(iss) { + if (!jwksByIssuer.has(iss)) { + const { createRemoteJWKSet } = await loadJose(); + jwksByIssuer.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks`))); + } + return jwksByIssuer.get(iss); +} + +/** + * Verify an `Authorization: Bearer ` header is a currently-valid + * Beacon access token. Throws on any failure; returns the token's claims + * on success. + */ +async function verifyBeaconToken(authorizationHeader) { + const match = /^Bearer (.+)$/.exec(authorizationHeader || ''); + if (!match) throw new Error('Missing or malformed Authorization header'); + + const { jwtVerify, decodeJwt } = await loadJose(); + + // This iss is unverified until jwtVerify checks it below - it's only + // used to pick which allow-listed issuer's JWKS to fetch, never to + // build a URL from an untrusted value. + let unverifiedIss; + try { + unverifiedIss = decodeJwt(match[1]).iss; + } catch { + throw new Error('Malformed token'); + } + + if (!TRUSTED_ISS.includes(unverifiedIss)) { + throw new Error(`Untrusted token issuer: ${unverifiedIss}`); + } + + const jwks = await getJwks(unverifiedIss); + + const { payload } = await jwtVerify(match[1], jwks, { + issuer: unverifiedIss, + audience: `${unverifiedIss}/resources`, + algorithms: ['RS256'], + }); + + const scopes = Array.isArray(payload.scope) ? payload.scope : String(payload.scope || '').split(' '); + if (!scopes.includes(REQUIRED_SCOPE)) { + throw new Error(`Token missing required scope: ${REQUIRED_SCOPE}`); + } + + return payload; +} + +module.exports = { verifyBeaconToken }; diff --git a/lambda/shared/verifyBeaconToken.mjs b/lambda/shared/verifyBeaconToken.mjs new file mode 100644 index 00000000..9dcb0837 --- /dev/null +++ b/lambda/shared/verifyBeaconToken.mjs @@ -0,0 +1,60 @@ +import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; + +// SES's Beacon identity server (Duende/IdentityServer4) runs one instance +// per environment (prod, train, ...), each issuing tokens with a different +// `iss` and its own signing keys at `/.well-known/jwks`. The allowed +// issuers are configured via a deployment env var, never derived from the +// token itself, so a caller can't point verification at a JWKS they control. +const TRUSTED_ISS = (process.env.TRUSTED_ISS || '') + .split(',') + .map((iss) => iss.trim()) + .filter(Boolean); +const REQUIRED_SCOPE = 'beaconApi'; + +// One remote JWKS per trusted issuer, cached across warm invocations. +const jwksByIssuer = new Map(); +function getJwks(iss) { + if (!jwksByIssuer.has(iss)) { + jwksByIssuer.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks`))); + } + return jwksByIssuer.get(iss); +} + +/** + * Verify an `Authorization: Bearer ` header is a currently-valid + * Beacon access token. Throws on any failure; returns the token's claims + * on success. + */ +export async function verifyBeaconToken(authorizationHeader) { + const match = /^Bearer (.+)$/.exec(authorizationHeader || ''); + if (!match) throw new Error('Missing or malformed Authorization header'); + + // This iss is unverified until jwtVerify checks it below - it's only + // used to pick which allow-listed issuer's JWKS to fetch, never to + // build a URL from an untrusted value. + let unverifiedIss; + try { + unverifiedIss = decodeJwt(match[1]).iss; + } catch { + throw new Error('Malformed token'); + } + + if (!TRUSTED_ISS.includes(unverifiedIss)) { + throw new Error(`Untrusted token issuer: ${unverifiedIss}`); + } + + const jwks = getJwks(unverifiedIss); + + const { payload } = await jwtVerify(match[1], jwks, { + issuer: unverifiedIss, + audience: `${unverifiedIss}/resources`, + algorithms: ['RS256'], + }); + + const scopes = Array.isArray(payload.scope) ? payload.scope : String(payload.scope || '').split(' '); + if (!scopes.includes(REQUIRED_SCOPE)) { + throw new Error(`Token missing required scope: ${REQUIRED_SCOPE}`); + } + + return payload; +} diff --git a/package-lock.json b/package-lock.json index 853e20f5..309da404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3010,9 +3010,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4783,9 +4783,9 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, "node_modules/immutable": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", "dev": true, "license": "MIT" }, @@ -5069,10 +5069,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5915,9 +5925,9 @@ "peer": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -6316,9 +6326,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -6336,7 +6346,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6520,9 +6530,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7414,9 +7424,9 @@ "peer": true }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "license": "MIT", "engines": { "node": ">=14.14" diff --git a/src/contentscripts/jobs/create.js b/src/contentscripts/jobs/create.js index 3b17a497..9c3210e2 100644 --- a/src/contentscripts/jobs/create.js +++ b/src/contentscripts/jobs/create.js @@ -32,6 +32,9 @@ window.addEventListener("message", function(event) { $('#nearest-lhq-text').text('Searching...') $('#nearest-rescue-lhq-text').text('Searching...') $('#nearest-rescue-drive-lhq-text').text('Searching...') + // Sent by injectscripts/jobs/create.js, which runs in the page's own + // JS context and already has `user.accessToken` available directly. + const token = event.data.token; $.getJSON(chrome.runtime.getURL("resources/SES_HQs.geojson"), function (data) { let distances = [] let rescueDistances = [] @@ -143,9 +146,12 @@ window.addEventListener("message", function(event) { let promise = new Promise((resolve, reject) => { $.ajax({ - url: "https://lambda.lighthouse-extension.com/lad/route", + url: "https://lambda.lighthouse-extension.com/lad_v2/route", method: "POST", contentType: "application/json", + beforeSend: function (n) { + if (token) n.setRequestHeader('Authorization', 'Bearer ' + token); + }, data: body, dataType: "json", success: function(data) { 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/injectscripts/jobs/create.js b/src/injectscripts/jobs/create.js index 6abb458f..54f1149a 100644 --- a/src/injectscripts/jobs/create.js +++ b/src/injectscripts/jobs/create.js @@ -241,6 +241,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -253,6 +254,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -266,6 +268,7 @@ $(document).ready(function () { report: null, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -287,6 +290,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -299,6 +303,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -312,6 +317,7 @@ $(document).ready(function () { report: null, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -334,6 +340,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -346,6 +353,7 @@ $(document).ready(function () { report: accreditations, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -359,6 +367,7 @@ $(document).ready(function () { report: null, lat: vm.latitude.peek(), lng: vm.longitude.peek(), + token: user.accessToken, }, '*', ); @@ -442,6 +451,7 @@ $(document).ready(function () { report: accreditations, lat: newAddress.latitude, lng: newAddress.longitude, + token: user.accessToken, }, '*', ); @@ -454,6 +464,7 @@ $(document).ready(function () { report: accreditations, lat: newAddress.latitude, lng: newAddress.longitude, + token: user.accessToken, }, '*', ); @@ -466,6 +477,7 @@ $(document).ready(function () { report: null, lat: newAddress.latitude, lng: newAddress.longitude, + token: user.accessToken, }, '*', ); diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 90988d59..59705a34 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -663,8 +663,9 @@ function renderNearestAssets({ teamFilter, activeOnly, resultsToDisplay, cb }) { } const router = new AmazonLocationRouter({ - serviceUrl: "https://lambda.lighthouse-extension.com/lad/route", + serviceUrl: "https://lambda.lighthouse-extension.com/lad_v2/route", travelMode: "Car", + headers: { Authorization: "Bearer " + user.accessToken }, }); var routingControl = L.Routing.control({ 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..46629713 --- /dev/null +++ b/src/pages/tasking/components/collab_marker_icons.js @@ -0,0 +1,121 @@ +import L from 'leaflet'; + +/** + * Curated set of Font Awesome 5 Free icons relevant to emergency-service + * field marking (hazards/weather, vehicles/rescue, people/animals, + * resources/supplies, observation/comms, 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 & Weather', + icons: [ + { key: 'exclamation-triangle', label: 'Hazard', fa: 'fa-exclamation-triangle' }, + { key: 'fire-alt', label: 'Fire', fa: 'fa-fire-alt' }, + { key: 'cloud-showers-heavy', label: 'Heavy rain', fa: 'fa-cloud-showers-heavy' }, + { key: 'wind', label: 'Storm / high wind', fa: 'fa-wind' }, + { key: 'snowflake', label: 'Snow / ice', fa: 'fa-snowflake' }, + { key: 'water', label: 'Flooding', fa: 'fa-water' }, + { key: 'gas-pump', label: 'Fuel / gas hazard', fa: 'fa-gas-pump' }, + ], + }, + { + group: 'Vehicles & Rescue', + icons: [ + { key: 'ambulance', label: 'Ambulance', fa: 'fa-ambulance' }, + { key: 'car-side', label: 'Car', fa: 'fa-car-side' }, + { key: 'truck-monster', label: '4x4 / off-road truck', fa: 'fa-truck-monster' }, + { key: 'shuttle-van', label: 'Shuttle van', fa: 'fa-shuttle-van' }, + { key: 'helicopter', label: 'Helicopter', fa: 'fa-helicopter' }, + { key: 'ship', label: 'Boat', fa: 'fa-ship' }, + { key: 'plane', label: 'Aircraft', fa: 'fa-plane' }, + ], + }, + { + group: 'People & Animals', + icons: [ + { key: 'users', label: 'Group of people', fa: 'fa-users' }, + { key: 'dog', label: 'Animal / pet', fa: 'fa-dog' }, + ], + }, + { + group: 'Resources & Supplies', + icons: [ + { key: 'utensils', label: 'Food', fa: 'fa-utensils' }, + { key: 'shopping-cart', label: 'Supplies', fa: 'fa-shopping-cart' }, + ], + }, + { + group: 'Observation & Comms', + icons: [ + { key: 'eye', label: 'Observation point', fa: 'fa-eye' }, + { key: 'camera', label: 'Photo evidence', fa: 'fa-camera' }, + { key: 'comments', label: 'Discussion / comments', fa: 'fa-comments' }, + ], + }, + { + group: 'Status & Markers', + icons: [ + { key: 'flag', label: 'Checkpoint', fa: 'fa-flag' }, + { key: 'thumbtack', label: 'Pinned location', fa: 'fa-thumbtack' }, + { key: 'times', label: 'Cancelled / closed', fa: 'fa-times' }, + { key: 'minus-circle', label: 'Unavailable', fa: 'fa-minus-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 = 'thumbtack'; + +/** + * 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..c35a0d45 100644 --- a/src/pages/tasking/components/mapContextMenu.js +++ b/src/pages/tasking/components/mapContextMenu.js @@ -36,14 +36,18 @@ const createJobUrl = (result) => { export function installMapContextMenu({ map, - geocodeEndpoint = 'https://lambda.lighthouse-extension.com/lad/geocode', + geocodeEndpoint = 'https://lambda.lighthouse-extension.com/lad_v2/geocode', 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 + getToken = null, // () => Promise -- Beacon access token }) { 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 +82,26 @@ export function installMapContextMenu({ map.on("contextmenu", (e) => { lastLatLng = e.latlng; + if (btnAddMarker) { + 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); 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 ---- @@ -130,7 +149,11 @@ export function installMapContextMenu({ url.searchParams.set('lat', String(lastLatLng.lat)); url.searchParams.set('lon', String(lastLatLng.lng)); - const res = await fetch(url.toString(), { method: 'GET' }); + const token = getToken ? await getToken() : null; + const res = await fetch(url.toString(), { + method: 'GET', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); json = await res.json(); } catch (e) { @@ -139,7 +162,7 @@ export function installMapContextMenu({ icon: geocodeRedMarkerIcon, pane: 'pane-top-plus', }) - .bindPopup('Reverse geocode failed') + .bindPopup('Reverse geocode failed', { pane: 'pane-popup-top' }) .addTo(geocodeClickedPointLayer); return; } @@ -150,7 +173,7 @@ export function installMapContextMenu({ icon: geocodeRedMarkerIcon, pane: 'pane-top-plus', }) - .bindPopup(`Clicked location
${lastLatLng.lat.toFixed(6)}, ${lastLatLng.lng.toFixed(6)}`) + .bindPopup(`Clicked location
${lastLatLng.lat.toFixed(6)}, ${lastLatLng.lng.toFixed(6)}`, { pane: 'pane-popup-top' }) .addTo(geocodeClickedPointLayer); @@ -229,7 +252,7 @@ export function installMapContextMenu({ if (shortLine) m.bindTooltip(shortLine, { direction: 'top', sticky: true }); // full details on click - m.bindPopup(popupHtml); + m.bindPopup(popupHtml, { pane: 'pane-popup-top' }); m.on('popupopen', () => { const popup = m.getPopup(); diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index e581beb7..6a152be6 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -33,7 +33,7 @@ import { registerAcronymTextBinding } from "./components/acronymText.js"; import { Asset } from './models/Asset.js'; import { Tasking } from './models/Tasking.js'; -import { Team, bumpDefaultAssetTick, setDefaultAssetApiUrl } from './models/Team.js'; +import { Team, bumpDefaultAssetTick, setDefaultAssetApiUrl, setDefaultAssetTokenGetter } from './models/Team.js'; import { Job } from './models/Job.js'; import { Sector } from './models/Sector.js'; import { Tag } from "./models/Tag.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, getWritableCollabLayers, startAddMarkerFlow } from "./mapLayers/collabLayer.js"; import { registerBOMRainfallLayer, registerBOMRadarLayer, @@ -168,12 +169,42 @@ const defaultRedSvgIcon = L.divIcon({ popupAnchor: [0, -36], }); +// Beacon member id (the JWT's `sub` claim), decoded from the access token +// purely for UI purposes -- deciding whether to show/hide the collaborative +// map layers' Edit/Delete-marker, Add-marker and Delete-layer controls for +// read-only/delete-restricted layers. This has to be `sub` specifically +// (not params.personId or params.userId) because it's what +// lambda/map-layers-v2's verifyBeaconToken.js hands the Lambda as the +// caller's *verified* identity -- the Lambda is the one that actually +// enforces these permissions from its own signature-checked copy of the +// token; decoding it again here just lets the UI predict that outcome +// instead of the user hitting a 403 after the fact. No verification happens +// client-side -- an untrusted decode would be pointless as a security +// control, which is exactly why enforcement lives server-side. +let currentMemberId = null; + +function decodeJwtSub(jwt) { + try { + const payloadB64 = jwt.split('.')[1]; + const json = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(json)?.sub || null; + } catch { + return null; + } +} + +/** Sync getter for the current member id -- see currentMemberId above. */ +function getMemberId() { + return currentMemberId; +} + /** * Set the current token and wake any waiters. */ function setToken(newToken, newTokenExp) { token = newToken; tokenExp = newTokenExp; + currentMemberId = decodeJwtSub(newToken); if (resolveTokenReady) { // First token arrival unblocks anyone awaiting getToken() @@ -196,8 +227,16 @@ 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); +setDefaultAssetTokenGetter(() => getToken()); var ko; var myViewModel; @@ -224,13 +263,20 @@ const map = L.map('map', { installMapContextMenu({ map, - geocodeEndpoint: 'https://lambda.lighthouse-extension.com/lad/geocode', + geocodeEndpoint: 'https://lambda.lighthouse-extension.com/lad_v2/geocode', geocodeMarkerIcon: defaultSvgIcon, geocodeRedMarkerIcon: defaultRedSvgIcon, geocodeMaxResults: 10, + getToken, 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: () => getWritableCollabLayers(myViewModel, getMemberId).length > 0, + onAddMarker: (latlng) => startAddMarkerFlow(myViewModel, sourceUrl, markerActorId, latlng, getToken, getMemberId), }); @@ -256,7 +302,8 @@ const polylineMeasure = L.control.polylineMeasure({ polylineMeasure.addTo(map); const geocoder = new AwsLambdaGeocoderProvider({ - endpoint: 'https://lambda.lighthouse-extension.com/lad/geocode', + endpoint: 'https://lambda.lighthouse-extension.com/lad_v2/geocode', + getToken, }); const searchControl = new GeoSearchControl({ @@ -305,9 +352,20 @@ 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; +// Fixed above every reorderable marker pane (Config's paneOrder only ever +// assigns 300-700, see Map.js applyPaneOrder) so popups -- which would +// otherwise sit in Leaflet's default popupPane (also z-index 700, but +// painted before these custom panes and so behind them on tie) -- always +// render above every marker, including the topmost "Incident markers" pane. +map.createPane('pane-popup-top'); map.getPane('pane-popup-top').style.zIndex = 750; + function buildBasemapLayer(key) { // --- NSW VECTOR BASEMAP (Topographic style) --- @@ -362,6 +420,11 @@ function VM() { const self = this; + // Exposed so nested viewmodels/utils that already hold a reference to + // the root VM (e.g. MapVM's `root` param) can get the current Beacon + // token without threading a new constructor param through every layer. + self.getToken = getToken; + self.mapVM = new MapVM(map, self); self.tokenLoading = ko.observable(true); @@ -399,6 +462,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 +1432,25 @@ 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. + // params.personId), not params.userId. + actorId: markerActorId, + // Sync getter for the verified Beacon member id (JWT `sub`) -- see + // getMemberId above. Used by Config.js to decide whether the + // Delete-layer button is enabled for a given row. + getMemberId, }; self.config = new ConfigVM(self, configDeps); @@ -1466,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(), @@ -1900,7 +2011,8 @@ function VM() { if (multiAssetTeamIds.length === 0) return; - fetchSharedDefaults(sourceUrl, multiAssetTeamIds) + getToken() + .then(token => fetchSharedDefaults(sourceUrl, multiAssetTeamIds, token)) .then(() => { // Force all Team.defaultAsset() computeds to re-evaluate bumpDefaultAssetTick(); @@ -2147,6 +2259,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) => { @@ -2527,6 +2668,17 @@ function VM() { }); } + // Fetches a single Ops Log entry by id. Used by the collaborative map + // layers feature, which stores only an entry id on each marker and + // treats the Ops Log entry itself as the source of truth for the + // marker's title/description/comments (see mapLayers/collabLayer.js). + self.getOpsLogEntry = async function (entryId, cb) { + const t = await getToken(); // blocks here until token is ready + BeaconClient.operationslog.get(entryId, apiHost, params.userId, t, function (data) { + cb(data); + }); + } + self.updateTeamStatus = function (tasking, status, payload, cb) { BeaconClient.tasking.updateTeamStatus(apiHost, tasking.id(), status, payload, token, function (data) { tasking.job.fetchTasking({ force: true }); @@ -2978,6 +3130,7 @@ function VM() { registerBOMFloodWarningBoundariesLayer(self, sourceUrl); registerBOMFireWeatherDistrictsLayer(self, sourceUrl); registerRainRadarLayer(self, map); + registerCollabLayers(self, sourceUrl, markerActorId, getToken, getMemberId); // --- Layers Drawer (under zoom) const LayersDrawer = L.Control.extend({ @@ -2992,6 +3145,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 +3230,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 +3340,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 +3432,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; }, @@ -3560,6 +3731,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(), @@ -3652,11 +3827,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() { diff --git a/src/pages/tasking/mapLayers/bom.js b/src/pages/tasking/mapLayers/bom.js index 64364cb5..b976354f 100644 --- a/src/pages/tasking/mapLayers/bom.js +++ b/src/pages/tasking/mapLayers/bom.js @@ -85,7 +85,7 @@ export function registerBOMLandWarningsLayer(vm) { ${p.phase ? `Phase: ${p.phase}
` : ""} ${p.start_time_local ? `From: ${new Date(p.start_time_local).toLocaleString()}
` : ""} ${p.end_time_local ? `Until: ${new Date(p.end_time_local).toLocaleString()}` : ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(floodWarning); /* --- 1 Flood Watch -------------------------------------- */ @@ -107,7 +107,7 @@ export function registerBOMLandWarningsLayer(vm) { ${p.phase ? `Phase: ${p.phase}
` : ""} ${p.start_time_local ? `From: ${new Date(p.start_time_local).toLocaleString()}
` : ""} ${p.end_time_local ? `Until: ${new Date(p.end_time_local).toLocaleString()}` : ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(floodWatch); /* --- 2 Severe Weather Warning --------------------------- */ @@ -132,7 +132,7 @@ export function registerBOMLandWarningsLayer(vm) { ${p.warning ? `${p.warning}
` : ""} ${p.validfrom_utc ? `From: ${new Date(p.validfrom_utc).toLocaleString()}
` : ""} ${p.validto_utc ? `Until: ${new Date(p.validto_utc).toLocaleString()}` : ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(severeWeather); /* --- 3 Thunderstorm Warning ----------------------------- */ @@ -157,7 +157,7 @@ export function registerBOMLandWarningsLayer(vm) { ${p.phase ? `Phase: ${p.phase}
` : ""} ${p.start_time_local ? `From: ${new Date(p.start_time_local).toLocaleString()}
` : ""} ${p.end_time_local ? `Until: ${new Date(p.end_time_local).toLocaleString()}` : ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(thunderstorm); /* --- 4 Fire Weather Warnings ---------------------------- */ @@ -179,7 +179,7 @@ export function registerBOMLandWarningsLayer(vm) { ${p.day ? `Day: ${p.day}
` : ""} ${p.start_time_local ? `From: ${new Date(p.start_time_local).toLocaleString()}
` : ""} ${p.end_time_local ? `Until: ${new Date(p.end_time_local).toLocaleString()}` : ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(fireWeather); }, }); diff --git a/src/pages/tasking/mapLayers/collabLayer.js b/src/pages/tasking/mapLayers/collabLayer.js new file mode 100644 index 00000000..6be7705d --- /dev/null +++ b/src/pages/tasking/mapLayers/collabLayer.js @@ -0,0 +1,1029 @@ +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, + deleteLayer, + updateLayerModerators, + updateLayerPermissions, + updateLayerAttachment, + 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) +const DEFAULT_FILL = MARKER_COLOR_SWATCHES[5]; // blue -- also the first swatch highlighted as "active" for a new marker + +// Purely an aesthetic guardrail, not a backend one (the Lambda/Ops Log +// don't enforce a length at all) -- keeps a description or comment from +// growing into an unreadable wall of text that blows out the map popup's +// bounded width. Enforced client-side only, via maxlength on the textareas. +const TEXT_CHAR_LIMIT = 300; + +// Unlike Text, Beacon's Ops Log Subject is capped at 50 chars *server-side* +// -- this one's real. The old "Lighthouse LAD - Collaborative marker +// - " lead-in was 47-50 chars on its own, leaving zero room for an +// actual title. "LAD" is short enough to still read as this feature's mark +// at a glance among an entity's other Ops Log entries, without eating the +// whole budget. +const OPSLOG_SUBJECT_LIMIT = 50; +const SUBJECT_PREFIX = "LAD"; + +const layerKeyFor = (layerId) => `collab-${layerId}`; + +// Layer keys with an interactive popup (a marker's view popup, or the +// create/edit form) currently open. The polling refresh's drawFn rebuilds +// every marker on the layer from scratch each tick (layerGroup.clearLayers() +// + redraw), which would otherwise silently close whatever popup the user +// has open -- e.g. fading it out mid-keystroke while typing a comment or +// editing a description. registerLayerPolling's skipIfBusy checks this. +const busyLayerKeys = new Set(); + +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`; +} + +const escHtml = (s) => String(s || "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +}[c])); + +/** + * Wires a live "n/limit" counter to an input/textarea's sibling `.collab- + * char-counter` element (must immediately follow it in the markup), + * colouring it up as the limit approaches/hits so the limit reads as + * guidance rather than a hard wall -- for the description/comment fields + * there's nothing on the backend enforcing it and `maxlength` is what + * actually stops typing past it; for the title, `limit` is chosen so that + * title + Subject prefix never exceeds Beacon's real 50-char server-side + * cap (see markerTitleMaxLength). + */ +function wireCharCounter(inputEl, limit) { + const counterEl = inputEl.nextElementSibling; + if (!counterEl || !counterEl.classList.contains("collab-char-counter")) return; + + const update = () => { + const len = inputEl.value.length; + counterEl.textContent = `${len}/${limit}`; + counterEl.classList.toggle("collab-char-counter-warn", len >= limit * 0.9 && len < limit); + counterEl.classList.toggle("collab-char-counter-limit", len >= limit); + }; + inputEl.addEventListener("input", update); + update(); +} + +// ── Permissions ────────────────────────────────────────────────────── +// +// 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 / +// 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`/`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) { + 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); +} + +/** + * 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 / + * `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, 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: stored === null ? true : stored === "1", + fetchFn: async () => fetchLayerMarkers(apiUrl, layer.id, await getToken()), + drawFn: (layerGroup, data) => { + syncLayerPermissionFields(vm, layer, data); + drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId); + }, + skipIfBusy: () => busyLayerKeys.has(key), + }); +} + +/** + * 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 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 + // gets added to the map, silently blanking it out until its View switch + // is toggled off and back on. + layers + .filter((layer) => !vm.mapVM.onlineLayers.has(layerKeyFor(layer.id))) + .forEach((layer) => registerLayerPolling(vm, apiUrl, layer, actorId, getToken, getMemberId)); + vm.mapVM.layersDrawer?.refresh?.(); + 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 + * separately, into the app's existing map context menu (see + * components/mapContextMenu.js + startAddMarkerFlow/getWritableCollabLayers + * above) rather than a second contextmenu listener here. + */ +export async function registerCollabLayers(vm, apiUrl, actorId, getToken, getMemberId) { + migrateLegacyVisibleLayersToSubscriptions(); + await refreshSubscribedLayers(vm, apiUrl, actorId, getToken, getMemberId); +} + +/** + * 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 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); + return layer; +} + +/** + * Delete a collaborative layer: fires the remote (soft-)delete, then tears + * 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) { + const ok = await deleteLayer(apiUrl, layerId, actorId, await getToken()); + if (!ok) return false; + + 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, 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); + const list = vm.mapVM.collabLayers(); + if (!list.some((l) => l.id === layer.id)) { + vm.mapVM.collabLayers([...list, layer]); + } + 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); +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * Update a layer's HQ and/or event attachment (creator-or-moderator only, + * enforced server-side -- see lambda updateLayerAttachment.js). Updates the + * in-memory layer object (shared by reference with vm.mapVM.collabLayers()'s + * entry, same as updateCollabLayerPermissions above) on success so + * Config.js's row immediately reflects the new HQ/event without waiting for + * the next poll/refresh. + */ +export async function updateCollabLayerAttachment(vm, apiUrl, layerId, attachment, getToken) { + const saved = await updateLayerAttachment(apiUrl, layerId, attachment, await getToken()); + if (saved == null) return null; + + const layer = vm.mapVM.collabLayers().find((l) => l.id === layerId); + if (layer) { + layer.hqId = saved.hqId; + layer.hqName = saved.hqName; + layer.eventId = saved.eventId; + layer.eventName = saved.eventName; + layer.eventIdentifier = saved.eventIdentifier; + } + return saved; +} + +// ── Drawing ────────────────────────────────────────────────────────── + +function drawCollabMarkers(vm, layerGroup, data, apiUrl, layer, key, actorId, getToken, getMemberId) { + 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, pane: "pane-collab" }); + + // Bind a concrete, already-built element rather than Leaflet's + // "content factory function" form of bindPopup -- that form gets + // re-invoked on every popup.update() call, not just on open, and + // loadMarkerContent() below calls update() after each async Ops + // Log fetch. A function-content popup would rebuild itself (and + // re-fetch, and re-update(), ...) in an unbounded loop. Fetching is + // instead deferred to the "popupopen" event so a marker's Ops Log + // entry is only pulled once the user actually clicks it. + const { el, state } = buildMarkerPopupEl(vm, apiUrl, layer, key, marker, actorId, getToken, leafletMarker, getMemberId); + leafletMarker.bindPopup(el, { minWidth: 260, maxWidth: 320, pane: "pane-popup-top" }); + leafletMarker.on("popupopen", () => { + busyLayerKeys.add(key); + loadMarkerContent(vm, marker, el, leafletMarker, state); + }); + leafletMarker.on("popupclose", () => busyLayerKeys.delete(key)); + + 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". +// +// The marker record only carries GPS/style + Ops Log entry ids -- title, +// description and comment text are all resolved live from the Ops Log +// (source of truth), fetched lazily on "popupopen" (see drawCollabMarkers). +function buildMarkerPopupEl(vm, apiUrl, layer, key, marker, actorId, getToken, leafletMarker, getMemberId) { + const layerId = layer.id; + const canWrite = canWriteMarkers(layer, getMemberId); + 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"; + + el.innerHTML = ` +
Loading…
+
+
+
+ ${canComment ? ` +
+ +
+ +
+ ` : `
${escHtml(commentsRestrictedMessage)}
`} + ${canWrite ? ` +
+ + +
+
+ Delete this marker? + + +
+ ` : ""} + `; + + // Populated on "popupopen" (see drawCollabMarkers) via loadMarkerContent, + // which fetches the marker's title/description/comments from the Ops + // Log. `state.mainEntry` is stashed there so the Edit/Delete handlers + // below can read whatever title and description are currently showing. + const state = { mainEntry: null }; + + if (canComment) { + const commentInput = el.querySelector(".collab-comment-input"); + const addCommentBtn = el.querySelector(".collab-add-comment-btn"); + wireCharCounter(commentInput, TEXT_CHAR_LIMIT); + + addCommentBtn.addEventListener("click", async () => { + const text = commentInput.value.trim(); + if (!text) return; + addCommentBtn.disabled = true; + try { + const opsLogId = await logMarkerComment(vm, layerId, marker, text); + if (opsLogId == null) return; + await addMarkerComment(apiUrl, layerId, marker.id, opsLogId, actorId, await getToken()); + marker.commentOpsLogIds = Array.isArray(marker.commentOpsLogIds) ? [...marker.commentOpsLogIds, opsLogId] : [opsLogId]; + commentInput.value = ""; + await renderComments(vm, el.querySelector(".collab-marker-comments"), marker, leafletMarker); + } finally { + addCommentBtn.disabled = false; + } + }); + } + + if (canWrite) { + 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), getToken, state.mainEntry); + }); + + 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 () => { + // Whatever title/description the marker currently resolves to (or + // blank, if the Ops Log lookup above hasn't landed yet) becomes the + // deletion audit entry's content -- there's nothing left to stamp + // an opsLogId onto afterwards, so it only lives in the Ops Log. + const title = stripSubjectPrefix(state.mainEntry?.Subject); + const description = stripAuditFooter(state.mainEntry?.Text); + vm.mapVM.map.closePopup(); // clears busyLayerKeys (via "popupclose") before the refresh below + await logMarkerAudit(vm, "deleted", layerId, marker, title, description); + await deleteMarker(apiUrl, layerId, marker.id, actorId, await getToken()); + vm.mapVM.refreshPollingLayer(key); + }); + } + + return { el, state }; +} + +function loadMarkerContent(vm, marker, el, leafletMarker, state) { + const titleEl = el.querySelector(".collab-marker-title"); + const descEl = el.querySelector(".collab-marker-desc"); + const metaEl = el.querySelector(".collab-marker-meta"); + + return fetchOpsLogEntry(vm, marker.opsLogId).then((entry) => { + state.mainEntry = entry; + if (entry) { + titleEl.textContent = stripSubjectPrefix(entry.Subject) || "Untitled marker"; + descEl.textContent = stripAuditFooter(entry.Text); + const author = entry.CreatedBy?.FullName || (marker.createdBy ? `user ${marker.createdBy}` : "Unknown"); + metaEl.textContent = `Added by ${author}${marker.updatedAt ? ` · updated ${timeAgo(marker.updatedAt)}` : ""}`; + } else { + titleEl.textContent = "Untitled marker"; + descEl.innerHTML = "Ops Log entry unavailable"; + metaEl.textContent = marker.createdBy ? `Added by user ${marker.createdBy}` : ""; + } + leafletMarker.getPopup()?.update(); + return renderComments(vm, el.querySelector(".collab-marker-comments"), marker, leafletMarker); + }); +} + +function renderComments(vm, commentsEl, marker, leafletMarker) { + const ids = Array.isArray(marker.commentOpsLogIds) ? marker.commentOpsLogIds : []; + if (!ids.length) { + commentsEl.innerHTML = ""; + return Promise.resolve(); + } + + commentsEl.innerHTML = `
Loading comments…
`; + leafletMarker.getPopup()?.update(); + + return Promise.all(ids.map((id) => fetchOpsLogEntry(vm, id))).then((entries) => { + commentsEl.innerHTML = entries + .filter(Boolean) + .sort((a, b) => new Date(a.TimeLogged || 0) - new Date(b.TimeLogged || 0)) + .map((c) => ` +
+
${escHtml(stripAuditFooter(c.Text))}
+
${escHtml(c.CreatedBy?.FullName || "Unknown")} · ${timeAgo(c.TimeLogged)}
+
+ `).join(""); + leafletMarker.getPopup()?.update(); + }); +} + +// ── Ops Log audit trail ────────────────────────────────────────────── +// +// Every marker create/edit/delete/comment is logged to Beacon's Operations +// Log, and the Ops Log is the source of truth for the marker's title, +// description and comment thread -- the marker record itself only stores +// GPS/style plus the entry ids (opsLogId, commentOpsLogIds), resolved back +// via BeaconClient.operationslog.get() (loadMarkerContent/renderComments +// above). A Beacon Ops Log entry can only be edited by its author, so +// editing a marker always creates a *new* entry and re-points opsLogId at +// it rather than mutating the old one -- comments work the same way, each +// one just its own entry appended to commentOpsLogIds. Tag 4 is a +// known-good TagIds value confirmed to work against the live API -- there +// isn't a dedicated "map marker" tag to select instead. +const MARKER_AUDIT_TAG_ID = 4; + +// The full audit detail (coords/icon/colour/layer/action) is appended to +// Text after this marker so it's captured in the Ops Log entry itself, but +// the marker popup only ever shows what's *before* it -- just the user's +// own title/description/comment, not the surrounding metadata. Deliberately +// distinctive so it'll never collide with anything a user actually types. +const AUDIT_FOOTER_MARKER = "\n\n————— Lighthouse LAD marker details —————\n"; + +function buildMarkerAuditFooter(action, layerName, marker) { + const coords = `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}`; + return `${AUDIT_FOOTER_MARKER}Collaborative marker ${action} on layer "${layerName}" at ${coords}. Icon: ${marker.icon}, colour: ${marker.fill}.`; +} + +/** Strips the audit-detail footer back off an Ops Log entry's Text for display. */ +function stripAuditFooter(text) { + const idx = (text || "").indexOf(AUDIT_FOOTER_MARKER); + return idx === -1 ? (text || "") : text.slice(0, idx); +} + +/** The fixed "LAD " lead-in every titled Subject starts with. */ +function markerSubjectLead(action) { + return `${SUBJECT_PREFIX} ${action}`; +} + +/** + * How many characters are left for the user's own title once the "LAD + * - " lead-in is accounted for, so title + lead-in never exceeds + * Beacon's 50-char server-side Subject cap. Depends on `action` since + * "edited" is a character shorter than "created"/"deleted". + */ +function markerTitleMaxLength(action) { + return OPSLOG_SUBJECT_LIMIT - markerSubjectLead(action).length - " - ".length; +} + +/** Strips the "LAD - " lead-in back off an Ops Log entry's Subject for display. */ +function stripSubjectPrefix(subject) { + if (!subject || !subject.startsWith(`${SUBJECT_PREFIX} `)) return subject || ""; + const sepIdx = subject.indexOf(" - "); + return sepIdx === -1 ? "" : subject.slice(sepIdx + 3); +} + +/** + * `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 = { + Subject: subject, + Text: text, + Important: false, + Restricted: false, + ActionRequired: false, + 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)); + }); +} + +/** + * Log a marker create/edit/delete to the Operations Log and resolve with + * the new entry's id (or null if unavailable/failed) so it can be attached + * to the marker as its opsLogId. + * + * The Subject always leads with "LAD " -- same as a comment's fixed + * "LAD comment" -- so what happened is clear at a glance in Beacon's Ops + * Log list without opening the entry; the user's own title (if any) is + * appended for extra context, but never stands in for it alone the way it + * used to. Kept within markerTitleMaxLength by the title input's maxlength, + * so this never needs to truncate. + */ +function logMarkerAudit(vm, action, layerId, marker, title, description) { + 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, layer?.eventId); +} + +/** + * Log a comment on a marker to the Operations Log and resolve with the new + * entry's id (or null if unavailable/failed) so it can be appended to the + * marker's commentOpsLogIds. + */ +function logMarkerComment(vm, layerId, marker, commentText) { + 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, layer?.eventId); +} + +/** Fetch a single Ops Log entry by id, resolving null if unavailable/failed. */ +function fetchOpsLogEntry(vm, entryId) { + if (entryId == null || typeof vm.getOpsLogEntry !== "function") return Promise.resolve(null); + return new Promise((resolve) => { + vm.getOpsLogEntry(entryId, (result) => resolve(result || null)); + }); +} + +// ── 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. + * + * `currentEntry` is the marker's currently-resolved Ops Log entry (from + * loadMarkerContent, via the popup's Edit button) so the title/description + * fields can be prefilled without a second fetch -- it's undefined for a + * brand new marker, or if the lookup hadn't landed yet when Edit was + * clicked, in which case the fields just start blank. + * + * 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, getToken, currentEntry) { + let icon = marker?.icon || DEFAULT_MARKER_ICON_KEY; + let fill = marker?.fill || DEFAULT_FILL; + + // Save always logs this same action (see the save handler below), so + // the title's maxlength can be pinned to it up front. + const action = marker ? "edited" : "created"; + const titleMaxLength = markerTitleMaxLength(action); + + const el = document.createElement("div"); + el.className = "collab-marker-form"; + el.innerHTML = ` +
+ + Preview +
+
+ + +
+ +
+ +
+
+ + +
+
Actions on this marker are logged to the Ops Log.
+ `; + + const previewEl = el.querySelector(".collab-style-preview"); + const iconToggle = el.querySelector(".collab-icon-toggle"); + const colorToggle = el.querySelector(".collab-color-toggle"); + + wireCharCounter(el.querySelector(".collab-title-input"), titleMaxLength); + wireCharCounter(el.querySelector(".collab-desc-input"), TEXT_CHAR_LIMIT); + + 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], pane: "pane-popup-top" }) + .setLatLng(latlng) + .setContent(el) + .openOn(vm.mapVM.map); + + // Keeps the layer's poll-driven redraw (registerLayerPolling's + // skipIfBusy) from clearing+rebuilding every marker -- and closing this + // form -- out from under whatever the user is mid-typing. + busyLayerKeys.add(key); + popup.on("remove", () => { + busyLayerKeys.delete(key); + closeFloatingDropdown(); + }); + + el.querySelector(".collab-cancel-btn").addEventListener("click", () => { + vm.mapVM.map.closePopup(popup); + }); + + el.querySelector(".collab-save-btn").addEventListener("click", async () => { + const title = el.querySelector(".collab-title-input").value.trim(); + const description = el.querySelector(".collab-desc-input").value.trim(); + const payload = { + id: marker?.id, + lat: latlng.lat, + lng: latlng.lng, + icon, + fill, + }; + vm.mapVM.map.closePopup(popup); + + const opsLogId = await logMarkerAudit(vm, action, layerId, payload, title, description); + if (opsLogId != null) payload.opsLogId = opsLogId; + + await upsertMarker(apiUrl, layerId, payload, actorId, await getToken()); + 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" ───────────────────────────────────────── +// +// 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 + +function closeContextMenu() { + if (openContextMenu) { + openContextMenu(); + openContextMenu = null; + } +} + +function showLayerPickerMenu(vm, apiUrl, actorId, layers, containerPoint, latlng, getToken) { + 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, getToken); + }); + 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); + }; +} + +/** + * 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 getWritableCollabLayers(vm, getMemberId) { + return (vm.mapVM.collabLayers() || []).filter((layer) => canWriteMarkers(layer, getMemberId)); +} + +/** + * Entry point for the "Add marker to collaborative layer" item in the app's + * existing right-click context menu (components/mapContextMenu.js). With + * 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 writable = getWritableCollabLayers(vm, getMemberId); + if (writable.length === 0) return; + + 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, writable, containerPoint, latlng, getToken); +} diff --git a/src/pages/tasking/mapLayers/dams.js b/src/pages/tasking/mapLayers/dams.js index ea4a48a8..b5574a6e 100644 --- a/src/pages/tasking/mapLayers/dams.js +++ b/src/pages/tasking/mapLayers/dams.js @@ -80,7 +80,7 @@ export function registerNSWDeclaredDamsLayer(vm) { : "", damId !== "" ? `
Dam ID: ${damId}` : "", ].join(""); - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(featureLayer); }, diff --git a/src/pages/tasking/mapLayers/geoservices.js b/src/pages/tasking/mapLayers/geoservices.js index 5aa0c88c..2bedb002 100644 --- a/src/pages/tasking/mapLayers/geoservices.js +++ b/src/pages/tasking/mapLayers/geoservices.js @@ -298,7 +298,7 @@ export function registerSESUnitLocationsLayer(vm) { iconAnchor: [8, 16], // Anchor point of the icon popupAnchor: [0, -16] // Point from which the popup opens relative to the iconAnchor }) - }).bindPopup(`Loading ${name}...`); + }).bindPopup(`Loading ${name}...`, { pane: "pane-popup-top" }); marker.on('popupopen', async () => { try { diff --git a/src/pages/tasking/mapLayers/hazardwatch.js b/src/pages/tasking/mapLayers/hazardwatch.js index cd872baa..d2c87366 100644 --- a/src/pages/tasking/mapLayers/hazardwatch.js +++ b/src/pages/tasking/mapLayers/hazardwatch.js @@ -175,7 +175,7 @@ export function registerHazardWatchWarningsLayer(vm, apiHost) { const p = feature?.properties || {}; const html = popupHtml(p); - layer.bindPopup(html, { maxWidth: 460 }); + layer.bindPopup(html, { maxWidth: 460, pane: "pane-popup-top" }); let center; try { @@ -197,7 +197,7 @@ export function registerHazardWatchWarningsLayer(vm, apiHost) { interactive: true, }); - marker.bindPopup(html, { maxWidth: 460 }); + marker.bindPopup(html, { maxWidth: 460, pane: "pane-popup-top" }); layerGroup.addLayer(marker); diff --git a/src/pages/tasking/mapLayers/transport.js b/src/pages/tasking/mapLayers/transport.js index 28da5eec..16f6ac15 100644 --- a/src/pages/tasking/mapLayers/transport.js +++ b/src/pages/tasking/mapLayers/transport.js @@ -42,7 +42,7 @@ export function registerTransportCamerasLayer(vm, map, getToken, apiHost, params iconAnchor: [16, 16], popupAnchor: [0, -16], }), - }).bindPopup(details); + }).bindPopup(details, { pane: "pane-popup-top" }); layerGroup.addLayer(marker); }); @@ -140,7 +140,7 @@ export function registerTransportIncidentsLayer(vm, map, getToken, apiHost, para iconAnchor: [16, 16], popupAnchor: [0, -16], }), - }).bindPopup(details); + }).bindPopup(details, { pane: "pane-popup-top" }); layerGroup.addLayer(marker); }); @@ -152,7 +152,6 @@ export function registerTransportIncidentsLayer(vm, map, getToken, apiHost, para function getTransportApiKeyOpsLog(apiHost, userId, token, cb) { var opsId = null; - switch (apiHost) { case 'https://previewbeacon.ses.nsw.gov.au': opsId = '46273'; @@ -205,7 +204,7 @@ async function fetchTransportIncidentsAsync(apiHost, userId, token) { if (!transportApiKeyCache) { transportApiKeyCache = await new Promise((resolve) => { - getTransportApiKeyOpsLog('https://trainbeacon.ses.nsw.gov.au', apiHost, userId, token, function (key) { + getTransportApiKeyOpsLog(apiHost, userId, token, function (key) { sessionStorage.setItem(sessionKey, key); resolve(key); }); diff --git a/src/pages/tasking/mapLayers/waternsw.js b/src/pages/tasking/mapLayers/waternsw.js index 25dff737..a31667fe 100644 --- a/src/pages/tasking/mapLayers/waternsw.js +++ b/src/pages/tasking/mapLayers/waternsw.js @@ -53,7 +53,7 @@ export function registerWaterNSWBoundariesLayer(vm) { featureLayer.bindPopup((layer) => { const p = layer.feature.properties; return `${p.NAME || "Unknown"}
${p.Class || ""}`; - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(featureLayer); }, @@ -101,7 +101,7 @@ export function registerEPAContaminationSitesLayer(vm) { ? `
Management: ${p.ManagementClass}` : "", ].join(""); - }); + }, { pane: "pane-popup-top" }); layerGroup.addLayer(featureLayer); }, diff --git a/src/pages/tasking/markers/assetMarker.js b/src/pages/tasking/markers/assetMarker.js index dc46f2a9..80e795ac 100644 --- a/src/pages/tasking/markers/assetMarker.js +++ b/src/pages/tasking/markers/assetMarker.js @@ -86,6 +86,7 @@ export function attachAssetMarker(ko, map, viewModel, asset) { maxHeight: 360, autoPan: true, autoPanPadding: [16, 16], + pane: 'pane-popup-top', }).setContent(contentEl); @@ -167,6 +168,7 @@ export function attachUnmatchedAssetMarker(ko, map, viewModel, asset) { maxHeight: 360, autoPan: true, autoPanPadding: [16, 16], + pane: 'pane-popup-top', }).setContent(contentEl); m.bindPopup(popup); diff --git a/src/pages/tasking/markers/jobMarker.js b/src/pages/tasking/markers/jobMarker.js index bd0d3a71..60246847 100644 --- a/src/pages/tasking/markers/jobMarker.js +++ b/src/pages/tasking/markers/jobMarker.js @@ -34,7 +34,8 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { maxWidth: 760, minHeight: 300, autoPan: true, - autoPanPadding: [16, 16] + autoPanPadding: [16, 16], + pane: 'pane-popup-top' }).setContent(contentEl); 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/models/Team.js b/src/pages/tasking/models/Team.js index 36ee6adb..541fea84 100644 --- a/src/pages/tasking/models/Team.js +++ b/src/pages/tasking/models/Team.js @@ -50,6 +50,12 @@ let _apiUrl = null; /** Called once from main.js after params are resolved. */ export function setDefaultAssetApiUrl(url) { _apiUrl = url; } +/** Async getter for the current Beacon access token, e.g. `() => getToken()`. */ +let _getToken = null; + +/** Called once from main.js after params are resolved. */ +export function setDefaultAssetTokenGetter(getTokenFn) { _getToken = getTokenFn; } + function _setDefaultAsset(teamId, assetId) { const map = loadSharedMapping(); @@ -72,8 +78,8 @@ function _setDefaultAsset(teamId, assetId) { _defaultAssetTick(_defaultAssetTick() + 1); // Push to Lambda / S3 backend so other browsers pick it up (fire-and-forget) - if (_apiUrl) { - pushSharedDefault(_apiUrl, teamId, assetId); + if (_apiUrl && _getToken) { + Promise.resolve(_getToken()).then((token) => pushSharedDefault(_apiUrl, teamId, assetId, token)); } } @@ -160,7 +166,8 @@ export function Team(data = {}, deps = {}) { self.trackableAssets.subscribe(assets => { if (assets.length > 1 && !_hadMultipleAssets && _apiUrl) { _hadMultipleAssets = true; - fetchSharedDefaults(_apiUrl, [String(self.id())]) + Promise.resolve(_getToken?.()) + .then(token => fetchSharedDefaults(_apiUrl, [String(self.id())], token)) .then(() => _defaultAssetTick(_defaultAssetTick() + 1)) .catch(() => { /* im not empty i promise */ }); } else if (assets.length <= 1) { @@ -258,7 +265,8 @@ export function Team(data = {}, deps = {}) { self.refreshData(); if (_apiUrl && (self.trackableAssets?.() || []).length > 1) { - fetchSharedDefaults(_apiUrl, [String(self.id())]) + Promise.resolve(_getToken?.()) + .then(token => fetchSharedDefaults(_apiUrl, [String(self.id())], token)) .then(() => _defaultAssetTick(_defaultAssetTick() + 1)) .catch(() => {/* im not empty i promise */}); } @@ -288,7 +296,8 @@ export function Team(data = {}, deps = {}) { // If this team has multiple assets, refresh shared default mapping if (_apiUrl && (self.trackableAssets?.() || []).length > 1) { - fetchSharedDefaults(_apiUrl, [String(self.id())]) + Promise.resolve(_getToken?.()) + .then(token => fetchSharedDefaults(_apiUrl, [String(self.id())], token)) .then(() => _defaultAssetTick(_defaultAssetTick() + 1)) .catch(() => {/* im not empty i promise */}); } diff --git a/src/pages/tasking/utils/batchRoute.js b/src/pages/tasking/utils/batchRoute.js index 05de6bb7..4fab2d21 100644 --- a/src/pages/tasking/utils/batchRoute.js +++ b/src/pages/tasking/utils/batchRoute.js @@ -12,7 +12,7 @@ * @module batchRoute */ -const ROUTE_URL = "https://lambda.lighthouse-extension.com/lad/route"; +const ROUTE_URL = "https://lambda.lighthouse-extension.com/lad_v2/route"; /** * @typedef {Object} RouteSummary @@ -31,12 +31,14 @@ const ROUTE_URL = "https://lambda.lighthouse-extension.com/lad/route"; * @param {number} [opts.timeoutMs=10000] Per-request timeout. * @param {AbortSignal} [opts.signal] Optional abort signal to * cancel all in-flight requests. + * @param {() => Promise} [opts.getToken] Beacon access token getter. * @returns {Promise<(RouteSummary|null)[]>} * Array aligned with `pairs`. Each entry is either a summary object or * `null` if that individual route failed. */ export async function batchRoute(pairs, opts = {}) { - const { travelMode = "Car", timeoutMs = 10000, signal } = opts; + const { travelMode = "Car", timeoutMs = 10000, signal, getToken } = opts; + const token = getToken ? await getToken() : null; const promises = pairs.map(({ fromLat, fromLng, toLat, toLng }) => { const controller = new AbortController(); @@ -57,7 +59,10 @@ export async function batchRoute(pairs, opts = {}) { return fetch(ROUTE_URL, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, body: JSON.stringify(payload), signal: controller.signal, }) diff --git a/src/pages/tasking/utils/collabLayerSync.js b/src/pages/tasking/utils/collabLayerSync.js new file mode 100644 index 00000000..c618a266 --- /dev/null +++ b/src/pages/tasking/utils/collabLayerSync.js @@ -0,0 +1,558 @@ +/** + * 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_v2/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)); +} + +// ── 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 ──────────────────────────────────────────── + +/** + * List collaborative layers for an org (layers unused for 120+ days are + * 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, 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)}${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 cached(); + } + const { layers } = await res.json(); + if (!hqId) saveCachedLayerIndex(layers || []); + return layers || []; + } catch (err) { + console.warn('[collabLayerSync] list error:', err); + return cached(); + } +} + +/** + * Create a new named collaborative layer. + * @param {string} apiUrl + * @param {string} name + * @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') + * 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 = {}) { + const trimmed = (name || '').trim(); + if (!apiUrl || !trimmed || !permissions.hq?.id) return null; + + try { + const res = await fetch(LAMBDA_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ + apiUrl, + name: trimmed, + createdBy: String(actorId), + 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) { + 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; + } +} + +/** + * Delete (soft-delete) a collaborative layer. Optimistically removes it + * from the local cached index, then fires the remote write. + * @param {string} apiUrl + * @param {string} layerId + * @param {string} actorId + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise} true on success + */ +export async function deleteLayer(apiUrl, layerId, actorId, token) { + if (!apiUrl || !layerId) return false; + + const index = loadCachedLayerIndex(); + saveCachedLayerIndex(index.filter((l) => l.id !== layerId)); + + try { + const url = `${LAMBDA_BASE}/${encodeURIComponent(layerId)}` + + `?apiUrl=${encodeURIComponent(apiUrl)}&actorId=${encodeURIComponent(actorId)}`; + const res = await fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); + if (!res.ok) { + throw new Error(`deleteLayer failed with status ${res.status}`); + } + localStorage.removeItem(layerCacheKey(layerId)); + return true; + } catch (err) { + console.warn('[collabLayerSync] deleteLayer error:', err); + // Restore the optimistically-removed entry so a transient network + // failure doesn't silently hide a layer that's still on the server. + saveCachedLayerIndex(index); + return false; + } +} + +/** + * 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; + } +} + +/** + * 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; + } +} + +/** + * Update a layer's HQ and/or event attachment. Only the creator or a + * current moderator is authorized server-side (see lambda + * updateLayerAttachment.js) -- calling this as anyone else fails with a + * 403 and the local cache is left untouched. `hq` is required, same as at + * creation; `event` given as null (or omitted) clears any existing event + * attachment. + * @param {string} apiUrl + * @param {string} layerId + * @param {{hq: {id: string, name: string}, event?: {id: string, name: string, identifier?: string}|null}} attachment + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise<{hqId: string, hqName: string, eventId: string|null, eventName: string|null, eventIdentifier: string|null}|null>} the saved attachment, or null on failure + */ +export async function updateLayerAttachment(apiUrl, layerId, attachment, token) { + if (!apiUrl || !layerId || !attachment?.hq?.id) return null; + + try { + const res = await fetch(`${LAMBDA_BASE}/${encodeURIComponent(layerId)}/attachment`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ apiUrl, hq: attachment.hq, event: attachment.event || null }), + }); + if (!res.ok) { + throw new Error(`updateLayerAttachment 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] updateLayerAttachment 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 + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise} the layer, including its markers array + */ +export async function fetchLayerMarkers(apiUrl, layerId, token) { + 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', Authorization: `Bearer ${token}` } }); + 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, icon: string, fill: string, opsLogId?: number}} marker + * Title/description aren't stored here -- they live in the Ops Log entry + * `opsLogId` points at (see mapLayers/collabLayer.js). + * @param {string} actorId + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise} the saved marker (with server-assigned id/timestamps), or null on failure + */ +export async function upsertMarker(apiUrl, layerId, marker, actorId, token) { + 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', 'Authorization': `Bearer ${token}` }, + 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 + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise} + */ +export async function deleteMarker(apiUrl, layerId, markerId, actorId, token) { + 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', headers: { 'Authorization': `Bearer ${token}` } }); + } catch (err) { + console.warn('[collabLayerSync] deleteMarker error:', err); + } +} + +/** + * Attach a comment -- an Ops Log entry id already created client-side -- + * to a marker's comment thread. Applies an optimistic local update to the + * cached layer before firing the remote write. + * @param {string} apiUrl + * @param {string} layerId + * @param {string} markerId + * @param {number} opsLogId Id of the comment's Ops Log entry. + * @param {string} actorId + * @param {string} token Beacon access token (Authorization: Bearer). + * @returns {Promise} the updated marker, or null on failure + */ +export async function addMarkerComment(apiUrl, layerId, markerId, opsLogId, actorId, token) { + if (!apiUrl || !layerId || !markerId || opsLogId == null) return null; + + // Optimistic local update + const cached = loadCachedLayer(layerId); + if (cached && Array.isArray(cached.markers)) { + const m = cached.markers.find((mk) => mk.id === markerId); + if (m) { + m.commentOpsLogIds = Array.isArray(m.commentOpsLogIds) ? [...m.commentOpsLogIds, opsLogId] : [opsLogId]; + saveCachedLayer(layerId, cached); + } + } + + try { + const url = `${LAMBDA_BASE}/${encodeURIComponent(layerId)}/features/${encodeURIComponent(markerId)}/comments`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ apiUrl, opsLogId, actorId: String(actorId) }), + }); + if (!res.ok) { + throw new Error(`addMarkerComment failed with status ${res.status}`); + } + const saved = await res.json(); + + // Reconcile with server-confirmed state + const latest = loadCachedLayer(layerId) || cached; + if (latest && Array.isArray(latest.markers)) { + const i = latest.markers.findIndex((mk) => mk.id === markerId); + if (i >= 0) latest.markers[i] = saved; + saveCachedLayer(layerId, latest); + } + + return saved; + } catch (err) { + console.warn('[collabLayerSync] addMarkerComment error:', err); + return null; + } +} diff --git a/src/pages/tasking/utils/defaultAssetSync.js b/src/pages/tasking/utils/defaultAssetSync.js index a7e60c57..5c7dabba 100644 --- a/src/pages/tasking/utils/defaultAssetSync.js +++ b/src/pages/tasking/utils/defaultAssetSync.js @@ -13,7 +13,7 @@ * doesn't pull the entire universe. */ -const LAMBDA_BASE = 'https://lambda.lighthouse-extension.com/lad/default-assets'; +const LAMBDA_BASE = 'https://lambda.lighthouse-extension.com/lad_v2/default-assets'; const LS_KEY = 'lh_sharedDefaultAssets'; // localStorage key for cached mapping const LS_TS_KEY = 'lh_sharedDefaultAssets_ts'; // timestamp of last successful fetch @@ -52,15 +52,16 @@ export function saveSharedMapping(mapping) { * * @param {string} apiUrl The Beacon source URL (namespace). * @param {string[]} teamIds Array of team ID strings. + * @param {string} token Beacon access token (Authorization: Bearer). * @returns {Promise>} teamId → assetId map */ -export async function fetchSharedDefaults(apiUrl, teamIds) { +export async function fetchSharedDefaults(apiUrl, teamIds, token) { if (!teamIds || teamIds.length === 0) return loadSharedMapping(); const url = `${LAMBDA_BASE}?apiUrl=${encodeURIComponent(apiUrl)}&teamIds=${teamIds.join(',')}`; try { - const res = await fetch(url, { method: 'GET' }); + const res = await fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { console.warn('[defaultAssetSync] GET failed:', res.status); return loadSharedMapping(); // fall back to cache @@ -100,9 +101,10 @@ export async function fetchSharedDefaults(apiUrl, teamIds) { * @param {string} apiUrl The Beacon source URL (namespace). * @param {string} teamId * @param {string} assetId + * @param {string} token Beacon access token (Authorization: Bearer). * @returns {Promise} */ -export async function pushSharedDefault(apiUrl, teamId, assetId) { +export async function pushSharedDefault(apiUrl, teamId, assetId, token) { if (!assetId) return; // nothing to push // Optimistic local update @@ -114,7 +116,7 @@ export async function pushSharedDefault(apiUrl, teamId, assetId) { try { await fetch(LAMBDA_BASE, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ apiUrl, teamId: String(teamId), diff --git a/src/pages/tasking/utils/geocode.js b/src/pages/tasking/utils/geocode.js index c064b81c..6c2efd65 100644 --- a/src/pages/tasking/utils/geocode.js +++ b/src/pages/tasking/utils/geocode.js @@ -1,8 +1,9 @@ // AwsLambdaGeocoderProvider.js export class AwsLambdaGeocoderProvider { - constructor({ endpoint, fetchOptions } = {}) { + constructor({ endpoint, fetchOptions, getToken } = {}) { this.endpoint = endpoint.replace(/\/$/, ''); this.fetchOptions = fetchOptions; // optional: headers, credentials, etc. + this.getToken = getToken; // optional: () => Promise -- Beacon access token } // leaflet-geosearch calls: provider.search({ query: string }) @@ -12,9 +13,17 @@ export class AwsLambdaGeocoderProvider { const url = new URL(this.endpoint); url.searchParams.set('q', query.trim()); + // Computed fresh per call (not baked into fetchOptions at construction) + // since this provider instance is long-lived and the token can expire. + const token = this.getToken ? await this.getToken() : null; + const res = await fetch(url.toString(), { method: 'GET', ...this.fetchOptions, + headers: { + ...this.fetchOptions?.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, }); if (!res.ok) { diff --git a/src/pages/tasking/viewmodels/AssetPopUp.js b/src/pages/tasking/viewmodels/AssetPopUp.js index 5a7f01ae..6675afe0 100644 --- a/src/pages/tasking/viewmodels/AssetPopUp.js +++ b/src/pages/tasking/viewmodels/AssetPopUp.js @@ -39,7 +39,7 @@ export class AssetPopupViewModel { - drawRouteToJob = (tasking) => { + drawRouteToJob = async (tasking) => { const from = tasking.getTeamLatLng(); const to = tasking.getJobLatLng(); if (!from || !to) { @@ -48,9 +48,11 @@ export class AssetPopupViewModel { } this.routeLoading(true); + const token = await this.api.getToken(); const router = new AmazonLocationRouter({ - serviceUrl: "https://lambda.lighthouse-extension.com/lad/route", + serviceUrl: "https://lambda.lighthouse-extension.com/lad_v2/route", travelMode: "Car", + headers: { Authorization: `Bearer ${token}` }, }); const routeControl = L.Routing.control({ diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 91fb4831..88464e4c 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -3,16 +3,320 @@ import ko from 'knockout'; import * as bootstrap from 'bootstrap5'; // Modal, Tooltip, etc. import { Enum } from '../utils/enum.js'; +import { + createCollabLayer, deleteCollabLayer, updateCollabLayerModerators, updateCollabLayerPermissions, + updateCollabLayerAttachment, + 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 => { + const entity = r.Entity ? String(r.Entity).trim() : ''; + return { + id: String(r.Username), + name: [r.Firstname, r.Lastname].filter(Boolean).join(' ') || String(r.Username), + // Unit name alongside the member number disambiguates + // same-named members across different units. + detail: entity ? `${entity} · ${r.Username}` : 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 + * "attach to event" field (see createCollabLayer's `event` param, and + * updateCollabLayerAttachment for changing it later). Same search/debounce + * shape, but holds at most one picked `{id, name, identifier}` rather than + * a list. + */ +function makeEventPicker(searchEvents, 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); + // 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; +} -const FUNCTION_URL = "https://lambda.lighthouse-extension.com/lad/share"; +/** + * 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) { const self = this; + // Exposed publicly so code holding a ConfigVM reference (e.g. + // InstantTaskViewModel's `config`) can get the current Beacon token + // without its own deps plumbing. + self.getToken = deps.getToken; + const LAYOUT_PRESETS = [ 'map-right-teams-top', 'map-right-tasking-top', @@ -74,8 +378,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' } ]; @@ -89,9 +394,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); @@ -188,6 +499,581 @@ export function ConfigVM(root, deps) { onCancel && onCancel(); }; } + // ── 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(''); + // 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 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 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 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 + 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); + // 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); + 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); + row.hqEditPicker?.reset(row.hqId ? { id: row.hqId, name: row.hqName } : null); + row.eventEditPicker?.reset(row.eventId ? { id: row.eventId, name: row.eventName, identifier: row.eventIdentifier } : null); + row.moderatorPicker?.reset(row.moderators); + 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. + 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(); + 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`; + } + + // 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. + self.collabLayerRows = ko.pureComputed(() => self.collabLayers() + .slice() + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .map(layer => { + 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, + name: layer.name, + markerCount: layer.markerCount || 0, + lastUsedLabel: relativeTime(layer.lastUsedAt), + markerMode, + deleteMode, + 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(', '), + eventId: layer.eventId || null, + eventName: layer.eventName || null, + eventIdentifier: layer.eventIdentifier || 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 moderator picker in + // #collabPermissionsModal at all. Edited in that same modal + // as permissions/HQ/event (see saveRowPermissions), not its + // own separate inline panel. + canManageModerators, + 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), + // HQ/event reassignment shares the same modal and the same + // authorization as the permission modes above (see + // updateLayerAttachment.js) -- same "draft, then commit" + // pickers as newLayerHqPicker/newLayerEventPicker in the + // create-layer form above, just seeded from this layer's + // current attachment instead of starting empty. + hqEditPicker: canManageModerators + ? makeHqPicker(deps.entitiesSearch, layer.hqId ? { id: layer.hqId, name: layer.hqName } : null) + : null, + eventEditPicker: canManageModerators + ? makeEventPicker(deps.searchEvents, layer.eventId + ? { id: layer.eventId, name: layer.eventName, identifier: layer.eventIdentifier } + : null) + : 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' : `${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.openPermissionsModal = () => self.openPermissionsModal(row); + row.savePermissions = () => self.saveRowPermissions(row); + 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)); + }); + + // 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()); + + // 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(); + 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 -- closes the + // form and puts it back to its just-opened state, ready for next time. + // HQ resets to the resolved default (not empty), since 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.showCreateLayerForm(false); + } + + self.cancelCreateLayer = () => { + self.collabLayerError(''); + resetNewLayerForm(); + }; + + 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 = { + 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.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.'); + self.creatingCollabLayer(false); + } + }; + + // 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 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.'); + } finally { + self.refreshingCollabLayers(false); + } + }; + + // Actual delete, fired from row.confirmDeleteLayer above once the user + // has clicked through the row's inline confirm step (same two-step + // pattern as a marker's own delete confirm in collabLayer.js). + self.deleteCollabLayer = async (row) => { + if (!deps.apiUrl || self.deletingCollabLayerId()) return; + + self.collabLayerError(''); + self.deletingCollabLayerId(row.layer.id); + try { + const ok = await deleteCollabLayer(root, deps.apiUrl, row.layer.id, deps.actorId, deps.getToken); + if (!ok) throw new Error('Delete failed'); + } catch (err) { + console.error('Error deleting collaborative layer:', err); + self.collabLayerError('Failed to delete layer. Try again later.'); + row.confirmingDelete(false); + } finally { + self.deletingCollabLayerId(null); + } + }; + + // Saves a row's in-progress marker/delete/comment mode radios, its + // in-progress HQ/event pickers, *and* its in-progress moderator picker + // (all edited together in #collabPermissionsModal) as the layer's new + // permissions, attachment and moderator list, 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. Three independent PUTs (permissions, + // attachment and moderators are separate Lambda routes/S3 fields) fired + // together so one Save click covers everything the modal edits; any can + // fail on its own, in which case the modal stays open with the error + // shown rather than silently discarding whichever part didn't make it. + self.saveRowPermissions = async (row) => { + if (!deps.apiUrl || !row.canManagePermissions || row.savingPermissions()) return; + if (row.hqEditPicker && !row.hqEditPicker.selected()) { + row.permissionsError('An HQ is required.'); + return; + } + + row.permissionsError(''); + row.savingPermissions(true); + try { + const permissions = { + markerMode: row.editMarkerMode(), + deleteMode: row.editDeleteMode(), + commentMode: row.editCommentMode(), + }; + const [savedPermissions, savedAttachment, savedModerators] = await Promise.all([ + updateCollabLayerPermissions(root, deps.apiUrl, row.layer.id, permissions, deps.getToken), + updateCollabLayerAttachment(root, deps.apiUrl, row.layer.id, { + hq: row.hqEditPicker.selected(), + event: row.eventEditPicker.selected(), + }, deps.getToken), + updateCollabLayerModerators(root, deps.apiUrl, row.layer.id, row.moderatorPicker.moderators(), deps.getToken), + ]); + if (savedPermissions == null || savedAttachment == null || savedModerators == null) throw new Error('Update failed'); + // All three update calls mutate row.layer's 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, HQ/event labels and + // moderator badges) reflects the new values 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 changes. 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. + 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); @@ -750,10 +1636,11 @@ export function ConfigVM(root, deps) { try { const savedConfig = buildConfig(); + const token = await deps.getToken(); const res = await fetch(FUNCTION_URL, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify({ config: savedConfig }) }); @@ -787,10 +1674,11 @@ export function ConfigVM(root, deps) { try { // Adjust to match your handler: expects ?id=... const url = `${FUNCTION_URL}?id=${encodeURIComponent(id)}`; + const token = await deps.getToken(); const res = await fetch(url, { method: "GET", - headers: { "Accept": "application/json" } + headers: { "Accept": "application/json", "Authorization": `Bearer ${token}` } }); if (!res.ok) { diff --git a/src/pages/tasking/viewmodels/InstantTask.js b/src/pages/tasking/viewmodels/InstantTask.js index 8f1d2548..fe6d30da 100644 --- a/src/pages/tasking/viewmodels/InstantTask.js +++ b/src/pages/tasking/viewmodels/InstantTask.js @@ -310,7 +310,7 @@ export class InstantTaskViewModel { const controller = new AbortController(); this._routeAbort = controller; - batchRoute(pairs, { signal: controller.signal }) + batchRoute(pairs, { signal: controller.signal, getToken: this.config?.getToken }) .then(results => { if (controller.signal.aborted) return; diff --git a/src/pages/tasking/viewmodels/JobPopUp.js b/src/pages/tasking/viewmodels/JobPopUp.js index d01e5d84..a5e58c56 100644 --- a/src/pages/tasking/viewmodels/JobPopUp.js +++ b/src/pages/tasking/viewmodels/JobPopUp.js @@ -50,7 +50,7 @@ export class JobPopupViewModel { - drawRouteToAsset = (tasking) => { + drawRouteToAsset = async (tasking) => { const from = tasking.getTeamLatLng(); const to = tasking.getJobLatLng(); if (!from || !to) { @@ -58,9 +58,11 @@ export class JobPopupViewModel { return; } + const token = await this.api.getToken(); const router = new AmazonLocationRouter({ - serviceUrl: "https://lambda.lighthouse-extension.com/lad/route", + serviceUrl: "https://lambda.lighthouse-extension.com/lad_v2/route", travelMode: "Car", + headers: { Authorization: `Bearer ${token}` }, }); this.routeLoading(true); diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index d4d9b3f8..90d246e2 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -514,6 +514,13 @@ export function MapVM(Lmap, root) { visibleByDefault: opts.visibleByDefault === true, fetchFn: opts.fetchFn, drawFn: opts.drawFn, + // Optional () => boolean. When it returns true, a poll tick is + // skipped entirely rather than fetch+redrawing -- for layers whose + // drawFn rebuilds every marker from scratch (clearLayers()), redrawing + // out from under an open popup would silently close it, e.g. while a + // user is mid-edit. See mapLayers/collabLayer.js for the only current + // user of this. + skipIfBusy: opts.skipIfBusy, timerId: null, menuGroup: opts.menuGroup || null, }; @@ -522,6 +529,7 @@ export function MapVM(Lmap, root) { // Only fetch/draw if the layer is actually on the map async function refreshIfVisible() { if (!self.map.hasLayer(layerGroup)) return; + if (entry.skipIfBusy?.()) return; try { const data = await entry.fetchFn(); @@ -552,6 +560,15 @@ export function MapVM(Lmap, root) { }; + /** Tear down a polling overlay layer registered via registerPollingLayer -- stops its timer, removes it from the map, and drops it from the registry entirely (unlike toggling visibility, which just removes/re-adds the same layerGroup). */ + self.unregisterPollingLayer = function (key) { + const entry = self.onlineLayers.get(key); + if (!entry) return; + if (entry.timerId) clearInterval(entry.timerId); + if (entry.layerGroup && self.map.hasLayer(entry.layerGroup)) self.map.removeLayer(entry.layerGroup); + self.onlineLayers.delete(key); + }; + self.refreshPollingLayer = function (key) { const entry = self.onlineLayers.get(key); if (!entry) return; @@ -559,6 +576,7 @@ export function MapVM(Lmap, root) { async function run() { // bail if layer is not currently visible on the map if (!self.map.hasLayer(entry.layerGroup)) return; + if (entry.skipIfBusy?.()) return; try { const data = await entry.fetchFn(); @@ -612,12 +630,16 @@ export function MapVM(Lmap, root) { label: entry.label || k, layer: entry.layerGroup, group: entry.menuGroup || null, + visibleByDefault: entry.visibleByDefault, }); } } 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); @@ -910,6 +932,8 @@ export function MapVM(Lmap, root) { }); const PopupStuff = { + getToken: () => root.getToken(), + flyToBounds: (bounds, { opts }) => { self._flyingToBounds = true; self.map.flyToBounds(bounds, opts); 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.js b/src/shared/BeaconClient.js index 60260ba3..461b3f73 100644 --- a/src/shared/BeaconClient.js +++ b/src/shared/BeaconClient.js @@ -21,11 +21,14 @@ 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'; +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 }; +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, 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/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/src/shared/BeaconClient/operationslog.js b/src/shared/BeaconClient/operationslog.js index 7d075738..f8272f13 100644 --- a/src/shared/BeaconClient/operationslog.js +++ b/src/shared/BeaconClient/operationslog.js @@ -32,11 +32,11 @@ export function get(entryId, host, userId = 'notPassed', token, callback) { cache: false, dataType: 'json', complete: function(response, textStatus) { + if (typeof callback !== "function") return; if (textStatus == 'success') { - let results = response.responseJSON; - if (typeof callback === "function") { - callback(results); - } + callback(response.responseJSON); + } else { + callback(null); } } }) 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/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 106bb642..d3fb6b89 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -2701,13 +2701,17 @@

aria-labelledby="headingMapLayers" data-bs-parent="#configOtherSettingsAccordion">
+ +
+ Display Settings +
- Marker Clustering + Incident Marker Clustering
@@ -2766,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. +
    @@ -2784,6 +2791,424 @@

+ +
+ +
+
+ Collaborative Layers +
+ +
+

+ Shared marker layers that everyone in your organisation can see and + 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
+
+ + +
+
+ + +
+
+ + +
+
Delete permissions
+
+ + +
+
+ + +
+
+ + +
+
Comment permissions
+
+ + +
+
+ + +
+
+ + +
+ +
+
Moderators
+
+ + + +
+
+ + + + +
+
+
+
+
+
+ + +
+
+ + + + + + +
+
+
Search HQ (optional — leave blank to search every HQ)
+ +
+ + +
+ + + + + + + + +
+
+ + +
+
    +
  • +
    +
    +
    + +
    +
    + by · + markers · + used + + + +
    +
    + + + Subscribed + +
  • +
+
+
+
+ + +
+ + +
+ +
+
    +
  • +
    +
    +
    +
    + +
    +
    + by · + markers · + used + + + + + + +
    +
    + + + + + + + + +
    +
  • +
+
+
+
+
+
+

@@ -3033,6 +3458,218 @@

+ + +