Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
118a6cc
fix(sms-parser): fix 2 failing tests by using barangays present in fa…
claude Apr 23, 2026
3e568a0
fix(validators): add include pattern to prevent duplicate test execution
claude Apr 23, 2026
c3785ee
fix(citizen-pwa): type bare catch blocks for private-mode storage fai…
claude Apr 23, 2026
5964a94
fix(packages): type bare catch blocks in sms-parser and validators
claude Apr 23, 2026
661f5e8
fix(functions): improve error handling and visibility in critical paths
claude Apr 23, 2026
41ac6d8
test(functions): add critical auth boundary tests for https-error han…
claude Apr 23, 2026
b3ac175
docs: add code quality audit findings and progress tracking
claude Apr 23, 2026
e4217e9
chore(validators): update compiled lib artifacts for msisdn and idemp…
claude Apr 23, 2026
cddbb24
fix: address PR review comments β€” Web Crypto guard and FCM error logging
claude Apr 23, 2026
57a7f4f
fix: address CodeRabbit review comments β€” enum coverage, PII logging,…
claude Apr 23, 2026
3da508a
fix(idempotency): strengthen Web Crypto guard to handle null subtle
claude Apr 23, 2026
21564cc
fix(shared-validators): circular reference detection, salt validation…
claude Apr 23, 2026
25cd8db
fix(functions): validate SMS_MSISDN_ENCRYPTION_KEY format before decr…
claude Apr 23, 2026
271b796
fix(citizen-pwa): discriminate storage errors and reduce network prob…
claude Apr 23, 2026
b0ecaa2
fix(shared-sms-parser): narrow MODULE_NOT_FOUND fallback to @bantayog…
claude Apr 23, 2026
20f33c3
docs(audit): refresh refactor-audit with current bare-catch counts an…
claude Apr 23, 2026
fe13c0d
fix(citizen-pwa): log unexpected storage errors in Step2WhoWhere
claude Apr 23, 2026
c67545c
fix(citizen-pwa): move timeout cleanup to finally block in useOnlineS…
claude Apr 23, 2026
8a2b5e1
fix(shared-validators): preserve original error in idempotency catch
claude Apr 23, 2026
1ba7782
fix(shared-validators): check typeof before accessing salt.length
claude Apr 23, 2026
a0ad82c
fix(citizen-pwa,docs): address CodeRabbit review comments for PR #61
claude Apr 23, 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
30 changes: 26 additions & 4 deletions apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,18 @@ const MUNI_LABELS_SORTED = [...CAMARINES_NORTE_MUNICIPALITIES]
.sort((a, b) => a.label.localeCompare(b.label))
.map((m) => ({ id: m.id, label: m.label }))

function isQuotaExceededError(err: unknown): boolean {
return (
err instanceof DOMException &&
// eslint-disable-next-line @typescript-eslint/no-deprecated
(err.name === 'QuotaExceededError' || err.code === 22)
)
}

function isSecurityError(err: unknown): boolean {
return err instanceof DOMException && err.name === 'SecurityError'
}

interface Step2WhoWhereProps {
onNext: (data: {
location: { lat: number; lng: number }
Expand Down Expand Up @@ -377,8 +389,13 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who
if (savedMsisdn) setReporterMsisdn(savedMsisdn)
setHasMemory(true)
}
} catch {
// Restricted/private mode β€” skip pre-fill silently
} catch (err: unknown) {
if (isQuotaExceededError(err)) {
console.warn('[Step2WhoWhere] Storage quota exceeded, skipping pre-fill')
} else if (!isSecurityError(err)) {
console.warn('[Step2WhoWhere] Unexpected storage read error, skipping pre-fill', err)
}
// SecurityError (private mode) is intentionally silent
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, [])

Expand Down Expand Up @@ -420,8 +437,13 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who
localStorage.setItem('bantayog.reporter.name', reporterName)
// Phone is session-only to limit long-lived PII exposure
sessionStorage.setItem('bantayog.reporter.msisdn', reporterMsisdn)
} catch {
// Restricted/private mode β€” skip persist silently
} catch (err: unknown) {
if (isQuotaExceededError(err)) {
console.warn('[Step2WhoWhere] Storage quota exceeded, skipping persist')
} else if (!isSecurityError(err)) {
console.warn('[Step2WhoWhere] Unexpected storage write error, skipping persist', err)
}
// SecurityError (private mode) is intentionally silent
}

onNext({
Expand Down
15 changes: 11 additions & 4 deletions apps/citizen-pwa/src/hooks/useOnlineStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,27 @@ export function useOnlineStatus() {
setProbeOnline(false)
return
}
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, PROBE_TIMEOUT_MS)
try {
await fetch(PROBE_URL, {
mode: 'no-cors',
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
signal: controller.signal,
})
setProbeOnline(true)
} catch {
} catch (_err: unknown) {
void _err
setProbeOnline(false)
} finally {
clearTimeout(timeoutId)
}
}, [])

// Probe on mount and every 30s β€” schedule immediately via setTimeout to avoid
// calling setState synchronously within the effect body (React lint rule)
// Probe immediately on mount, then every 30s via setInterval.
// setInterval avoids calling setState synchronously within the effect body (React lint rule)
useEffect(() => {
const scheduleProbe = () => {
void probe()
Expand Down
3 changes: 2 additions & 1 deletion apps/citizen-pwa/src/services/draft-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ async function isBlobReadable(blob: Blob): Promise<boolean> {
try {
await blob.slice(0, 1).arrayBuffer()
return true
} catch {
} catch (_err: unknown) {
void _err
return false
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Durable rules worth keeping across sessions.
- Use `catch (err: unknown)` and narrow explicitly.
- Avoid `any`; prefer real types or `unknown`.
- With `exactOptionalPropertyTypes`, omit optional keys entirely instead of assigning `undefined`.
- **`catch (_err: unknown) { void _err }` does not satisfy `@typescript-eslint/no-unused-vars` in all configs.** If the linter rejects `_`-prefixed catch variables, prefer `catch { /* reason */ }` with an explicit comment over a disabled lint rule. Only capture the error when you actually log or transform it.

## Auth / Async

Expand Down
25 changes: 25 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

## Current

### Code quality & security refactor (2026-04-23)

- Status: DONE β€” audit-driven fixes across monorepo
- Files changed:
- `packages/shared-sms-parser/src/__tests__/inbound.test.ts` β€” fix 2 failing tests by using barangays present in fallback gazetteer (`LANG` for ambiguous match, `ANAHAW` for exact match)
- `packages/shared-validators/vitest.config.ts` β€” add `include: ['src/**/*.test.ts']` to prevent duplicate test execution (was running tests from both `src/` and `lib/`)
- `apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx` β€” type 2 bare `catch {}` blocks (`_err: unknown`) for localStorage/sessionStorage private-mode failures
- `apps/citizen-pwa/src/services/draft-store.ts` β€” type bare `catch {}` in `isBlobReadable`
- `apps/citizen-pwa/src/hooks/useOnlineStatus.ts` β€” type bare `catch {}` in network probe
- `packages/shared-sms-parser/src/inbound.ts` β€” type bare `catch {}` in gazetteer require fallback
- `packages/shared-validators/src/msisdn.ts` β€” type bare `catch {}` in `node:crypto` require fallback
- `functions/src/services/fcm-send.ts` β€” capture and append FCM retry error to warnings; keep outer catch bare (intentional retry)
- `functions/src/services/sms-providers/semaphore.ts` β€” include original parse error in thrown `SmsProviderRetryableError`
- `functions/src/http/sms-inbound.ts` β€” capture and log MSISDN normalization error
- `functions/src/triggers/on-media-finalize.ts` β€” capture and log corrupt-image processing error
- `functions/src/firestore/sms-inbound-processor.ts` β€” capture and log MSISDN decryption error
- `functions/src/triggers/inbox-reconciliation-sweep.ts` β€” restore bare catch with explicit comment (transaction contention is intentional skip)
- `functions/src/__tests__/callables/https-error.test.ts` β€” NEW: 8 tests for critical auth boundary (`requireAuth`, `bantayogErrorToHttps`, code mapping)
- Verification:
- `npx turbo run lint` β€” PASS (25/25)
- `npx turbo run typecheck` β€” PASS (25/25)
- `pnpm --filter @bantayog/shared-sms-parser test` β€” PASS (13/13)
- `pnpm --filter @bantayog/shared-validators test` β€” PASS (190/190, no duplicates)
- `pnpm --filter @bantayog/functions test src/__tests__/callables/https-error.test.ts` β€” PASS (8/8)

### Phase 5 Responder MVP β€” PR #60 review fixes (2026-04-23)

- Status: DONE β€” all CodeRabbit + CodeQL review comments addressed
Expand Down
156 changes: 156 additions & 0 deletions docs/refactor-audit-2026-04-23.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Refactor Audit β€” 2026-04-23

**Scope:** Full monorepo (apps/_, packages/_, functions)
**Health Check:** Lint/Typecheck βœ… 25/25 passing | Tests βœ… All passing (shared-sms-parser fixed)

---

## πŸ”΄ P0 β€” Fix Before Anything Else

### ~~1. `shared-sms-parser` has 2 failing tests~~ βœ… RESOLVED

- **Status:** Fixed in this PR β€” tests now pass (13/13)
- **Fix:** Updated test expectations to use barangays present in fallback gazetteer (LANG for ambiguous match, ANAHAW for exact match)

---

## 🟠 P1 β€” High Risk / Hard to Maintain

### 2. `Step2WhoWhere.tsx` is a β‰ˆ707-line file with an oversized component

- **File:** `apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx`
- **Issues:**
- β‰ˆ707 lines in one file; component body is much smaller, large constants/helpers inflate file size
- ~~2 bare `catch {}` blocks~~ βœ… Fixed β€” now typed as `catch (err: unknown)` for private-mode storage failures
- Mixes GPS logic, municipality selection, form validation, UI rendering
- **Impact:** Impossible to test. Every change risks regressing the entire citizen reporting flow.
- **Action:** Extract sub-components: `GpsButton`, `MunicipalitySelector`, `BarangaySelector`, `ContactFields`. Extract hooks: `useGpsLocation`, `useMunicipalityBarangays`.
- **Effort:** Medium (~3-5 files, ~150 lines)

### 3. `inbound.ts` is a 541-line parser with multiple responsibilities

- **File:** `packages/shared-sms-parser/src/inbound.ts`
- **Issues:**
- Contains gazetteer loading, Levenshtein distance, auto-reply building, AND parsing logic
- 6 `@ts-expect-error` comments (lines 360-376) indicating fragile array logic
- ~~1 bare `catch {}` (line 45)~~ βœ… Fixed β€” now uses `catch (err: unknown)` with selective fallback/rethrow (only swallows `MODULE_NOT_FOUND` for `@bantayog/shared-data`)
- **Impact:** High cyclomatic complexity makes bugs likely.
- **Action:** Split into `levenshtein.ts`, `gazetteer.ts`, `auto-reply.ts`, `parser.ts`.
- **Effort:** Medium (~4 files, ~80 lines of changes)

### 4. `dispatch-responder.ts` is 307 lines of callable logic

- **File:** `functions/src/callables/dispatch-responder.ts`
- **Impact:** Core responder dispatch function. Long functions = hard to reason about security rules.
- **Action:** Extract validation, notification, and Firestore write logic into separate functions.
- **Effort:** Medium (~1 file refactored, ~2 new files)

---

## 🟑 P2 β€” Structural / Consistency Debt

### 5. Multiple apps/packages still have minimal or zero tests

| Package/App | Source Files | Tests |
| ----------------------- | ------------ | ----- |
| `apps/admin-desktop` | 18 | **0** |
| `apps/responder-app` | 23 | **1** |
| `packages/shared-data` | 1 | **0** |
| `packages/shared-types` | 8 | **0** |
| `packages/shared-ui` | 2 | **0** |

- **Impact:** Regressions in admin/responder flows are only caught in manual QA or production.
- **Action:** Add characterization tests for critical paths first:
1. `admin-desktop`: `LoginPage`, `TriageQueuePage`, `DispatchModal`
2. `responder-app`: `DispatchListPage`, `DispatchDetailPage`, `useAcceptDispatch`
3. `shared-ui`: render tests for shared components
- **Effort:** Large (spread across sprints)

### 6. Auth boilerplate duplicated across 2 apps

- **Files:**
- `apps/admin-desktop/src/app/auth-provider.tsx` vs `apps/responder-app/src/app/auth-provider.tsx`
- `apps/admin-desktop/src/app/protected-route.tsx` vs `apps/responder-app/src/app/protected-route.tsx`
- `apps/admin-desktop/src/app/firebase.ts` vs `apps/responder-app/src/app/firebase.ts`
- **Issues:** Same patterns, slightly different implementations (claims types, role checks). Fixes in one don't propagate.
- **Action:** Move auth provider + protected route to `shared-ui` or `shared-firebase`. Keep role-checking as props/config.
- **Effort:** Medium (~2 new files in shared package, ~4 files deleted from apps)

### 7. Inconsistent error handling (5 catch patterns)

- **Patterns found:**
- `catch (err: unknown)` β€” ~51 occurrences
- `catch (err)` β€” 13 occurrences (implicit any)
- `catch (error)` β€” 5 occurrences
- `catch (e: unknown)` β€” 2 occurrences
- `catch {}` β€” **2** in production source (intentional exceptions documented below)
- **Files with bare `catch {}` (remaining):**
- `functions/src/services/fcm-send.ts` (1Γ—) β€” outer catch intentional for retry logic (has server-side console.error)
- `functions/src/triggers/inbox-reconciliation-sweep.ts` (1Γ—) β€” transaction contention intentional skip (has comment)
- `scripts/phase-3c/acceptance.ts` (2Γ—) β€” scripts, not production source
- **Recently fixed:**
- ~~`apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx`~~ βœ…
- ~~`apps/citizen-pwa/src/services/draft-store.ts`~~ βœ…
- ~~`apps/citizen-pwa/src/hooks/useOnlineStatus.ts`~~ βœ…
- ~~`packages/shared-sms-parser/src/inbound.ts`~~ βœ…
- ~~`packages/shared-validators/src/msisdn.ts`~~ βœ…
- ~~`functions/src/services/sms-providers/semaphore.ts`~~ βœ…
- ~~`functions/src/triggers/on-media-finalize.ts`~~ βœ…
- ~~`functions/src/http/sms-inbound.ts`~~ βœ…
- ~~`functions/src/firestore/sms-inbound-processor.ts`~~ βœ…
- **Impact:** Silent failures in production. Error monitoring tools see nothing.
- **Action:** Enforce `catch (err: unknown) { logError(err) }` via lint rule or codemod. Convert remaining `catch (err)` and `catch (error)` implicit-any patterns.
- **Effort:** Medium (~14 files, ~30 lines)

### 8. `console.log` in production code

- **File:** `packages/shared-validators/src/logging.ts:87`
- **Impact:** Pollutes production logs. Could leak PII.
- **Action:** Replace with structured logger or remove.
- **Effort:** Tiny (~1 file, ~3 lines)

---

## 🟒 P3 β€” Cleanup / Polish

### 9. `any` types in source and tests

- **Source files:**
- `functions/src/services/fcm-send.ts` β€” `batchResponse: any`
- **Test files:**
- `functions/src/__tests__/rules/*.test.ts` β€” `db: any`
- `functions/src/__tests__/callables/close-report.test.ts` β€” `doc: any`
- `functions/src/__tests__/acceptance/phase-4a-acceptance.test.ts` β€” `db: any`, `rtdb: any`
- **Note:** Test file `any` casts under `functions/src/__tests__/` are intentional for Firebase emulator compatibility and should NOT be flagged as tech-debt. These are required for emulator mock setup.
- **Action:** Replace source file `any` usages with proper Firestore types or `unknown`. Test file casts are exempt.
- **Effort:** Small

### 10. Single lingering TODO

- **File:** `packages/shared-validators/src/sms-templates.ts:1`
- **Text:** `// TODO(phase-5): move template bodies to Firestore for CMS-driven editing.`
- **Action:** Ticket it or implement if Phase 5 is active.
- **Effort:** N/A (planning)

---

## Recommended Execution Order

1. **P1:** Extract `Step2WhoWhere.tsx` into sub-components (2-3 sessions)
2. **P1:** Split `inbound.ts` into modules (gazetteer, Levenshtein, auto-reply, parser) (1-2 sessions)
3. **P2:** Add tests to `admin-desktop` critical paths (3-4 sessions)
4. **P2:** Consolidate auth provider into `shared-firebase` (2 sessions)
5. **P2:** Standardize remaining `catch (err)` / `catch (error)` implicit-any patterns (1 session)
6. **P3:** Remove `any` types and `console.log` (1 session)

---

## Stats

- **Total source files:** ~250
- **Total test files:** ~410 (but heavily concentrated in `functions`)
- **Lines of code:** ~14,665
- **Test coverage gaps:** 5 packages/apps at 0-1 tests
- **Bare `catch {}` blocks:** 2 in production source (2 in scripts)
- `console.log` in src: 1
- `TODO` in src: 1
85 changes: 85 additions & 0 deletions functions/src/__tests__/callables/https-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { HttpsError } from 'firebase-functions/v2/https'
import {
BANTAYOG_TO_HTTPS_CODE,
bantayogErrorToHttps,
requireAuth,
} from '../../callables/https-error.js'
import { BantayogError, BantayogErrorCode } from '@bantayog/shared-validators'

describe('BANTAYOG_TO_HTTPS_CODE', () => {
it('maps every BantayogErrorCode to a FunctionsErrorCode', () => {
// Iterate actual enum values, not map keys, to catch unmapped entries
const codes = Object.values(BantayogErrorCode).filter(
(value): value is BantayogErrorCode => typeof value === 'string',
)
expect(codes.length).toBeGreaterThan(0)
for (const code of codes) {
expect(BANTAYOG_TO_HTTPS_CODE[code]).toBeDefined()
expect(typeof BANTAYOG_TO_HTTPS_CODE[code]).toBe('string')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})

describe('bantayogErrorToHttps', () => {
it('converts a BantayogError to an HttpsError with the right code', () => {
const err = new BantayogError(BantayogErrorCode.VALIDATION_ERROR, 'bad input', { field: 'x' })
const httpsErr = bantayogErrorToHttps(err)
expect(httpsErr).toBeInstanceOf(HttpsError)
expect(httpsErr.code).toBe('invalid-argument')
expect(httpsErr.message).toBe('bad input')
expect(httpsErr.details).toEqual({ field: 'x' })
})

it('converts NOT_FOUND to not-found', () => {
const err = new BantayogError(BantayogErrorCode.NOT_FOUND, 'missing')
const httpsErr = bantayogErrorToHttps(err)
expect(httpsErr.code).toBe('not-found')
})
})

describe('requireAuth', () => {
it('throws unauthenticated when request.auth is null', () => {
expect(() => requireAuth({ auth: null }, ['municipal_admin'])).toThrow(HttpsError)
expect(() => requireAuth({ auth: null }, ['municipal_admin'])).toThrow('sign-in required')
})

it('throws unauthenticated when request.auth is undefined', () => {
expect(() => requireAuth({}, ['municipal_admin'])).toThrow(HttpsError)
})

it('throws permission-denied when role is not in allowed list', () => {
const request = {
auth: {
uid: 'u1',
token: { role: 'citizen' },
},
}
expect(() => requireAuth(request, ['municipal_admin'])).toThrow('role citizen is not allowed')
})

it('throws permission-denied when role is missing', () => {
const request = {
auth: {
uid: 'u1',
token: {},
},
}
expect(() => requireAuth(request, ['municipal_admin'])).toThrow('role undefined is not allowed')
})

it('returns uid and claims when role is allowed', () => {
const request = {
auth: {
uid: 'u1',
token: { role: 'municipal_admin', municipalityId: 'm1' },
},
}
const result = requireAuth(request, ['municipal_admin', 'superadmin'])
expect(result.uid).toBe('u1')
expect(result.claims).toEqual({
role: 'municipal_admin',
municipalityId: 'm1',
})
})
})
Loading
Loading