-
Notifications
You must be signed in to change notification settings - Fork 0
feat(phase5): Cluster B — Inter-Agency Coordination #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
9875c3b
feat(callables): requestAgencyAssistance, acceptAgencyAssistance, dec…
claude 71b4cf2
fix(callables): agency-assistance spec compliance fixes
claude c0d898d
fix(request-agency-assistance): validate agencyId against allowed req…
claude 35e1b51
feat(scheduled): adminOperationsSweep — escalate stale agency assista…
claude 1c1722a
fix(sweep): store escalatedAt as Firestore Timestamp milliseconds
claude 7387789
feat(admin-desktop): AgencyAssistanceQueuePage — accept/decline with …
claude d0e748b
test(admin-desktop): add decline callable invocation test for AgencyA…
claude 182b46b
feat(field-mode): add enterFieldMode + exitFieldMode callables
claude 612574e
feat(admin-desktop): useFieldModeStore hook + ReconnectBanner component
claude 3b914ee
feat(phase5): OSM boundary extraction script for Camarines Norte
claude 7ed2ec1
feat(callables): add shareReport callable for cross-municipality repo…
claude c46c454
feat(triggers): borderAutoShareTrigger — geohash fast-reject, turf bu…
claude 6fae9a7
feat(callables+ui): addCommandChannelMessage callable; CommandChannel…
claude 28d9ab0
fix: correct error type mismatch and test emulator cleanup
claude 33a5a4d
fix(callables): add account status check and validate participantUids…
claude 55f898e
test(field-mode): use Date.now() for fresh auth_time in callable wrap…
claude 7fc24dc
chore(deps): add @turf/turf, ngeohash, geojson for boundary extractio…
claude c873007
build: compile functions and shared-validators lib outputs
claude d7d5217
fix: address CodeRabbit PR #64 review comments
claude cf3cdb5
fix(shared-validators): add schemaVersion to agencyAssistanceRequestD…
claude 48cf645
fix(functions): restore missing acceptAgencyAssistance handler and De…
claude 7516c04
chore(functions): rebuild lib artifacts after acceptAgencyAssistance fix
claude 2ba693f
test(admin-desktop): use fixed timestamp in field-mode-store test for…
claude eb15f7b
fix(admin-desktop): defer setState in effects to satisfy react-hooks/…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
apps/admin-desktop/src/__tests__/command-channel-panel.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| 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 } = vi.hoisted(() => ({ | ||
| mockOnSnapshot: vi.fn(), | ||
| mockHttpsCallable: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('firebase/firestore', () => ({ | ||
| doc: vi.fn(), | ||
| onSnapshot: mockOnSnapshot, | ||
| getFirestore: vi.fn(() => ({})), | ||
| })) | ||
|
|
||
| vi.mock('firebase/functions', () => ({ | ||
| httpsCallable: vi.fn(() => mockHttpsCallable), | ||
| getFunctions: vi.fn(() => ({})), | ||
| })) | ||
|
|
||
| const ts = Date.now() | ||
|
|
||
| beforeEach(() => { | ||
| mockOnSnapshot.mockReset() | ||
| mockHttpsCallable.mockReset() | ||
| mockHttpsCallable.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).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
150 changes: 150 additions & 0 deletions
150
apps/admin-desktop/src/components/CommandChannelPanel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| // apps/admin-desktop/src/components/CommandChannelPanel.tsx | ||
| import { useState, useEffect } from 'react' | ||
| import { | ||
| collection, | ||
| query, | ||
| where, | ||
| orderBy, | ||
| limit, | ||
| onSnapshot, | ||
| type QueryDocumentSnapshot, | ||
| getFirestore, | ||
| } from 'firebase/firestore' | ||
| import { httpsCallable, getFunctions } from 'firebase/functions' | ||
|
|
||
| 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 [messages, setMessages] = useState<Message[]>([]) | ||
| const [input, setInput] = useState('') | ||
| const [error, setError] = useState<string | null>(null) | ||
| const db = getFirestore() | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| useEffect(() => { | ||
| 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 && !activeThreadId) { | ||
| const first = found[0] | ||
| if (first) { | ||
| setActiveThreadId(first.id) | ||
| } | ||
| } | ||
| }) | ||
| }, [reportId, db, activeThreadId]) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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, db]) | ||
|
|
||
| async function handleSend() { | ||
| if (!activeThreadId || !input.trim()) return | ||
| setError(null) | ||
| try { | ||
| const fn = httpsCallable(getFunctions(), 'addCommandChannelMessage') | ||
| await fn({ | ||
| threadId: activeThreadId, | ||
| body: input.trim(), | ||
| idempotencyKey: crypto.randomUUID(), | ||
| }) | ||
| setInput('') | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : 'Failed to send') | ||
| } | ||
| } | ||
|
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> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| interface Props { | ||
| actionLabel: string | ||
| } | ||
|
|
||
| export function ReconnectBanner({ actionLabel }: Props) { | ||
| return ( | ||
| <div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800"> | ||
| <span>⚠</span> | ||
| <span>Connect to {actionLabel}</span> | ||
| </div> | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| ) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.