Compare commits
8
Commits
5f85a878fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79b8bc37d | ||
|
|
c72c28f5dc | ||
|
|
02b5b7b5a5 | ||
|
|
40014a47a3 | ||
|
|
6c0e7a4f8e | ||
|
|
6479174022 | ||
|
|
c043298902 | ||
|
|
fa194e0f0a |
@@ -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,
|
||||||
@@ -22,6 +23,42 @@ class EvalTranscriptUpdate(BaseModel):
|
|||||||
router = APIRouter(prefix="/calls", tags=["calls"])
|
router = APIRouter(prefix="/calls", tags=["calls"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: Optional[str], field: str) -> Optional[datetime]:
|
||||||
|
"""ISO string from a query param → aware datetime, or 400.
|
||||||
|
|
||||||
|
started_at is stored as a Firestore timestamp, so a cursor or range bound
|
||||||
|
passed through as the raw string compares by *type* (every string sorts
|
||||||
|
after every timestamp) rather than by time — a string cursor made "Load
|
||||||
|
more" return the first page again.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_cursor(rows: list[dict], matches: list[dict], page: list[dict], window: int) -> Optional[str]:
|
||||||
|
"""Where the next page of a bounded-window scan starts.
|
||||||
|
|
||||||
|
More matches than fit on the page → resume right after the last row
|
||||||
|
returned, or every match between it and the end of the window is skipped
|
||||||
|
(a 200-row window shown 50 at a time lost 150 calls per "Load more").
|
||||||
|
Otherwise resume after the last row SCANNED, not the last match — a page
|
||||||
|
whose last match sits early in the window would re-scan everything after
|
||||||
|
it and loop forever on a sparse filter. A short window is the end.
|
||||||
|
"""
|
||||||
|
if len(matches) > len(page):
|
||||||
|
last = page[-1].get("started_at")
|
||||||
|
elif len(rows) == window:
|
||||||
|
last = rows[-1].get("started_at")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return last.isoformat() if hasattr(last, "isoformat") else last
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_calls(
|
async def list_calls(
|
||||||
node_id: Optional[str] = Query(None),
|
node_id: Optional[str] = Query(None),
|
||||||
@@ -54,7 +91,9 @@ 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),
|
date_from: Optional[str] = Query(None, description="ISO timestamp, inclusive lower bound on started_at"),
|
||||||
|
date_to: Optional[str] = Query(None, description="ISO timestamp, inclusive upper bound on started_at"),
|
||||||
|
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 +111,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:
|
||||||
@@ -82,13 +126,24 @@ async def search_calls(
|
|||||||
if not org_id:
|
if not org_id:
|
||||||
raise HTTPException(403, "No organization scope for this caller.")
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
|
from_dt = _parse_ts(date_from, "date_from")
|
||||||
|
to_dt = _parse_ts(date_to, "date_to")
|
||||||
|
|
||||||
|
# A range on the ordered field rides the same org_id/started_at index.
|
||||||
|
conditions: list[tuple[str, str, object]] = [("org_id", "==", org_id)]
|
||||||
|
if from_dt:
|
||||||
|
conditions.append(("started_at", ">=", from_dt))
|
||||||
|
if to_dt:
|
||||||
|
conditions.append(("started_at", "<=", to_dt))
|
||||||
|
|
||||||
window = max(limit * 10, 200)
|
window = max(limit * 10, 200)
|
||||||
rows = await fstore.collection_where(
|
rows = await fstore.collection_where(
|
||||||
"calls",
|
"calls",
|
||||||
[("org_id", "==", org_id)],
|
conditions,
|
||||||
order_by=[("started_at", "DESCENDING")],
|
order_by=[("started_at", "DESCENDING")],
|
||||||
limit_to=window,
|
limit_to=window,
|
||||||
start_after={"started_at": cursor} if cursor else None,
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
needle = (q or "").strip().lower()
|
needle = (q or "").strip().lower()
|
||||||
@@ -117,13 +172,7 @@ async def search_calls(
|
|||||||
matches = [c for c in rows if _keep(c)]
|
matches = [c for c in rows if _keep(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
# Cursor advances over the SCANNED window, not the filtered page — otherwise
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
# a page whose last match sits early in the window would re-scan everything
|
|
||||||
# after it on the next request and loop forever on a sparse filter.
|
|
||||||
next_cursor = None
|
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
@@ -163,13 +212,14 @@ async def eval_queue(
|
|||||||
if not org_id:
|
if not org_id:
|
||||||
raise HTTPException(403, "No organization scope for this caller.")
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
window = max(limit * 20, 300)
|
window = max(limit * 20, 300)
|
||||||
rows = await fstore.collection_where(
|
rows = await fstore.collection_where(
|
||||||
"calls",
|
"calls",
|
||||||
[("org_id", "==", org_id)],
|
[("org_id", "==", org_id)],
|
||||||
order_by=[("started_at", "DESCENDING")],
|
order_by=[("started_at", "DESCENDING")],
|
||||||
limit_to=window,
|
limit_to=window,
|
||||||
start_after={"started_at": cursor} if cursor else None,
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _eligible(c: dict) -> bool:
|
def _eligible(c: dict) -> bool:
|
||||||
@@ -179,10 +229,7 @@ async def eval_queue(
|
|||||||
matches = [c for c in rows if _eligible(c)]
|
matches = [c for c in rows if _eligible(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""calls._parse_ts — cursor/date bounds must reach Firestore as datetimes.
|
||||||
|
|
||||||
|
A raw ISO string compared against a timestamp field sorts by type, not time,
|
||||||
|
which made the Archive's "Load more" return the first page again.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.calls import _next_cursor, _parse_ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_is_none():
|
||||||
|
assert _parse_ts(None, "cursor") is None
|
||||||
|
assert _parse_ts("", "cursor") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_suffix_parses_as_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00Z", "date_from") == datetime(2026, 9, 20, 12, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_is_assumed_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00", "date_to").tzinfo == timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trips_isoformat_cursor():
|
||||||
|
dt = datetime(2026, 9, 20, 12, 30, 5, 123456, tzinfo=timezone.utc)
|
||||||
|
assert _parse_ts(dt.isoformat(), "cursor") == dt
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_is_400():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_parse_ts("yesterday", "date_from")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── _next_cursor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(n):
|
||||||
|
return [{"started_at": datetime(2026, 9, 20, 12, i // 60, i % 60, tzinfo=timezone.utc)} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_last_returned_row_when_matches_overflow():
|
||||||
|
rows = _rows(200)
|
||||||
|
page = rows[:50]
|
||||||
|
assert _next_cursor(rows, rows, page, 200) == page[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_window_when_page_holds_every_match():
|
||||||
|
rows = _rows(200)
|
||||||
|
matches = rows[:3]
|
||||||
|
assert _next_cursor(rows, matches, matches, 200) == rows[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_window_is_the_end():
|
||||||
|
rows = _rows(20)
|
||||||
|
assert _next_cursor(rows, rows[:5], rows[:5], 200) is None
|
||||||
@@ -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";
|
||||||
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
|
|
||||||
type LinkFilter = "any" | "orphan" | "linked";
|
type LinkFilter = "any" | "orphan" | "linked";
|
||||||
type TranscriptFilter = "any" | "yes" | "no";
|
type TranscriptFilter = "any" | "yes" | "no";
|
||||||
@@ -68,11 +70,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 +182,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 +212,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 +223,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);
|
||||||
|
|
||||||
@@ -235,10 +240,12 @@ export default function ArchivePage() {
|
|||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [submittedQ, setSubmittedQ] = useState("");
|
const [submittedQ, setSubmittedQ] = useState("");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = 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) => {
|
||||||
@@ -252,6 +259,8 @@ export default function ArchivePage() {
|
|||||||
transcript,
|
transcript,
|
||||||
system_id: systemId || undefined,
|
system_id: systemId || undefined,
|
||||||
q: submittedQ || undefined,
|
q: submittedQ || undefined,
|
||||||
|
date_from: dayStart(dateFrom)?.toISOString(),
|
||||||
|
date_to: dayEnd(dateTo)?.toISOString(),
|
||||||
});
|
});
|
||||||
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
||||||
setCursor(res.next_cursor);
|
setCursor(res.next_cursor);
|
||||||
@@ -262,14 +271,14 @@ export default function ArchivePage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[link, transcript, systemId, submittedQ],
|
[link, transcript, systemId, submittedQ, dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 +286,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 +295,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">
|
||||||
@@ -329,6 +340,8 @@ export default function ArchivePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<DateRange from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
||||||
className="flex items-center gap-2 ml-auto"
|
className="flex items-center gap-2 ml-auto"
|
||||||
@@ -373,6 +386,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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||||
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||||
@@ -27,6 +28,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 +196,19 @@ 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 [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
|
||||||
|
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
|
||||||
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||||
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 +220,24 @@ 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() !== "" || dateFrom !== "" || dateTo !== "";
|
||||||
|
function clearFilters() {
|
||||||
|
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
||||||
|
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
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 +287,39 @@ 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>
|
||||||
|
<DateRange
|
||||||
|
from={dateFrom}
|
||||||
|
to={dateTo}
|
||||||
|
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
|
||||||
Sort
|
Sort
|
||||||
<select
|
<select
|
||||||
value={sortMode}
|
value={sortMode}
|
||||||
@@ -270,7 +340,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,19 +373,27 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
{filtered.length === 0 && !error && (
|
{filtered.length === 0 && !error && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
|
||||||
description={
|
description={
|
||||||
incidents.length === 0
|
incidents.length === 0 && !filtersActive
|
||||||
? "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" ? (
|
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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// A from/to pair of native date inputs. Values are the inputs' own
|
||||||
|
// "YYYY-MM-DD" strings; dayStart/dayEnd turn them into the local-midnight
|
||||||
|
// bounds a started_at range query needs, so "to" includes the whole day.
|
||||||
|
|
||||||
|
export function dayStart(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayEnd(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent";
|
||||||
|
|
||||||
|
export function DateRange({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (from: string, to: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="From date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value, to)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<span>to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="To date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => onChange(from, e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{(from || to) && (
|
||||||
|
<button onClick={() => onChange("", "")} className="text-ink-muted hover:text-ink-2">
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ export const c2api = {
|
|||||||
link?: "any" | "orphan" | "linked";
|
link?: "any" | "orphan" | "linked";
|
||||||
transcript?: "any" | "yes" | "no";
|
transcript?: "any" | "yes" | "no";
|
||||||
q?: string;
|
q?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ const toISO = (v: unknown): string =>
|
|||||||
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
||||||
(typeof v === "string" ? v : new Date().toISOString());
|
(typeof v === "string" ? v : new Date().toISOString());
|
||||||
|
|
||||||
export function useIncidents(limitCount = 100) {
|
export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
|
||||||
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();
|
||||||
|
|
||||||
|
// Stable ms values so the effect dependency doesn't fire on every render
|
||||||
|
const dateFromMs = dateFrom?.getTime();
|
||||||
|
const dateToMs = dateTo?.getTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -34,9 +41,18 @@ export function useIncidents(limitCount = 100) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A range on the ordered field rides the existing org_id/started_at index.
|
||||||
|
// Incident started_at is stored as a Python isoformat() STRING
|
||||||
|
// ("2026-09-20T12:00:00.123456+00:00", incident_correlator.py), not a
|
||||||
|
// Firestore timestamp — unlike calls. A Date bound compares by type and
|
||||||
|
// matches nothing, so the bounds go in as UTC ISO strings in the same
|
||||||
|
// shape, which then compare lexicographically in time order.
|
||||||
|
const isoBound = (ms: number) => new Date(ms).toISOString().replace("Z", "+00:00");
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "incidents"),
|
collection(db, "incidents"),
|
||||||
where("org_id", "==", orgId),
|
where("org_id", "==", orgId),
|
||||||
|
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
|
||||||
|
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount)
|
||||||
);
|
);
|
||||||
@@ -49,6 +65,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);
|
||||||
@@ -61,9 +78,9 @@ export function useIncidents(limitCount = 100) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount, orgId]);
|
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||||
|
|
||||||
return { incidents, loading, error };
|
return { incidents, loading, error, hasMore };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIncident(incidentId: string | null) {
|
export function useIncident(incidentId: string | null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user