Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9875c3b
feat(callables): requestAgencyAssistance, acceptAgencyAssistance, dec…
claude Apr 24, 2026
71b4cf2
fix(callables): agency-assistance spec compliance fixes
claude Apr 24, 2026
c0d898d
fix(request-agency-assistance): validate agencyId against allowed req…
claude Apr 24, 2026
35e1b51
feat(scheduled): adminOperationsSweep — escalate stale agency assista…
claude Apr 24, 2026
1c1722a
fix(sweep): store escalatedAt as Firestore Timestamp milliseconds
claude Apr 24, 2026
7387789
feat(admin-desktop): AgencyAssistanceQueuePage — accept/decline with …
claude Apr 24, 2026
d0e748b
test(admin-desktop): add decline callable invocation test for AgencyA…
claude Apr 24, 2026
182b46b
feat(field-mode): add enterFieldMode + exitFieldMode callables
claude Apr 24, 2026
612574e
feat(admin-desktop): useFieldModeStore hook + ReconnectBanner component
claude Apr 24, 2026
3b914ee
feat(phase5): OSM boundary extraction script for Camarines Norte
claude Apr 24, 2026
7ed2ec1
feat(callables): add shareReport callable for cross-municipality repo…
claude Apr 24, 2026
c46c454
feat(triggers): borderAutoShareTrigger — geohash fast-reject, turf bu…
claude Apr 24, 2026
6fae9a7
feat(callables+ui): addCommandChannelMessage callable; CommandChannel…
claude Apr 24, 2026
28d9ab0
fix: correct error type mismatch and test emulator cleanup
claude Apr 24, 2026
33a5a4d
fix(callables): add account status check and validate participantUids…
claude Apr 24, 2026
55f898e
test(field-mode): use Date.now() for fresh auth_time in callable wrap…
claude Apr 24, 2026
7fc24dc
chore(deps): add @turf/turf, ngeohash, geojson for boundary extractio…
claude Apr 24, 2026
c873007
build: compile functions and shared-validators lib outputs
claude Apr 24, 2026
d7d5217
fix: address CodeRabbit PR #64 review comments
claude Apr 25, 2026
cf3cdb5
fix(shared-validators): add schemaVersion to agencyAssistanceRequestD…
claude Apr 25, 2026
48cf645
fix(functions): restore missing acceptAgencyAssistance handler and De…
claude Apr 25, 2026
7516c04
chore(functions): rebuild lib artifacts after acceptAgencyAssistance fix
claude Apr 25, 2026
2ba693f
test(admin-desktop): use fixed timestamp in field-mode-store test for…
claude Apr 25, 2026
eb15f7b
fix(admin-desktop): defer setState in effects to satisfy react-hooks/…
claude Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/admin-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@
"react-router-dom": "^7.14.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"happy-dom": "^15.11.0",
"vitest": "^4.1.4",
"vite": "^8.0.8"
}
}
138 changes: 138 additions & 0 deletions apps/admin-desktop/src/__tests__/command-channel-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { CommandChannelPanel } from '../components/CommandChannelPanel.js'
import { httpsCallable } from 'firebase/functions'

const { mockOnSnapshot, mockHttpsCallable, mockGetFunctions, mockDb } = vi.hoisted(() => ({
mockOnSnapshot: vi.fn(),
mockHttpsCallable: vi.fn(),
mockGetFunctions: vi.fn(() => ({})),
mockDb: {},
}))

interface MockQueryRef {
type: string
filters: { type: string; field?: string }[]
}

vi.mock('firebase/firestore', () => {
const collection = vi.fn((_db, name) => ({ type: 'collection', name }))
const where = vi.fn((field, _op, value) => ({ type: 'where', field, value }))
const orderBy = vi.fn((field, dir) => ({ type: 'orderBy', field, dir }))
const limit = vi.fn((n) => ({ type: 'limit', n }))
const query = vi.fn((...args) => ({ type: 'query', filters: args.slice(1) }))
return {
getFirestore: vi.fn(() => mockDb),
collection,
query,
where,
orderBy,
limit,
onSnapshot: mockOnSnapshot,
doc: vi.fn(),
}
})

vi.mock('firebase/functions', () => ({
httpsCallable: vi.fn(() => mockHttpsCallable),
getFunctions: mockGetFunctions,
}))

const threadSnap = {
docs: [
{
id: 'th1',
data: () => ({
threadType: 'agency_assistance',
subject: 'Test thread',
participantUids: { u1: true, u2: true },
lastMessageAt: 1000,
}),
},
],
empty: false,
}

const msgSnap = {
docs: [
{
id: 'm1',
data: () => ({
authorUid: 'u2',
body: 'Hello world',
createdAt: 1000,
}),
},
],
empty: false,
}

beforeEach(() => {
mockOnSnapshot.mockReset()
mockHttpsCallable.mockReset()
mockHttpsCallable.mockResolvedValue({ data: { status: 'sent' } })

mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => {
if (ref.type === 'query') {
const isMessages = ref.filters.some((f) => f.type === 'where' && f.field === 'threadId')
cb(isMessages ? msgSnap : threadSnap)
}
return vi.fn()
})
})

describe('CommandChannelPanel', () => {
it('renders nothing when no threads', () => {
mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => {
if (ref.type === 'query') {
cb({ docs: [], empty: true })
}
return vi.fn()
})
const { container } = render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />)
expect(container.firstChild).toBeNull()
})

it('renders thread tabs and messages', () => {
render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />)
expect(screen.getByText('🏥 Agency')).toBeInTheDocument()
expect(screen.getByText('Test thread')).toBeInTheDocument()
expect(screen.getByText('Hello world')).toBeInTheDocument()
})

it('sends a message when clicking Send', async () => {
render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />)
const textarea = screen.getByPlaceholderText('Type a message...')
fireEvent.change(textarea, { target: { value: 'Units dispatched' } })
fireEvent.click(screen.getByText('Send'))
await waitFor(() => {
expect(httpsCallable).toHaveBeenCalledWith(expect.any(Object), 'addCommandChannelMessage')
expect(mockHttpsCallable).toHaveBeenCalledWith(
expect.objectContaining({
threadId: 'th1',
body: 'Units dispatched',
idempotencyKey: expect.any(String),
}),
)
})
expect(textarea).toHaveValue('')
})

it('displays an error when send fails', async () => {
mockHttpsCallable.mockRejectedValue(new Error('Network error'))
render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />)
const textarea = screen.getByPlaceholderText('Type a message...')
fireEvent.change(textarea, { target: { value: 'Fail me' } })
fireEvent.click(screen.getByText('Send'))
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})

it('enforces max length of 2000', () => {
render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />)
const textarea = screen.getByPlaceholderText('Type a message...')
expect(textarea).toHaveAttribute('maxLength', '2000')
})
})
75 changes: 75 additions & 0 deletions apps/admin-desktop/src/__tests__/field-mode-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useFieldModeStore } from '../hooks/useFieldModeStore.js'

const { mockOnSnapshot, mockHttpsCallable, mockHttpsCallableFn } = vi.hoisted(() => {
const httpsCallableFn = vi.fn()
return {
mockOnSnapshot: vi.fn(),
mockHttpsCallable: vi.fn(() => httpsCallableFn),
mockHttpsCallableFn: httpsCallableFn,
}
})

vi.mock('firebase/firestore', () => ({
doc: vi.fn(),
onSnapshot: mockOnSnapshot,
getFirestore: vi.fn(() => ({})),
}))

vi.mock('firebase/functions', () => ({
httpsCallable: mockHttpsCallable,
getFunctions: vi.fn(() => ({})),
}))

const ts = 1700000000000 // fixed timestamp for test determinism

beforeEach(() => {
mockOnSnapshot.mockReset()
mockHttpsCallable.mockClear()
mockHttpsCallableFn.mockReset()
mockHttpsCallableFn.mockResolvedValue({ data: { status: 'exited' } })
})
afterEach(() => {
vi.useRealTimers()
})

describe('useFieldModeStore', () => {
it('shows isActive true when session snapshot has isActive true', () => {
mockOnSnapshot.mockImplementation((_ref, cb) => {
cb({
exists: () => true,
data: () => ({ isActive: true, expiresAt: ts + 3600000 }),
})
return vi.fn()
})
const { result } = renderHook(() => useFieldModeStore('uid-1'))
expect(result.current.isActive).toBe(true)
})

it('shows isActive false when no session exists', () => {
mockOnSnapshot.mockImplementation((_ref, cb) => {
cb({ exists: () => false, data: () => undefined })
return vi.fn()
})
const { result } = renderHook(() => useFieldModeStore('uid-1'))
expect(result.current.isActive).toBe(false)
})

it('calls exitFieldMode when session expires', () => {
vi.useFakeTimers()
const expiredAt = Date.now() - 1000
mockOnSnapshot.mockImplementation((_ref, cb) => {
cb({
exists: () => true,
data: () => ({ isActive: true, expiresAt: expiredAt }),
})
return vi.fn()
})
renderHook(() => useFieldModeStore('uid-1'))
act(() => {
vi.advanceTimersByTime(65000)
})
expect(mockHttpsCallable).toHaveBeenCalledWith(expect.anything(), 'exitFieldMode')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
160 changes: 160 additions & 0 deletions apps/admin-desktop/src/components/CommandChannelPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// apps/admin-desktop/src/components/CommandChannelPanel.tsx
import { useState, useEffect, useRef } from 'react'
import {
collection,
query,
where,
orderBy,
limit,
onSnapshot,
type QueryDocumentSnapshot,
getFirestore,
} from 'firebase/firestore'
import { httpsCallable, getFunctions } from 'firebase/functions'

const db = getFirestore()
const functions = getFunctions()

interface Thread {
id: string
threadType: 'agency_assistance' | 'border_share'
subject: string
participantUids: Record<string, boolean>
lastMessageAt?: number
}

interface Message {
id: string
authorUid: string
body: string
createdAt: number
}

interface Props {
reportId: string
currentUserUid: string
}

export function CommandChannelPanel({ reportId, currentUserUid }: Props) {
const [threads, setThreads] = useState<Thread[]>([])
const [activeThreadId, setActiveThreadId] = useState<string | null>(null)
const initialSelectionDoneRef = useRef(false)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)

useEffect(() => {
// Reset selection state when reportId changes
initialSelectionDoneRef.current = false
queueMicrotask(() => {
setActiveThreadId(null)
setMessages([])
})
const q = query(collection(db, 'command_channel_threads'), where('reportId', '==', reportId))
return onSnapshot(q, (snap) => {
const found = snap.docs.map((d: QueryDocumentSnapshot) => ({
id: d.id,
...(d.data() as Omit<Thread, 'id'>),
}))
setThreads(found)
if (found.length > 0 && !initialSelectionDoneRef.current) {
const first = found[0]
if (first) {
setActiveThreadId(first.id)
initialSelectionDoneRef.current = true
}
}
})
}, [reportId])

useEffect(() => {
if (!activeThreadId) return
const q = query(
collection(db, 'command_channel_messages'),
where('threadId', '==', activeThreadId),
orderBy('createdAt', 'desc'),
limit(50),
)
return onSnapshot(q, (snap) => {
setMessages(
snap.docs
.map((d: QueryDocumentSnapshot) => ({ id: d.id, ...(d.data() as Omit<Message, 'id'>) }))
.reverse(),
)
})
}, [activeThreadId])
Comment on lines +41 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset thread selection when reportId changes.

initialSelectionDoneRef never resets, so a mounted panel that switches from one report to another keeps the old activeThreadId and old message subscription. The new report can end up rendering with no active tab while still listening to the previous thread. Reset the ref/state on reportId changes, and if the current thread disappears from found, select a valid replacement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/components/CommandChannelPanel.tsx` around lines 41 -
79, Reset the selection state and ensure the active thread is valid when
reportId changes: in the useEffect that queries 'command_channel_threads' reset
initialSelectionDoneRef.current = false (or set it based on reportId) and clear
messages/activeThreadId via setMessages([]) and setActiveThreadId(null) before
subscribing; after receiving found, if current activeThreadId is missing from
found choose a replacement (e.g., first.id) via setActiveThreadId(first.id) and
mark initialSelectionDoneRef.current = true; ensure setThreads(found) is still
called so UI updates correctly. Use the existing symbols
initialSelectionDoneRef, setThreads, setActiveThreadId, setMessages and the
threads-query useEffect to implement this behavior.


async function handleSend() {
if (!activeThreadId || !input.trim()) return
setError(null)
try {
const fn = httpsCallable(functions, 'addCommandChannelMessage')
await fn({
threadId: activeThreadId,
body: input.trim(),
idempotencyKey: crypto.randomUUID(),
})
setInput('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send')
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (threads.length === 0) return null

const activeThread = threads.find((t) => t.id === activeThreadId)

return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex gap-2">
{threads.map((t) => (
<button
key={t.id}
onClick={() => {
setActiveThreadId(t.id)
}}
className={`px-2 py-1 text-xs rounded ${t.id === activeThreadId ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}
>
{t.threadType === 'agency_assistance' ? '🏥 Agency' : '🗺️ Border'}
</button>
))}
</div>

{activeThread && <p className="text-xs text-gray-500">{activeThread.subject}</p>}

<div className="h-48 overflow-y-auto space-y-2 border rounded p-2 bg-gray-50">
{messages.map((m) => (
<div
key={m.id}
className={`text-sm ${m.authorUid === currentUserUid ? 'text-right' : ''}`}
>
<span className="text-xs text-gray-500">{m.authorUid}</span>
<p>{m.body}</p>
</div>
))}
</div>

<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
}}
maxLength={2000}
rows={2}
className="flex-1 border rounded px-2 py-1 text-sm resize-none"
placeholder="Type a message..."
/>
<button
onClick={() => void handleSend()}
disabled={!input.trim()}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm disabled:opacity-50"
>
Send
</button>
</div>
<p className="text-xs text-right text-gray-400">{input.length}/2000</p>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
)
}
Loading
Loading