Incidents search/filter/load-more; Archive viewable by viewers #164

Merged
logan merged 1 commits from feat/archive-search-viewers into main 2026-09-23 23:54:36 -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.auth import (
require_admin_token,
require_firebase_token,
require_service_or_firebase_token,
resolve_caller_org_id,
reprocess_limiter,
@@ -54,7 +55,7 @@ async def search_calls(
link: str = Query("any", pattern="^(any|orphan|linked)$"),
transcript: str = Query("any", pattern="^(any|yes|no)$"),
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.
@@ -72,6 +73,11 @@ async def search_calls(
`window_exhausted` says the scan hit its cap before filling the page, so an
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)
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
// quality is the thing under development — the orphans are the evidence.
//
// Admin-only, because it exposes every call in the org regardless of node
// ownership and carries the manual attribution controls.
// Readable by every org member — the Firestore rules already let any member
// 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 { useRouter } from "next/navigation";
@@ -68,11 +69,13 @@ function ArchiveRow({
call,
systemName,
incidents,
canEdit,
onChanged,
}: {
call: CallRecord;
systemName?: string;
incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
@@ -178,18 +181,18 @@ function ArchiveRow({
<div key={id} className="flex items-center gap-2 text-xs">
<span className="text-ink-muted">attached to</span>
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
<button
{canEdit && <button
onClick={() => detach(id)}
disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
>
detach
</button>
</button>}
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
{canEdit && <div className="flex flex-wrap items-center gap-2">
<select
value={attachTo}
onChange={(e) => setAttachTo(e.target.value)}
@@ -208,7 +211,7 @@ function ArchiveRow({
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"}
</Button>
</div>
</div>}
</div>
{error && <ErrorBanner message={error} />}
@@ -219,8 +222,9 @@ function ArchiveRow({
}
export default function ArchivePage() {
const { isAdmin, loading: authLoading } = useAuth();
const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems();
const { incidents } = useIncidents(200);
@@ -237,8 +241,8 @@ export default function ArchivePage() {
const [submittedQ, setSubmittedQ] = useState("");
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/");
}, [authLoading, isAdmin, router]);
if (!authLoading && !canView) router.replace("/");
}, [authLoading, canView, router]);
const load = useCallback(
async (nextCursor: string | null, append: boolean) => {
@@ -267,9 +271,9 @@ export default function ArchivePage() {
// Reload from the top whenever a filter changes.
useEffect(() => {
if (authLoading || !isAdmin) return;
if (authLoading || !canView) return;
load(null, false);
}, [authLoading, isAdmin, load]);
}, [authLoading, canView, load]);
const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name]));
@@ -277,7 +281,7 @@ export default function ArchivePage() {
}, [systems]);
// 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 noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
@@ -286,7 +290,9 @@ export default function ArchivePage() {
<div className="space-y-6">
<PageHeader
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">
@@ -373,6 +379,7 @@ export default function ArchivePage() {
call={call}
systemName={systemName(call.system_id)}
incidents={incidents}
canEdit={isAdmin}
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 };
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
// 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() {
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 [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
const [typeFilter, setTypeFilter] = useState("");
const [search, setSearch] = useState("");
const onAirIncidentIds = useMemo(() => {
const s = new Set<string>();
@@ -194,12 +215,23 @@ export default function IncidentsPage() {
const filtered = useMemo(() => {
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") {
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
}, [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 activeCount = filtered.filter((i) => i.status === "active").length;
@@ -249,7 +281,34 @@ export default function IncidentsPage() {
</button>
))}
</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
<select
value={sortMode}
@@ -270,7 +329,8 @@ export default function IncidentsPage() {
<>
{hiddenCount > 0 && (
<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>
)}
@@ -306,15 +366,23 @@ export default function IncidentsPage() {
description={
incidents.length === 0
? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold."
: "Try clearing a filter, or load older incidents."
}
action={
incidents.length > 0 && severityFilter !== "all" ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
incidents.length > 0 && filtersActive ? (
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : 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 [loading, setLoading] = useState(true);
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();
useEffect(() => {
@@ -49,6 +52,7 @@ export function useIncidents(limitCount = 100) {
updated_at: toISO(data.updated_at),
} as IncidentRecord;
}));
setHasMore(snap.size >= limitCount);
setLoading(false);
}, (err: FirestoreError) => {
console.error("useIncidents:", err);
@@ -63,7 +67,7 @@ export function useIncidents(limitCount = 100) {
};
}, [limitCount, orgId]);
return { incidents, loading, error };
return { incidents, loading, error, hasMore };
}
export function useIncident(incidentId: string | null) {