From 166b7481766c5a90c1964b9242dedca8d3a866c9 Mon Sep 17 00:00:00 2001 From: officialbones Date: Tue, 21 Jul 2026 08:08:12 -0400 Subject: [PATCH] Add call-nature force expiry and system-admin map pin controls Call natures gain a per-category expireMinutes setting (0 = never). Incidents whose nature has a force-expire value drop off the live map that many minutes after dispatch, overriding the viewer's time range. Disabled categories are inert; blank natures shown as UNKNOWN PROBLEM expire with the UNKNOWN PROBLEM category. Values are clamped 0-10080 server-side and a since-less /api/incidents request is floored to a 31-day scan window so expiry cannot force a full-history table scan. System admins (and the admin console) can correct a pin's address, nature, and location, or remove the plot, from the incident popup. Corrections use click-to-place on the map and persist with manual/admin status so they survive the map filter. Backed by PUT/DELETE /api/incidents/pin/{callId}, gated to system-admin users. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 ++ .../rdio-scanner/admin/admin.service.ts | 1 + .../call-natures/call-natures.component.html | 20 +++ .../call-natures/call-natures.component.scss | 35 ++++ .../call-natures/call-natures.component.ts | 17 ++ .../incident-map/incident-map.component.html | 38 ++++ .../incident-map/incident-map.component.scss | 129 +++++++++++++ .../incident-map/incident-map.component.ts | 170 ++++++++++++++++++ .../incident-map/incidents.service.ts | 24 +++ server/api_call_natures.go | 34 +++- server/api_incident_pin.go | 140 +++++++++++++++ server/call_natures.go | 36 ++-- server/main.go | 1 + server/mapping_api.go | 27 +++ server/mapping_store.go | 31 ++++ 15 files changed, 698 insertions(+), 22 deletions(-) create mode 100644 server/api_incident_pin.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 57eb0f19..b684200d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Change log +## Unreleased + +### Added + +- **Call natures — per-category force expiry** + - Each call-nature category now has a **Force expire (minutes)** setting (`0` = never), edited from the admin **Options → Incident Mapping → Call Natures** section. + - Incidents in a category with a force-expire value are removed from the live incident map that many minutes after dispatch, **overriding** the viewer's selected time range. + - Category cards show an **Expires *N* min** badge. Disabled categories never expire incidents. Blank natures (shown on the map as `UNKNOWN PROBLEM`) expire when the `UNKNOWN PROBLEM` category has a force-expire value. + - Value is clamped server-side to `0–10080` (7 days); a `since`-less `/api/incidents` request is floored to a 31-day scan window so expiry cannot turn it into a full-history table scan. + +- **Incident map — system-admin pin corrections and removal** + - System admins (and the admin console) can **correct** a pin's address, nature, and location, or **remove** the plot entirely, from the incident popup on the map. + - **Correct Pin** opens an inline editor with **click-to-place** repositioning on the map; **Remove Pin** clears the map plot while keeping the call and its audio. + - Backed by `PUT`/`DELETE /api/incidents/pin/{callId}`, gated to system-admin users (or the admin token); corrections are stored with `manual`/`admin` status so they survive the map filter and are distinguishable from geocoded pins. + +--- + ## Version 26.07.23 - Released July 20, 2026 ### Fixed diff --git a/client/src/app/components/rdio-scanner/admin/admin.service.ts b/client/src/app/components/rdio-scanner/admin/admin.service.ts index 74187d75..b64990a1 100644 --- a/client/src/app/components/rdio-scanner/admin/admin.service.ts +++ b/client/src/app/components/rdio-scanner/admin/admin.service.ts @@ -168,6 +168,7 @@ export interface CallNature { phrases?: string[]; enabled?: boolean; order?: number; + expireMinutes?: number; createdAt?: number; } diff --git a/client/src/app/components/rdio-scanner/admin/config/call-natures/call-natures.component.html b/client/src/app/components/rdio-scanner/admin/config/call-natures/call-natures.component.html index fe0ccd9b..c6da5de4 100644 --- a/client/src/app/components/rdio-scanner/admin/config/call-natures/call-natures.component.html +++ b/client/src/app/components/rdio-scanner/admin/config/call-natures/call-natures.component.html @@ -1,6 +1,7 @@

Call natures are the incident categories shown on call cards. Add transcript phrases under each category for automatic matching. + A category can also force-expire its incidents off the live map after a set number of minutes, even when the viewer's selected time range would still include them.

+
+ + Force expire (minutes) + + 0 = never expire + Whole number between 0 and 10080 (7 days) + +

+ Removes this category's incidents from the live map after this many minutes, overriding the viewer's time range. + Disabled categories never expire incidents. +

+
+
diff --git a/client/src/app/components/rdio-scanner/incident-map/incident-map.component.scss b/client/src/app/components/rdio-scanner/incident-map/incident-map.component.scss index c0c2ac34..284c65c2 100644 --- a/client/src/app/components/rdio-scanner/incident-map/incident-map.component.scss +++ b/client/src/app/components/rdio-scanner/incident-map/incident-map.component.scss @@ -282,6 +282,82 @@ select.toolbar-control { height: 100%; overflow: hidden; overscroll-behavior: none; + position: relative; +} + +// System-admin pin correction panel (floats over the map canvas). +.pin-edit-panel { + position: absolute; + top: 12px; + right: 12px; + z-index: 1100; + width: 280px; + background: rgba(18, 18, 18, 0.96); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.pin-edit-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #e0e0e0; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + color: #d9a441; + } +} + +.pin-edit-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: #999; + + input { + width: 100%; + box-sizing: border-box; + } +} + +.pin-edit-coords { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pin-edit-coords__value { + font-size: 11.5px; + color: #bbb; + font-variant-numeric: tabular-nums; +} + +.pin-edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.pin-edit-save { + background: #cc0000; + border-color: #cc0000; + color: #fff; + + &[disabled] { + opacity: 0.6; + } } .incident-map-layout__sidebar { @@ -332,6 +408,59 @@ select.toolbar-control { } } +// Admin actions inside the Leaflet popup (rendered outside Angular's view). +:host ::ng-deep .tlr-map-info__admin { + display: flex; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +:host ::ng-deep .tlr-map-info__admin-btn { + flex: 1; + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid rgba(217, 164, 65, 0.55); + background: transparent; + color: #d9a441; + cursor: pointer; + + &:hover { + background: rgba(217, 164, 65, 0.15); + } + + &--danger { + border-color: rgba(204, 0, 0, 0.65); + color: #e06666; + + &:hover { + background: rgba(204, 0, 0, 0.15); + } + } +} + +// Crosshair cursor while placing a corrected pin location. +:host ::ng-deep .pin-move-armed { + cursor: crosshair !important; +} + +// Temporary marker showing the corrected position before saving. +:host ::ng-deep .tlr-pin-edit-marker { + background: transparent !important; + border: 0 !important; +} + +:host ::ng-deep .tlr-pin-edit-marker__dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(217, 164, 65, 0.35); + border: 2px solid #d9a441; + box-shadow: 0 0 8px rgba(217, 164, 65, 0.8); +} + // Leaflet custom incident pins :host ::ng-deep .tlr-incident-pin-icon { background: transparent !important; diff --git a/client/src/app/components/rdio-scanner/incident-map/incident-map.component.ts b/client/src/app/components/rdio-scanner/incident-map/incident-map.component.ts index 74749b28..5d2642e9 100644 --- a/client/src/app/components/rdio-scanner/incident-map/incident-map.component.ts +++ b/client/src/app/components/rdio-scanner/incident-map/incident-map.component.ts @@ -63,6 +63,12 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After /** Stable deduped list for sidebar, markers, and click handlers. */ private dedupedFilteredIncidents: IncidentRecord[] = []; + /** System-admin pin correction state (panel over the map). */ + pinEdit: { incident: IncidentRecord; address: string; nature: string; lat: number; lon: number } | null = null; + pinEditSaving = false; + movePinArmed = false; + private pinEditMarker: L.Marker | null = null; + readonly mapStyleOptions: IncidentMapStyleOption[] = [ { id: 'voyager', label: 'Voyager' }, { id: 'dark', label: 'Dark' }, @@ -225,6 +231,7 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After clearTimeout(this.markerRenderTimer); this.markerRenderTimer = null; } + this.removePinEditMarker(); if (this.map) { this.map.remove(); this.map = null; @@ -240,6 +247,11 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After return this.dedupedFilteredIncidents; } + /** Gates the map's pin correction/removal controls. */ + get isSystemAdmin(): boolean { + return this.rdioScannerService.isSystemAdmin(); + } + /** Raw filtered rows before location dedupe (for search across all channels). */ private filteredIncidentsRaw(): IncidentRecord[] { return this.applyIncidentFilters(this.incidents); @@ -933,6 +945,21 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After this.scheduleBoundaryRefresh(); this.scheduleMarkerRender(); }); + this.map.on('click', (e: L.LeafletMouseEvent) => { + if (!this.movePinArmed || !this.pinEdit) { + return; + } + this.ngZone.run(() => { + if (!this.pinEdit) { + return; + } + this.pinEdit.lat = Number(e.latlng.lat.toFixed(6)); + this.pinEdit.lon = Number(e.latlng.lng.toFixed(6)); + this.disarmMovePin(); + this.updatePinEditMarker(); + this.cdr.markForCheck(); + }); + }); this.map.on('moveend', () => { this.radarMapMoving = false; this.markersHiddenDuringMove = false; @@ -1115,6 +1142,12 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After if (this.selectedIncident) { const selectedId = this.selectedIncident.callId; this.selectedIncident = this.resolveIncident(selectedId); + if (!this.selectedIncident) { + // The incident aged out (e.g. call-nature force expiry) + // — its standalone popup is not bound to the marker, so + // close it or it floats over an empty spot. + this.map?.closePopup(); + } } this.scheduleMarkerRender(); this.cdr.markForCheck(); @@ -1345,10 +1378,18 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After ? `` : ''; + const adminActions = this.isSystemAdmin + ? `
+ + +
` + : ''; + const html = `
${this.escapeHtml(address)}
${multi ? `
${segmentHtml}
` : `${legacyNature}${legacyChannel}${legacyTime}${legacyTranscript}${legacyPlay}`} + ${adminActions}
`; const popupMaxWidth = multi ? Math.min(920, segments.length * 252 + 48) : 360; L.popup({ maxWidth: popupMaxWidth, className: popupClass, autoPan: false }) @@ -1362,9 +1403,138 @@ export class RdioScannerIncidentMapComponent implements OnInit, OnDestroy, After this.ngZone.run(() => this.playIncident(seg)); }); } + document.getElementById(`tlr-map-edit-${incident.callId}`)?.addEventListener('click', () => { + this.ngZone.run(() => this.startPinEdit(incident)); + }); + document.getElementById(`tlr-map-remove-${incident.callId}`)?.addEventListener('click', () => { + this.ngZone.run(() => this.removePin(incident)); + }); }, 0); } + /** Opens the admin correction panel seeded from the incident. */ + startPinEdit(incident: IncidentRecord): void { + if (!this.isSystemAdmin) { + return; + } + this.pinEdit = { + incident, + address: incident.address || '', + nature: incident.nature || '', + lat: Number(incident.lat) || 0, + lon: Number(incident.lon) || 0, + }; + this.movePinArmed = false; + this.map?.closePopup(); + this.updatePinEditMarker(); + this.cdr.markForCheck(); + } + + toggleMovePin(): void { + this.movePinArmed = !this.movePinArmed; + const canvas = document.getElementById('tlr-incident-map-canvas'); + canvas?.classList.toggle('pin-move-armed', this.movePinArmed); + this.cdr.markForCheck(); + } + + cancelPinEdit(): void { + this.pinEdit = null; + this.pinEditSaving = false; + this.disarmMovePin(); + this.removePinEditMarker(); + this.cdr.markForCheck(); + } + + savePinEdit(): void { + const edit = this.pinEdit; + if (!edit || this.pinEditSaving) { + return; + } + const changes: { address?: string; nature?: string; lat?: number; lon?: number } = { + address: edit.address.trim(), + nature: edit.nature.trim(), + }; + if (edit.lat !== 0 || edit.lon !== 0) { + changes.lat = edit.lat; + changes.lon = edit.lon; + } + this.pinEditSaving = true; + const pin = this.rdioScannerService.readPin(); + this.incidentsService.correctIncidentPin(edit.incident.callId, changes, pin || undefined).subscribe({ + next: () => { + this.snackBar.open('Pin corrected', '', { duration: 2500 }); + this.cancelPinEdit(); + this.refreshIncidents(); + }, + error: (err) => { + this.pinEditSaving = false; + this.snackBar.open(this.pinAdminErrorMessage(err, 'Failed to correct pin'), '', { duration: 4000 }); + this.cdr.markForCheck(); + }, + }); + } + + /** Removes an incident's pin from the map after confirmation. */ + removePin(incident: IncidentRecord): void { + if (!this.isSystemAdmin) { + return; + } + const label = this.incidentAddress(incident); + if (!confirm(`Remove the map pin for "${label}"? The call and audio are kept; only the map plot is cleared.`)) { + return; + } + const pin = this.rdioScannerService.readPin(); + this.incidentsService.removeIncidentPin(incident.callId, pin || undefined).subscribe({ + next: () => { + this.snackBar.open('Pin removed', '', { duration: 2500 }); + if (this.pinEdit?.incident.callId === incident.callId) { + this.cancelPinEdit(); + } + this.map?.closePopup(); + this.refreshIncidents(); + }, + error: (err) => { + this.snackBar.open(this.pinAdminErrorMessage(err, 'Failed to remove pin'), '', { duration: 4000 }); + }, + }); + } + + private pinAdminErrorMessage(err: unknown, fallback: string): string { + const status = (err as { status?: number })?.status; + if (status === 401 || status === 403) { + return 'System admin sign-in required'; + } + return fallback; + } + + private disarmMovePin(): void { + this.movePinArmed = false; + document.getElementById('tlr-incident-map-canvas')?.classList.remove('pin-move-armed'); + } + + private updatePinEditMarker(): void { + this.removePinEditMarker(); + const edit = this.pinEdit; + const map = this.map; + if (!edit || !map || (edit.lat === 0 && edit.lon === 0)) { + return; + } + const icon = L.divIcon({ + className: 'tlr-pin-edit-marker', + html: '
', + iconSize: [18, 18], + iconAnchor: [9, 9], + }); + this.pinEditMarker = L.marker([edit.lat, edit.lon], { icon, zIndexOffset: 2000 }).addTo(map); + } + + private removePinEditMarker(): void { + if (this.pinEditMarker) { + this.pinEditMarker.remove(); + this.pinEditMarker = null; + } + } + private scrollIncidentIntoView(callId: number): void { requestAnimationFrame(() => { const el = document.querySelector(`[data-incident-call-id="${callId}"]`) as HTMLElement | null; diff --git a/client/src/app/components/rdio-scanner/incident-map/incidents.service.ts b/client/src/app/components/rdio-scanner/incident-map/incidents.service.ts index 9090cde9..70e92f23 100644 --- a/client/src/app/components/rdio-scanner/incident-map/incidents.service.ts +++ b/client/src/app/components/rdio-scanner/incident-map/incidents.service.ts @@ -40,6 +40,30 @@ export class IncidentsService { ); } + /** System-admin correction of an incident pin (address, nature, position). */ + correctIncidentPin( + callId: number, + changes: { address?: string; nature?: string; lat?: number; lon?: number }, + pin?: string, + ): Observable { + let url = `${this.getFullUrl('/api/incidents/pin/')}${callId}`; + if (pin) { + url += `?pin=${encodeURIComponent(pin)}`; + } + const headers = pin ? new HttpHeaders().set('Authorization', `Bearer ${pin}`) : undefined; + return this.http.put(url, changes, { headers }); + } + + /** System-admin removal of an incident pin from the map. */ + removeIncidentPin(callId: number, pin?: string): Observable { + let url = `${this.getFullUrl('/api/incidents/pin/')}${callId}`; + if (pin) { + url += `?pin=${encodeURIComponent(pin)}`; + } + const headers = pin ? new HttpHeaders().set('Authorization', `Bearer ${pin}`) : undefined; + return this.http.delete(url, { headers }); + } + getMapBoundaries( west: number, south: number, diff --git a/server/api_call_natures.go b/server/api_call_natures.go index 1c8c9107..f74059e2 100644 --- a/server/api_call_natures.go +++ b/server/api_call_natures.go @@ -51,6 +51,7 @@ func (api *Api) CallNaturesHandler(w http.ResponseWriter, r *http.Request) { phrases = []string{label} } order := uintFromAny(body["order"]) + expireMinutes := clampCallNatureExpireMinutes(uintFromAny(body["expireMinutes"])) enabled := true if v, ok := body["enabled"].(bool); ok { enabled = v @@ -58,16 +59,16 @@ func (api *Api) CallNaturesHandler(w http.ResponseWriter, r *http.Request) { phrasesJSON, _ := json.Marshal(phrases) var id int64 err := api.Controller.Database.Sql.QueryRow( - `INSERT INTO "callNatures" ("label", "phrases", "enabled", "order", "createdAt") - VALUES ($1, $2, $3, $4, $5) RETURNING "callNatureId"`, - label, string(phrasesJSON), enabled, order, time.Now().UnixMilli(), + `INSERT INTO "callNatures" ("label", "phrases", "enabled", "order", "expireMinutes", "createdAt") + VALUES ($1, $2, $3, $4, $5, $6) RETURNING "callNatureId"`, + label, string(phrasesJSON), enabled, order, expireMinutes, time.Now().UnixMilli(), ).Scan(&id) if err != nil { api.exitWithError(w, http.StatusInternalServerError, fmt.Sprintf("insert failed: %v", err)) return } _ = api.Controller.CallNaturesCache.Read(api.Controller.Database) - row := api.Controller.Database.Sql.QueryRow(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "createdAt" + row := api.Controller.Database.Sql.QueryRow(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "expireMinutes", "createdAt" FROM "callNatures" WHERE "callNatureId" = $1`, id) n, _ := callNatureFromRow(row) w.Header().Set("Content-Type", "application/json") @@ -106,21 +107,22 @@ func (api *Api) CallNatureHandler(w http.ResponseWriter, r *http.Request) { } phrases := sanitizeCallNaturePhrases(stringsFromAnySlice(body["phrases"])) order := uintFromAny(body["order"]) + expireMinutes := clampCallNatureExpireMinutes(uintFromAny(body["expireMinutes"])) enabled := true if v, ok := body["enabled"].(bool); ok { enabled = v } phrasesJSON, _ := json.Marshal(phrases) _, err := api.Controller.Database.Sql.Exec( - `UPDATE "callNatures" SET "label" = $1, "phrases" = $2, "enabled" = $3, "order" = $4 WHERE "callNatureId" = $5`, - label, string(phrasesJSON), enabled, order, id, + `UPDATE "callNatures" SET "label" = $1, "phrases" = $2, "enabled" = $3, "order" = $4, "expireMinutes" = $5 WHERE "callNatureId" = $6`, + label, string(phrasesJSON), enabled, order, expireMinutes, id, ) if err != nil { api.exitWithError(w, http.StatusInternalServerError, fmt.Sprintf("update failed: %v", err)) return } _ = api.Controller.CallNaturesCache.Read(api.Controller.Database) - row := api.Controller.Database.Sql.QueryRow(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "createdAt" + row := api.Controller.Database.Sql.QueryRow(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "expireMinutes", "createdAt" FROM "callNatures" WHERE "callNatureId" = $1`, id) n, _ := callNatureFromRow(row) w.Header().Set("Content-Type", "application/json") @@ -171,15 +173,33 @@ func sanitizeCallNaturePhrases(phrases []string) []string { } func uintFromAny(v any) uint { + // Cap at int4 max: every column these values land in is a Postgres + // integer, and float64→uint conversion above that range is undefined. + const maxInt4 = 2147483647 switch n := v.(type) { case float64: + if n >= maxInt4 { + return maxInt4 + } if n >= 0 { return uint(n) } case int: + if n >= maxInt4 { + return maxInt4 + } if n >= 0 { return uint(n) } } return 0 } + +// clampCallNatureExpireMinutes mirrors the admin UI bound (7 days). +func clampCallNatureExpireMinutes(v uint) uint { + const max = 10080 + if v > max { + return max + } + return v +} diff --git a/server/api_incident_pin.go b/server/api_incident_pin.go new file mode 100644 index 00000000..e2b718aa --- /dev/null +++ b/server/api_incident_pin.go @@ -0,0 +1,140 @@ +// Copyright (C) 2026 Thinline Dynamic Solutions + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "rdio-scanner/server/mapping" +) + +// canManageIncidentPins reports whether the request comes from the admin +// console (admin token) or a system-admin user session. +func (api *Api) canManageIncidentPins(client *Client) bool { + if api.isAdmin(client) { + return true + } + return client != nil && client.User != nil && client.User.SystemAdmin +} + +// IncidentPinHandler handles PUT/DELETE /api/incidents/pin/{callId} — system +// admins correcting or removing incident pins on the live map. +func (api *Api) IncidentPinHandler(w http.ResponseWriter, r *http.Request) { + client := api.getClient(r) + if client == nil || (client.User == nil && !client.IsAdmin) { + api.exitWithError(w, http.StatusUnauthorized, "unauthorized") + return + } + if !api.canManageIncidentPins(client) { + api.exitWithError(w, http.StatusForbidden, "system admin access required") + return + } + idStr := strings.TrimPrefix(r.URL.Path, "/api/incidents/pin/") + callId, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 64) + if err != nil || callId == 0 { + api.exitWithError(w, http.StatusBadRequest, "invalid call id") + return + } + call, err := api.Controller.Calls.GetCall(callId) + if err != nil || call == nil { + api.exitWithError(w, http.StatusNotFound, "call not found") + return + } + store := NewMappingStore(api.Controller.Database) + + switch r.Method { + case http.MethodDelete: + if err := store.ClearCallIncident(callId); err != nil { + api.exitWithError(w, http.StatusInternalServerError, fmt.Sprintf("clear failed: %v", err)) + return + } + api.Controller.Logs.LogEvent(LogLevelInfo, fmt.Sprintf("incident pin for call %d removed by %s", callId, pinAdminActor(client))) + api.Controller.IncidentMappingQueue.broadcastIncidentUpdate(call, nil, "skipped") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": "removed"}) + + case http.MethodPut: + var body struct { + Address *string `json:"address"` + Nature *string `json:"nature"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + api.exitWithError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Address == nil && body.Nature == nil && body.Lat == nil && body.Lon == nil { + api.exitWithError(w, http.StatusBadRequest, "nothing to update") + return + } + if (body.Lat == nil) != (body.Lon == nil) { + api.exitWithError(w, http.StatusBadRequest, "lat and lon must be provided together") + return + } + if body.Lat != nil { + if *body.Lat < -90 || *body.Lat > 90 || *body.Lon < -180 || *body.Lon > 180 { + api.exitWithError(w, http.StatusBadRequest, "coordinates out of range") + return + } + if *body.Lat == 0 && *body.Lon == 0 { + api.exitWithError(w, http.StatusBadRequest, "coordinates cannot both be zero") + return + } + } + if body.Address != nil { + trimmed := strings.TrimSpace(*body.Address) + body.Address = &trimmed + } + if body.Nature != nil { + upper := strings.ToUpper(strings.TrimSpace(*body.Nature)) + body.Nature = &upper + } + if err := store.CorrectCallIncident(callId, body.Address, body.Nature, body.Lat, body.Lon); err != nil { + api.exitWithError(w, http.StatusInternalServerError, fmt.Sprintf("update failed: %v", err)) + return + } + api.Controller.Logs.LogEvent(LogLevelInfo, fmt.Sprintf("incident pin for call %d corrected by %s", callId, pinAdminActor(client))) + + // Broadcast the persisted values so open maps and alert cards refresh. + var ( + addr, nature string + lat, lon float64 + ) + err := api.Controller.Database.Sql.QueryRow( + `SELECT "incidentAddress", "incidentNature", "incidentLat", "incidentLon" FROM "calls" WHERE "callId" = $1`, + callId, + ).Scan(&addr, &nature, &lat, &lon) + if err == nil { + primary := &mapping.CuratedAlert{ + Address: addr, + NatureDesc: nature, + Lat: fmt.Sprintf("%.6f", lat), + Lon: fmt.Sprintf("%.6f", lon), + } + api.Controller.IncidentMappingQueue.broadcastIncidentUpdate(call, primary, "manual") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "corrected", + "address": addr, + "nature": nature, + "lat": lat, + "lon": lon, + }) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func pinAdminActor(client *Client) string { + if client != nil && client.User != nil { + return fmt.Sprintf("system admin %s", client.User.Email) + } + return "admin console" +} diff --git a/server/call_natures.go b/server/call_natures.go index 9ed9598e..28006c78 100644 --- a/server/call_natures.go +++ b/server/call_natures.go @@ -15,12 +15,16 @@ import ( // CallNature is a dispatch incident category with optional transcript phrases // that map transcripts to the canonical label shown on call cards. type CallNature struct { - Id uint64 - Label string - Phrases []string - Enabled bool - Order uint - CreatedAt int64 + Id uint64 + Label string + Phrases []string + Enabled bool + Order uint + // ExpireMinutes force-expires this category's incidents off the live map + // that many minutes after dispatch, overriding the viewer's selected time + // range. Zero means incidents never force-expire. + ExpireMinutes uint + CreatedAt int64 } // CallNatureMatchData is the flattened catalog passed into the mapping pipeline. @@ -45,7 +49,7 @@ func (cache *CallNaturesCache) Read(db *Database) error { if db == nil || db.Sql == nil { return fmt.Errorf("database unavailable") } - rows, err := db.Sql.Query(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "createdAt" + rows, err := db.Sql.Query(`SELECT "callNatureId", "label", "phrases", "enabled", "order", "expireMinutes", "createdAt" FROM "callNatures" ORDER BY "order" ASC, "label" ASC`) if err != nil { return err @@ -59,7 +63,7 @@ func (cache *CallNaturesCache) Read(db *Database) error { phrasesJSON string enabled bool ) - if err := rows.Scan(&n.Id, &n.Label, &phrasesJSON, &enabled, &n.Order, &n.CreatedAt); err != nil { + if err := rows.Scan(&n.Id, &n.Label, &phrasesJSON, &enabled, &n.Order, &n.ExpireMinutes, &n.CreatedAt); err != nil { continue } n.Enabled = enabled @@ -133,6 +137,7 @@ func migrateCallNatures(db *Database) error { "createdAt" bigint NOT NULL DEFAULT 0 )`, `CREATE UNIQUE INDEX IF NOT EXISTS "callNatures_label_uidx" ON "callNatures" ("label")`, + `ALTER TABLE "callNatures" ADD COLUMN IF NOT EXISTS "expireMinutes" integer NOT NULL DEFAULT 0`, } for _, q := range queries { if _, err := db.Sql.Exec(q); err != nil { @@ -173,7 +178,7 @@ func callNatureFromRow(row *sql.Row) (*CallNature, error) { phrasesJSON string enabled bool ) - if err := row.Scan(&n.Id, &n.Label, &phrasesJSON, &enabled, &n.Order, &n.CreatedAt); err != nil { + if err := row.Scan(&n.Id, &n.Label, &phrasesJSON, &enabled, &n.Order, &n.ExpireMinutes, &n.CreatedAt); err != nil { return nil, err } n.Enabled = enabled @@ -189,11 +194,12 @@ func callNatureToJSON(n *CallNature) map[string]any { return nil } return map[string]any{ - "id": n.Id, - "label": n.Label, - "phrases": n.Phrases, - "enabled": n.Enabled, - "order": n.Order, - "createdAt": n.CreatedAt, + "id": n.Id, + "label": n.Label, + "phrases": n.Phrases, + "enabled": n.Enabled, + "order": n.Order, + "expireMinutes": n.ExpireMinutes, + "createdAt": n.CreatedAt, } } diff --git a/server/main.go b/server/main.go index cfde4e16..11834589 100644 --- a/server/main.go +++ b/server/main.go @@ -579,6 +579,7 @@ func main() { http.HandleFunc("/api/alerts", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.AlertsHandler))).ServeHTTP) http.HandleFunc("/api/alerts/preferences", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.AlertPreferencesHandler))).ServeHTTP) http.HandleFunc("/api/incidents", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.IncidentsHandler))).ServeHTTP) + http.HandleFunc("/api/incidents/pin/", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.IncidentPinHandler))).ServeHTTP) http.HandleFunc("/api/map/boundaries", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.MapBoundariesHandler))).ServeHTTP) http.HandleFunc("/api/map/tiles/", tileWrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.MapTilesHandler))).ServeHTTP) http.HandleFunc("/api/stats", wrapHandler(corsMiddleware(http.HandlerFunc(controller.Api.StatsHandler))).ServeHTTP) diff --git a/server/mapping_api.go b/server/mapping_api.go index 451c7120..dd386897 100644 --- a/server/mapping_api.go +++ b/server/mapping_api.go @@ -436,6 +436,20 @@ func (api *Api) IncidentsHandler(w http.ResponseWriter, r *http.Request) { if v := r.URL.Query().Get("until"); v != "" { until, _ = strconv.ParseInt(v, 10, 64) } + nowMs := time.Now().UnixMilli() + // The expiry predicate below makes rows older than a nature's window + // permanently unmatchable, so a since-less request would walk the whole + // timestamp index without ever filling LIMIT on large installs. Floor the + // scan window when the caller omits "since" (the shipped map always sends + // one). + const maxIncidentScanWindowMs = int64(31) * 24 * 60 * 60 * 1000 + if since <= 0 { + if until > 0 { + since = until - maxIncidentScanWindowMs + } else { + since = nowMs - maxIncidentScanWindowMs + } + } // Nature is optional: geocoded address-only pins must still appear on the // map (client labels blank nature as "UNKNOWN PROBLEM"). where := `WHERE c."incidentLat" <> 0 AND c."incidentLon" <> 0 @@ -446,6 +460,19 @@ func (api *Api) IncidentsHandler(w http.ResponseWriter, r *http.Request) { if until > 0 { where += fmt.Sprintf(` AND c."timestamp" <= %d`, until) } + // Call-nature force expiry: an enabled category with expireMinutes > 0 + // removes its incidents from the map that many minutes after dispatch, no + // matter what time range the viewer selected. Disabled categories are inert + // here, matching MatchData. Blank natures render as UNKNOWN PROBLEM on the + // map, so an expiry on that category covers them too (same equivalence as + // SuppressUnknownNaturePins). + where += fmt.Sprintf(` AND NOT EXISTS ( + SELECT 1 FROM "callNatures" n + WHERE n."enabled" = true AND n."expireMinutes" > 0 + AND (UPPER(n."label") = UPPER(c."incidentNature") + OR (c."incidentNature" = '' AND UPPER(n."label") = 'UNKNOWN PROBLEM')) + AND c."timestamp" + n."expireMinutes"::bigint * 60000 <= %d + )`, nowMs) query := fmt.Sprintf(`SELECT c."callId", c."systemId", c."talkgroupId", c."timestamp", c."incidentAddress", c."incidentCrossStreet1", c."incidentCrossStreet2", c."incidentNature", c."incidentCommonName", c."incidentLat", c."incidentLon", diff --git a/server/mapping_store.go b/server/mapping_store.go index 9fbc5154..dad38fbd 100644 --- a/server/mapping_store.go +++ b/server/mapping_store.go @@ -456,6 +456,37 @@ func (ms *MappingStore) SaveCallIncident(callId uint64, primary *mapping.Curated return err } +// CorrectCallIncident applies a system-admin correction to a call's incident +// card. Only non-nil fields are written; status/source become manual/admin so +// the pin survives the map query filter and is distinguishable from geocoding. +func (ms *MappingStore) CorrectCallIncident(callId uint64, address, nature *string, lat, lon *float64) error { + sets := []string{`"incidentGeocodeStatus" = 'manual'`, `"incidentGeocodeSource" = 'admin'`} + args := []any{} + idx := 1 + add := func(clause string, v any) { + sets = append(sets, fmt.Sprintf(clause, idx)) + args = append(args, v) + idx++ + } + add(`"incidentMappingProcessedAt" = $%d`, time.Now().UnixMilli()) + if address != nil { + add(`"incidentAddress" = $%d`, *address) + } + if nature != nil { + add(`"incidentNature" = $%d`, *nature) + } + if lat != nil { + add(`"incidentLat" = $%d`, *lat) + } + if lon != nil { + add(`"incidentLon" = $%d`, *lon) + } + args = append(args, callId) + query := fmt.Sprintf(`UPDATE "calls" SET %s WHERE "callId" = $%d`, strings.Join(sets, ", "), idx) + _, err := ms.db.Sql.Exec(query, args...) + return err +} + func (ms *MappingStore) ClearCallIncident(callId uint64) error { now := time.Now().UnixMilli() _, err := ms.db.Sql.Exec(`UPDATE "calls" SET