-
Notifications
You must be signed in to change notification settings - Fork 0
fix: code quality and security refactor β audit-driven fixes across monorepo #61
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
Changes from 9 commits
118a6cc
3e568a0
c3785ee
5964a94
661f5e8
41ac6d8
b3ac175
e4217e9
cddbb24
57a7f4f
3da508a
21564cc
25cd8db
271b796
b0ecaa2
20f33c3
fe13c0d
c67545c
8a2b5e1
1ba7782
a0ad82c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| # Refactor Audit β 2026-04-23 | ||
|
|
||
| **Scope:** Full monorepo (apps/_, packages/_, functions) | ||
| **Health Check:** Lint/Typecheck β 25/25 passing | Tests β 2 failures in `shared-sms-parser` | ||
|
|
||
| --- | ||
|
|
||
| ## π΄ P0 β Fix Before Anything Else | ||
|
|
||
| ### 1. `shared-sms-parser` has 2 failing tests | ||
|
|
||
| - **File:** `packages/shared-sms-parser/src/__tests__/inbound.test.ts` | ||
| - **Failures:** | ||
| 1. `returns candidates on ambiguous barangay match` β expects `confidence: 'low'`, gets `'none'` | ||
| 2. `parses accident synonym AKSIDENTE` β expects `confidence: 'high'`, gets `'low'` | ||
| - **Impact:** SMS ingestion pipeline is unreliable. Broken parser = lost disaster reports. | ||
| - **Action:** Fix parser logic in `packages/shared-sms-parser/src/inbound.ts` (lines 419+), or update tests if expectations drifted. | ||
| - **Effort:** Small (~1 file, ~20 lines) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| --- | ||
|
|
||
| ## π P1 β High Risk / Hard to Maintain | ||
|
|
||
| ### 2. `Step2WhoWhere.tsx` is a 707-line god component | ||
|
|
||
| - **File:** `apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx` | ||
| - **Issues:** | ||
| - 707 lines in a single React component | ||
| - 2 bare `catch {}` blocks (lines 380, 423) that swallow errors silently | ||
| - 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) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| ### 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) | ||
| - **Impact:** The failing tests live here. 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. Entire apps/packages have zero tests | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| | 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 (6+ catch patterns) | ||
|
|
||
| - **Patterns found:** | ||
| - `catch (err: unknown)` β 33 occurrences | ||
| - `catch {}` β 13 occurrences (swallows errors) | ||
| - `catch (err)` β 10 occurrences (implicit any) | ||
| - `catch (error)` β 4 occurrences | ||
| - `catch ((_e: unknown) => {` β 3 occurrences | ||
| - `catch (e: unknown)` β 2 occurrences | ||
| - **Files with bare `catch {}`:** | ||
| - `apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx` (2Γ) | ||
| - `apps/citizen-pwa/src/services/draft-store.ts` (1Γ) | ||
| - `apps/citizen-pwa/src/hooks/useOnlineStatus.ts` (1Γ) | ||
| - `packages/shared-sms-parser/src/inbound.ts` (1Γ) | ||
| - `packages/shared-validators/src/msisdn.ts` (1Γ) | ||
| - `functions/src/services/fcm-send.ts` (2Γ) | ||
| - `functions/src/services/sms-providers/semaphore.ts` (1Γ) | ||
| - `functions/src/triggers/inbox-reconciliation-sweep.ts` (1Γ) | ||
| - `functions/src/triggers/on-media-finalize.ts` (1Γ) | ||
| - `functions/src/http/sms-inbound.ts` (1Γ) | ||
| - `functions/src/firestore/sms-inbound-processor.ts` (1Γ) | ||
| - **Impact:** Silent failures in production. Error monitoring tools see nothing. | ||
| - **Action:** Enforce `catch (err: unknown) { logError(err) }` via lint rule or codemod. Start with citizen-pwa and functions. | ||
| - **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 (acceptable but should be typed):** | ||
| - `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` | ||
| - **Action:** Replace with proper Firestore types or `unknown`. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| - **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. **P0:** Fix `shared-sms-parser` tests (1 session) | ||
| 2. **P1:** Extract `Step2WhoWhere.tsx` into sub-components (2-3 sessions) | ||
| 3. **P1:** Split `inbound.ts` into modules + fix `catch {}` (1-2 sessions) | ||
| 4. **P2:** Add tests to `admin-desktop` critical paths (3-4 sessions) | ||
| 5. **P2:** Consolidate auth provider into `shared-firebase` (2 sessions) | ||
| 6. **P2:** Standardize error handling across `catch {}` sites (1 session) | ||
| 7. **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:** 13 | ||
| - `console.log` in src: 1 | ||
| - `TODO` in src: 1 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| 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', () => { | ||
| const codes = Object.keys(BANTAYOG_TO_HTTPS_CODE) as BantayogErrorCode[] | ||
| 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') | ||
| } | ||
|
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', | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,9 +59,9 @@ export function createSemaphoreSmsProvider(): SmsProvider { | |
| let data: SemaphoreResponse = {} | ||
| try { | ||
| data = (await res.json()) as SemaphoreResponse | ||
| } catch { | ||
| } catch (err: unknown) { | ||
| throw new SmsProviderRetryableError( | ||
| `semaphore ${res.status.toString()}: unparseable response`, | ||
| `semaphore ${res.status.toString()}: unparseable response (${String(err)})`, | ||
| res.ok || res.status >= 500 ? 'provider_error' : 'network', | ||
| ) | ||
|
Comment on lines
+62
to
71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-retryable 4xx parse failures are being retried. At Line 65, a non-JSON 4xx response can be classified as retryable and deferred, even when itβs likely a permanent request/config issue. π§ Proposed fix let data: SemaphoreResponse = {}
try {
data = (await res.json()) as SemaphoreResponse
} catch (err: unknown) {
+ if (!res.ok && res.status < 500 && res.status !== 429) {
+ return { accepted: false, reason: 'other' as const, latencyMs: 0 }
+ }
throw new SmsProviderRetryableError(
`semaphore ${res.status.toString()}: unparseable response (${String(err)})`,
- res.ok || res.status >= 500 ? 'provider_error' : 'network',
+ res.status === 429 ? 'rate_limited' : 'provider_error',
)
}π€ Prompt for AI Agents |
||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.