Merge pull request 'Incidents search/filter/load-more; Archive viewable by viewers' (#164) from feat/archive-search-viewers into main
This commit was merged in pull request #164.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user