Merge pull request 'Incidents search/filter/load-more; Archive viewable by viewers' (#164) from feat/archive-search-viewers into main
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 4s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Successful in 1s

This commit was merged in pull request #164.
This commit is contained in:
2026-09-23 23:54:35 -04:00
4 changed files with 108 additions and 23 deletions
+7 -1
View File
@@ -5,6 +5,7 @@ from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import ( from app.internal.auth import (
require_admin_token, require_admin_token,
require_firebase_token,
require_service_or_firebase_token, require_service_or_firebase_token,
resolve_caller_org_id, resolve_caller_org_id,
reprocess_limiter, reprocess_limiter,
@@ -54,7 +55,7 @@ async def search_calls(
link: str = Query("any", pattern="^(any|orphan|linked)$"), link: str = Query("any", pattern="^(any|orphan|linked)$"),
transcript: str = Query("any", pattern="^(any|yes|no)$"), transcript: str = Query("any", pattern="^(any|yes|no)$"),
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"), q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
decoded: dict = Depends(require_admin_token), decoded: dict = Depends(require_firebase_token),
): ):
""" """
Paged, filterable call archive — the backend for the /calls page. Paged, filterable call archive — the backend for the /calls page.
@@ -72,6 +73,11 @@ async def search_calls(
`window_exhausted` says the scan hit its cap before filling the page, so an `window_exhausted` says the scan hit its cap before filling the page, so an
empty result means "not in this window", not "none exist". empty result means "not in this window", not "none exist".
Open to every org member (viewer included), not just admins: the Firestore
rules already let any member read every call doc in their org
(firestore.rules `calls` → docInMyOrg), so this route exposes nothing a
viewer's browser couldn't already read directly.
""" """
org_id = await resolve_caller_org_id(decoded) org_id = await resolve_caller_org_id(decoded)
if org_id is None: if org_id is None:
+20 -13
View File
@@ -6,8 +6,9 @@
// never correlated was invisible. That is the wrong way round when correlation // never correlated was invisible. That is the wrong way round when correlation
// quality is the thing under development — the orphans are the evidence. // quality is the thing under development — the orphans are the evidence.
// //
// Admin-only, because it exposes every call in the org regardless of node // Readable by every org member — the Firestore rules already let any member
// ownership and carries the manual attribution controls. // read every call in their org. The manual attribution controls stay
// admin-only, matching the admin gate on the link/unlink routes.
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -68,11 +69,13 @@ function ArchiveRow({
call, call,
systemName, systemName,
incidents, incidents,
canEdit,
onChanged, onChanged,
}: { }: {
call: CallRecord; call: CallRecord;
systemName?: string; systemName?: string;
incidents: IncidentRecord[]; incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void; onChanged: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -178,18 +181,18 @@ function ArchiveRow({
<div key={id} className="flex items-center gap-2 text-xs"> <div key={id} className="flex items-center gap-2 text-xs">
<span className="text-ink-muted">attached to</span> <span className="text-ink-muted">attached to</span>
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span> <span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
<button {canEdit && <button
onClick={() => detach(id)} onClick={() => detach(id)}
disabled={busy} disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0" className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
> >
detach detach
</button> </button>}
</div> </div>
); );
})} })}
<div className="flex flex-wrap items-center gap-2"> {canEdit && <div className="flex flex-wrap items-center gap-2">
<select <select
value={attachTo} value={attachTo}
onChange={(e) => setAttachTo(e.target.value)} onChange={(e) => setAttachTo(e.target.value)}
@@ -208,7 +211,7 @@ function ArchiveRow({
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}> <Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"} {busy ? "Saving…" : "Attach"}
</Button> </Button>
</div> </div>}
</div> </div>
{error && <ErrorBanner message={error} />} {error && <ErrorBanner message={error} />}
@@ -219,8 +222,9 @@ function ArchiveRow({
} }
export default function ArchivePage() { export default function ArchivePage() {
const { isAdmin, loading: authLoading } = useAuth(); const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems(); const { systems } = useSystems();
const { incidents } = useIncidents(200); const { incidents } = useIncidents(200);
@@ -237,8 +241,8 @@ export default function ArchivePage() {
const [submittedQ, setSubmittedQ] = useState(""); const [submittedQ, setSubmittedQ] = useState("");
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/"); if (!authLoading && !canView) router.replace("/");
}, [authLoading, isAdmin, router]); }, [authLoading, canView, router]);
const load = useCallback( const load = useCallback(
async (nextCursor: string | null, append: boolean) => { async (nextCursor: string | null, append: boolean) => {
@@ -267,9 +271,9 @@ export default function ArchivePage() {
// Reload from the top whenever a filter changes. // Reload from the top whenever a filter changes.
useEffect(() => { useEffect(() => {
if (authLoading || !isAdmin) return; if (authLoading || !canView) return;
load(null, false); load(null, false);
}, [authLoading, isAdmin, load]); }, [authLoading, canView, load]);
const systemName = useMemo(() => { const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name])); const m = new Map(systems.map((s) => [s.system_id, s.name]));
@@ -277,7 +281,7 @@ export default function ArchivePage() {
}, [systems]); }, [systems]);
// Every hook runs before this guard — see the note in app/nodes/page.tsx. // Every hook runs before this guard — see the note in app/nodes/page.tsx.
if (authLoading || !isAdmin) return null; if (authLoading || !canView) return null;
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length; const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length; const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
@@ -286,7 +290,9 @@ export default function ArchivePage() {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Archive" title="Archive"
description="Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong." description={isAdmin
? "Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
: "Every call on the account, correlated or not."}
/> />
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -373,6 +379,7 @@ export default function ArchivePage() {
call={call} call={call}
systemName={systemName(call.system_id)} systemName={systemName(call.system_id)}
incidents={incidents} incidents={incidents}
canEdit={isAdmin}
onChanged={() => load(null, false)} onChanged={() => load(null, false)}
/> />
))} ))}
+76 -8
View File
@@ -27,6 +27,23 @@ const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 }; const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
type StatusFilter = "any" | "active" | "resolved";
const INCIDENT_TYPES = ["fire", "police", "ems", "accident", "other"];
// Firestore holds the paging; text/type/status filtering runs over the loaded
// window, so "Load more" also widens what the search can find.
const PAGE_SIZE = 100;
function matchesSearch(inc: IncidentRecord, needle: string): boolean {
if (!needle) return true;
const hay = [
inc.title, inc.location, inc.summary, inc.type,
...(inc.units ?? []), ...(inc.vehicles ?? []), ...(inc.tags ?? []),
...(inc.location_mentions ?? []),
].filter(Boolean).join(" ").toLowerCase();
return hay.includes(needle);
}
// The Firestore client surfaces a missing composite index or an undeployed // The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something // ruleset as a raw multi-line string with a console URL in it — not something
@@ -178,11 +195,15 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
export default function IncidentsPage() { export default function IncidentsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { incidents, loading, error } = useIncidents(); const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
const { incidents, loading, error, hasMore } = useIncidents(pageLimit);
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all"); const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent"); const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
const [typeFilter, setTypeFilter] = useState("");
const [search, setSearch] = useState("");
const onAirIncidentIds = useMemo(() => { const onAirIncidentIds = useMemo(() => {
const s = new Set<string>(); const s = new Set<string>();
@@ -194,12 +215,23 @@ export default function IncidentsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const threshold = FILTER_THRESHOLD[severityFilter]; const threshold = FILTER_THRESHOLD[severityFilter];
const list = incidents.filter((i) => severityRank(i.severity) >= threshold); const needle = search.trim().toLowerCase();
const list = incidents.filter((i) =>
severityRank(i.severity) >= threshold &&
(statusFilter === "any" || i.status === statusFilter) &&
(!typeFilter || i.type === typeFilter) &&
matchesSearch(i, needle)
);
if (sortMode === "severity") { if (sortMode === "severity") {
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)); return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
} }
return list; // useIncidents() already orders by started_at desc return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode]); }, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "";
function clearFilters() {
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
}
const hiddenCount = incidents.length - filtered.length; const hiddenCount = incidents.length - filtered.length;
const activeCount = filtered.filter((i) => i.status === "active").length; const activeCount = filtered.filter((i) => i.status === "active").length;
@@ -249,7 +281,34 @@ export default function IncidentsPage() {
</button> </button>
))} ))}
</div> </div>
<label className="flex items-center gap-2 text-xs text-ink-muted"> <input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search title, location, units…"
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-full sm:w-64 focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="any">Any status</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="">All types</option>
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
Sort Sort
<select <select
value={sortMode} value={sortMode}
@@ -270,7 +329,8 @@ export default function IncidentsPage() {
<> <>
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter. {hiddenCount} of {incidents.length} loaded incident{incidents.length !== 1 ? "s" : ""} hidden by filters
{hasMore && " — load more to search further back"}.
</p> </p>
)} )}
@@ -306,15 +366,23 @@ export default function IncidentsPage() {
description={ description={
incidents.length === 0 incidents.length === 0
? "Incidents appear automatically once calls start correlating." ? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold." : "Try clearing a filter, or load older incidents."
} }
action={ action={
incidents.length > 0 && severityFilter !== "all" ? ( incidents.length > 0 && filtersActive ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button> <Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : undefined ) : undefined
} }
/> />
)} )}
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
Load more
</Button>
</div>
)}
</> </>
)} )}
+5 -1
View File
@@ -15,6 +15,9 @@ export function useIncidents(limitCount = 100) {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// A full page means there may be older incidents past the limit; a short
// page means the query reached the end of the collection.
const [hasMore, setHasMore] = useState(false);
const { orgId } = useAuth(); const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
@@ -49,6 +52,7 @@ export function useIncidents(limitCount = 100) {
updated_at: toISO(data.updated_at), updated_at: toISO(data.updated_at),
} as IncidentRecord; } as IncidentRecord;
})); }));
setHasMore(snap.size >= limitCount);
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { }, (err: FirestoreError) => {
console.error("useIncidents:", err); console.error("useIncidents:", err);
@@ -63,7 +67,7 @@ export function useIncidents(limitCount = 100) {
}; };
}, [limitCount, orgId]); }, [limitCount, orgId]);
return { incidents, loading, error }; return { incidents, loading, error, hasMore };
} }
export function useIncident(incidentId: string | null) { export function useIncident(incidentId: string | null) {