diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18aeae17..9d6905c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@
### 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.
- **Learned tone patterns — play dispatch audio**
- In **Systems → Talkgroups → Analyze tone history**, each learned tone pattern (e.g. a learned A/B pair) now has a **play/stop** control on its transcript samples so an operator can hear the dispatch tone-out and name the tone set correctly before adding it.
- Patterns whose calls have no transcript (tone-only) get a single **Play dispatch audio** button that plays a representative call.
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 9a4a9969..96924be9 100644
--- a/client/src/app/components/rdio-scanner/admin/admin.service.ts
+++ b/client/src/app/components/rdio-scanner/admin/admin.service.ts
@@ -169,6 +169,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 @@
@@ -77,6 +83,20 @@
Enabled
+
+
+ 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
? `Play Audio `
: '';
+ const adminActions = this.isSystemAdmin
+ ? `
+ Correct Pin
+ Remove Pin
+
`
+ : '';
+
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 a81dd8e4..201c10de 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