diff --git a/docs/learnings.md b/docs/learnings.md index a0da25c5..e0bc3320 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -30,13 +30,14 @@ - Fail explicitly on missing auth/scope; no permissive fallbacks. - Normalize fields on both read and write paths. - Verify Firestore Rules function signatures match call sites. -- Staff MFA audits must inspect `multiFactor.enrolledFactors` directly; custom claims only describe role and access, not whether TOTP is actually enrolled. +- Staff MFA audits must inspect `multiFactor.enrolledFactors` directly; `CustomClaims.mfaEnrolled` (or any custom claim) can record intent but is not the source of truth — only `enrolledFactors` reflects whether TOTP is actually enrolled and the factor type. ## Testing - `vi.hoisted()` mocks must be created inside the hoisted callback. - `requestAnimationFrame` in Vitest: capture callback explicitly, don’t assume timers. - A passing test is not enough; confirm it exercises the changed path. +- BigQuery summary jobs should keep the core dependency-injected; mocking `query()` directly is simpler than testing the scheduler wrapper. - Never mix Admin SDK and Client SDK Firestore calls in the same context. - Callable error handling: use runtime client code (`not-found`), not internal enum names. - Wrap `waitFor(() => expect(...))` assertion body in braces to avoid `no-confusing-void-expression`. diff --git a/docs/progress.md b/docs/progress.md index a906a541..2bf847e6 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -83,6 +83,7 @@ - **Code quality + security refactor (2026-04-23)** — 14 `catch (err: unknown)` conversions, error logging improvements, 8 new https-error tests. - **Phase 5 Responder MVP (2026-04-23)** — Decline callable, queue/detail hooks, Playwright smoke (6 pass, 4 skipped). Fixed stale `enforceAppCheck` binary causing E2E `internal` error. - **3-Step Wizard Wiring (2026-04-23)** — WizardContainer + SubmissionPanel. 101 tests pass. +- **Phase 8B cost snapshot writer (2026-04-28)** — Scheduled BigQuery summarizer now writes `costSnapshot` into `system_health/latest`; focused writer test passes. - **Citizen PWA Firebase env fallback (2026-04-22)** — Graceful degradation when `VITE_FIREBASE_*` vars missing. - **Map Tab (2026-04-22)** — Full Leaflet implementation with public incident + own-report layers. - **PR #56 Review Fixes (2026-04-22)** — 18 fixes across citizen PWA: guard empty report ref, canvas blob preview for CodeQL, offline state fixes, schema alignment. diff --git a/docs/superpowers/plans/2026-04-28-phase8b.md b/docs/superpowers/plans/2026-04-28-phase8b.md new file mode 100644 index 00000000..4c5bf6a6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-phase8b.md @@ -0,0 +1,1235 @@ +# Phase 8B Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Phase 8B storm-signal control plane: canonical TCWS signal lifecycle, projected operator read model, scraper/quarantine/replay flows, health and cost summaries, and the upgraded Admin Desktop `System Health` surface. + +**Architecture:** Keep `hazard_signals` as the canonical write model and add a projected `hazard_signal_status/current` read model plus richer `system_health/latest` summaries. All client writes stay server-authoritative through callables or scheduled functions, while the UI remains read-first and consumes only projected/aggregated documents. + +**Tech Stack:** TypeScript, Firebase Cloud Functions v2, Firestore, Firestore Rules, Vitest, React, Firebase Web SDK, BigQuery, Zod + +--- + +## File Map + +### Shared schemas and constants + +- Modify: `packages/shared-validators/src/hazard.ts` + Expand the hazard signal schema and add the projected `hazard_signal_status` schema. +- Modify: `packages/shared-validators/src/index.ts` + Re-export new hazard schemas/types for apps and functions. +- Reuse: `packages/shared-validators/src/municipalities.ts` + Source of truth for province-wide municipality normalization. +- Modify: `packages/shared-validators/src/shared-schemas.test.ts` + Add schema coverage for new signal and projection documents. + +### Rules and backend write paths + +- Modify: `infra/firebase/firestore.rules` + Add `hazard_signal_status` read rules for privileged staff and keep writes backend-only. +- Modify: `functions/src/__tests__/rules/public-collections.rules.test.ts` + Cover new projection-document reads and negative cases. +- Create: `functions/src/callables/declare-hazard-signal.ts` + Manual declare and clear callables. +- Create: `functions/src/callables/replay-signal-dead-letter.ts` + Category-based replay path for `pagasa_scraper` and `hazard_signal_projection`. +- Modify: `functions/src/index.ts` + Export new callables and scheduled functions. + +### Projection and lifecycle workers + +- Create: `functions/src/services/hazard-signal-projector.ts` + Pure projection logic and Firestore write helper for `hazard_signal_status/current`. +- Create: `functions/src/__tests__/services/hazard-signal-projector.test.ts` + Unit tests for precedence, expiry, stale scraper, and non-revival of superseded signals. +- Create: `functions/src/triggers/hazard-signal-expiry-sweep.ts` + Scheduled expiry lifecycle transition and projector replay. +- Create: `functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts` + Sweep tests including concurrent recompute semantics. + +### Scraper, quarantine, and health aggregation + +- Create: `functions/src/triggers/pagasa-signal-poll.ts` + Scheduled scraper ingest with parse, quarantine, degraded-state write, and dead-letter emission. +- Create: `functions/src/__tests__/triggers/pagasa-signal-poll.test.ts` + Parse success, parse failure, quarantine, dedupe, and auto-recovery tests. +- Modify: `functions/src/triggers/audit-export-health-check.ts` + Extend `system_health/latest` with signal and scraper summary fields without regressing audit-gap checks. +- Create: `functions/src/triggers/cost-snapshot-writer.ts` + Scheduled BigQuery summarizer for operator-facing cost snapshot. +- Create: `functions/src/__tests__/triggers/cost-snapshot-writer.test.ts` + Cost-baseline and anomaly tests. + +### Admin Desktop + +- Modify: `apps/admin-desktop/src/services/callables.ts` + Add typed wrappers for declare, clear, and replay actions. +- Create: `apps/admin-desktop/src/__tests__/SystemHealthPage.test.tsx` + UI tests for ranked degradation, signal cards, guarded actions, and stale-state rendering. +- Modify: `apps/admin-desktop/src/pages/SystemHealthPage.tsx` + Replace the Phase 7 stub with the 8B operator surface. + +### Docs touched during implementation + +- Modify: `docs/progress.md` + Record the delivered Phase 8B slices and outstanding follow-up. +- Modify: `docs/learnings.md` + Capture any new implementation pitfalls discovered while shipping signal control. + +--- + +### Task 1: Expand Shared Hazard Schemas + +**Files:** + +- Modify: `packages/shared-validators/src/hazard.ts` +- Modify: `packages/shared-validators/src/index.ts` +- Test: `packages/shared-validators/src/shared-schemas.test.ts` + +- [ ] **Step 1: Write the failing schema tests** + +```ts +describe('hazard schemas', () => { + it('accepts a manual tcws signal lifecycle document', () => { + expect( + hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 4, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + status: 'active', + validFrom: ts, + validUntil: ts + 60 * 60 * 1000, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'PAGASA radio confirmation', + schemaVersion: 1, + }), + ).toMatchObject({ status: 'active', signalLevel: 4 }) + }) + + it('rejects province scope when affectedMunicipalityIds is empty', () => { + expect(() => + hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 3, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: [], + status: 'active', + validFrom: ts, + validUntil: ts + 1, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'test', + schemaVersion: 1, + }), + ).toThrow() + }) + + it('accepts a projected hazard signal status document', () => { + expect( + hazardSignalStatusDocSchema.parse({ + active: true, + effectiveSignalId: 'sig-1', + effectiveLevel: 4, + effectiveSource: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + effectiveScopes: [ + { municipalityId: 'daet', signalLevel: 4, source: 'manual', signalId: 'sig-1' }, + ], + validUntil: ts + 60 * 60 * 1000, + manualOverrideActive: true, + scraperDegraded: false, + lastProjectedAt: ts, + degradedReasons: [], + schemaVersion: 1, + }), + ).toMatchObject({ active: true }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/packages/shared-validators && npx vitest run src/shared-schemas.test.ts` + +Expected: FAIL with missing `hazardSignalStatusDocSchema` export and schema mismatches for the current `hazardSignalDocSchema`. + +- [ ] **Step 3: Write the minimal schema implementation** + +```ts +const signalSourceSchema = z.enum(['manual', 'scraper']) +const signalStatusSchema = z.enum(['active', 'cleared', 'expired', 'superseded', 'quarantined']) + +export const hazardSignalDocSchema = z + .object({ + hazardType: z.literal('tropical_cyclone'), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + scopeType: z.enum(['province', 'municipalities']), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + status: signalStatusSchema, + validFrom: z.number().int(), + validUntil: z.number().int(), + recordedAt: z.number().int(), + rawSource: z.string().min(1), + recordedBy: z.string().min(1).optional(), + reason: z.string().min(1).optional(), + clearedAt: z.number().int().optional(), + clearedBy: z.string().min(1).optional(), + supersededBy: z.string().min(1).optional(), + schemaVersion: z.number().int().positive(), + }) + .strict() + .refine( + (doc) => + doc.scopeType !== 'province' || + doc.affectedMunicipalityIds.length === CAMARINES_NORTE_MUNICIPALITIES.length, + { message: 'province scope must normalize to the full municipality set' }, + ) + +export const hazardSignalStatusDocSchema = z + .object({ + active: z.boolean(), + effectiveSignalId: z.string().min(1).optional(), + effectiveLevel: z.number().int().min(1).max(5).optional(), + effectiveSource: signalSourceSchema.optional(), + scopeType: z.enum(['province', 'municipalities']).optional(), + affectedMunicipalityIds: z.array(z.string().min(1)), + effectiveScopes: z.array( + z + .object({ + municipalityId: z.string().min(1), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + signalId: z.string().min(1), + }) + .strict(), + ), + validUntil: z.number().int().optional(), + manualOverrideActive: z.boolean(), + scraperLastSuccessAt: z.number().int().optional(), + scraperLastFailureAt: z.number().int().optional(), + scraperDegraded: z.boolean(), + lastProjectedAt: z.number().int(), + degradedReasons: z.array(z.string().min(1)), + schemaVersion: z.number().int().positive(), + }) + .strict() +``` + +- [ ] **Step 4: Export the new schema and type** + +```ts +export { + hazardZoneDocSchema, + hazardZoneHistoryDocSchema, + hazardSignalDocSchema, + hazardSignalStatusDocSchema, +} from './hazard.js' +export type { + HazardZoneDoc, + HazardZoneHistoryDoc, + HazardSignalDoc, + HazardSignalStatusDoc, +} from './hazard.js' +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/packages/shared-validators && npx vitest run src/shared-schemas.test.ts` + +Expected: PASS with new hazard lifecycle and projection schemas covered. + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared-validators/src/hazard.ts packages/shared-validators/src/index.ts packages/shared-validators/src/shared-schemas.test.ts +git commit -m "feat(phase8b): expand hazard signal schemas" +``` + +### Task 2: Add Projection Read Rules + +**Files:** + +- Modify: `infra/firebase/firestore.rules` +- Test: `functions/src/__tests__/rules/public-collections.rules.test.ts` + +- [ ] **Step 1: Write the failing rules tests** + +```ts +describe('hazard_signal_status', () => { + it('superadmin with active privileged claim can read hazard signal status', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertSucceeds(getDoc(doc(db, 'hazard_signal_status', 'current'))) + }) + + it('citizen cannot read hazard signal status', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertFails(getDoc(doc(db, 'hazard_signal_status', 'current'))) + }) + + it('client writes to hazard signal status remain blocked', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertFails( + setDoc(doc(db, 'hazard_signal_status', 'current'), { + active: false, + affectedMunicipalityIds: [], + effectiveScopes: [], + manualOverrideActive: false, + scraperDegraded: false, + lastProjectedAt: ts, + degradedReasons: [], + schemaVersion: 1, + }), + ) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/rules/public-collections.rules.test.ts` + +Expected: FAIL because `hazard_signal_status` currently falls through to default deny without explicit read coverage. + +- [ ] **Step 3: Add the rule block** + +```txt +match /hazard_signal_status/{id} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/rules/public-collections.rules.test.ts` + +Expected: PASS for the new projection collection and no regressions on other public-collection rules. + +- [ ] **Step 5: Commit** + +```bash +git add infra/firebase/firestore.rules functions/src/__tests__/rules/public-collections.rules.test.ts +git commit -m "feat(phase8b): add hazard signal projection rules" +``` + +### Task 3: Build the Pure Projector + +**Files:** + +- Create: `functions/src/services/hazard-signal-projector.ts` +- Test: `functions/src/__tests__/services/hazard-signal-projector.test.ts` + +- [ ] **Step 1: Write the failing projector tests** + +```ts +it('prefers manual signals per municipality regardless of lower scraper level', () => { + const result = projectHazardSignalStatus({ + now: 1713350400000, + signals: [ + manualSignal({ id: 'm-1', affectedMunicipalityIds: ['daet'], signalLevel: 3 }), + scraperSignal({ id: 's-1', affectedMunicipalityIds: ['daet'], signalLevel: 4 }), + ], + }) + + expect(result.effectiveScopes).toEqual([ + { municipalityId: 'daet', signalLevel: 3, source: 'manual', signalId: 'm-1' }, + ]) + expect(result.effectiveLevel).toBe(3) +}) + +it('does not revive a superseded manual signal after the newer one expires', () => { + const result = projectHazardSignalStatus({ + now: 1713354000000, + signals: [ + manualSignal({ + id: 'm-1', + affectedMunicipalityIds: ['daet'], + signalLevel: 3, + status: 'superseded', + }), + manualSignal({ + id: 'm-2', + affectedMunicipalityIds: ['daet'], + signalLevel: 4, + status: 'expired', + }), + ], + }) + + expect(result.effectiveScopes).toEqual([]) + expect(result.active).toBe(false) +}) + +it('ignores expired scraper state when manual clear reveals fallback', () => { + const result = projectHazardSignalStatus({ + now: 1713354000000, + signals: [ + scraperSignal({ id: 's-1', affectedMunicipalityIds: ['daet'], validUntil: 1713350399999 }), + ], + }) + + expect(result.active).toBe(false) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/services/hazard-signal-projector.test.ts` + +Expected: FAIL because the projector service does not exist yet. + +- [ ] **Step 3: Write the minimal projector** + +```ts +export function projectHazardSignalStatus(input: { + now: number + signals: HazardSignalDoc[] + scraperLastSuccessAt?: number + scraperLastFailureAt?: number +}): HazardSignalStatusDoc { + const effectiveByMunicipality = new Map< + string, + { + municipalityId: string + signalLevel: 1 | 2 | 3 | 4 | 5 + source: 'manual' | 'scraper' + signalId: string + } + >() + + const activeSignals = input.signals.filter( + (signal) => signal.status === 'active' && signal.validUntil > input.now, + ) + + const compare = (a: HazardSignalDoc, b: HazardSignalDoc) => { + if (a.source !== b.source) return a.source === 'manual' ? -1 : 1 + if (a.recordedAt !== b.recordedAt) return b.recordedAt - a.recordedAt + if (a.signalLevel !== b.signalLevel) return b.signalLevel - a.signalLevel + return a.rawSource.localeCompare(b.rawSource) + } + + for (const municipalityId of CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) { + const candidates = activeSignals + .filter((signal) => signal.affectedMunicipalityIds.includes(municipalityId)) + .sort(compare) + + const winner = candidates[0] + if (!winner) continue + + effectiveByMunicipality.set(municipalityId, { + municipalityId, + signalLevel: winner.signalLevel as 1 | 2 | 3 | 4 | 5, + source: winner.source, + signalId: winner.rawSource, + }) + } + + const effectiveScopes = [...effectiveByMunicipality.values()] + return { + active: effectiveScopes.length > 0, + effectiveSignalId: effectiveScopes[0]?.signalId, + effectiveLevel: + effectiveScopes.length > 0 + ? (Math.max(...effectiveScopes.map((scope) => scope.signalLevel)) as 1 | 2 | 3 | 4 | 5) + : undefined, + effectiveSource: effectiveScopes.some((scope) => scope.source === 'manual') + ? 'manual' + : effectiveScopes[0]?.source, + scopeType: + effectiveScopes.length === CAMARINES_NORTE_MUNICIPALITIES.length + ? 'province' + : effectiveScopes.length > 0 + ? 'municipalities' + : undefined, + affectedMunicipalityIds: effectiveScopes.map((scope) => scope.municipalityId), + effectiveScopes, + validUntil: activeSignals.map((signal) => signal.validUntil).sort()[0], + manualOverrideActive: effectiveScopes.some((scope) => scope.source === 'manual'), + scraperLastSuccessAt: input.scraperLastSuccessAt, + scraperLastFailureAt: input.scraperLastFailureAt, + scraperDegraded: Boolean( + input.scraperLastFailureAt && + (!input.scraperLastSuccessAt || input.scraperLastFailureAt > input.scraperLastSuccessAt), + ), + lastProjectedAt: input.now, + degradedReasons: [], + schemaVersion: 1, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/services/hazard-signal-projector.test.ts` + +Expected: PASS for precedence, expired fallback, and non-revival semantics. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/services/hazard-signal-projector.ts functions/src/__tests__/services/hazard-signal-projector.test.ts +git commit -m "feat(phase8b): add hazard signal projector" +``` + +### Task 4: Add Manual Declare and Clear Callables + +**Files:** + +- Create: `functions/src/callables/declare-hazard-signal.ts` +- Modify: `functions/src/index.ts` +- Test: `functions/src/__tests__/callables/declare-hazard-signal.test.ts` + +- [ ] **Step 1: Write the failing callable tests** + +```ts +it('rejects non-superadmin callers', async () => { + await expect( + declareHazardSignalCore( + mockDb, + { + signalLevel: 3, + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + validUntil: ts + 60_000, + reason: 'test', + }, + { uid: 'muni-1', role: 'municipal_admin' }, + ), + ).rejects.toThrow('permission-denied') +}) + +it('normalizes province scope to all municipalities', async () => { + const result = await declareHazardSignalCore( + mockDb, + { + signalLevel: 4, + scopeType: 'province', + affectedMunicipalityIds: ['daet'], + validUntil: ts + 60_000, + reason: 'test', + }, + { uid: 'super-1', role: 'provincial_superadmin' }, + ) + + expect(result.affectedMunicipalityIds).toEqual(CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/callables/declare-hazard-signal.test.ts` + +Expected: FAIL because the callable module does not exist yet. + +- [ ] **Step 3: Write the minimal callable** + +```ts +const declareHazardSignalInputSchema = z.object({ + signalLevel: z.number().int().min(1).max(5), + scopeType: z.enum(['province', 'municipalities']), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + validUntil: z.number().int(), + reason: z.string().min(1).max(500), +}) + +export async function declareHazardSignalCore( + db: Firestore, + input: z.infer, + actor: { uid: string; role: string }, +) { + if (actor.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'superadmin_required') + } + + const normalizedMunicipalityIds = + input.scopeType === 'province' + ? CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id) + : input.affectedMunicipalityIds + + const signalId = crypto.randomUUID() + const payload = { + hazardType: 'tropical_cyclone' as const, + signalLevel: input.signalLevel, + source: 'manual' as const, + scopeType: input.scopeType, + affectedMunicipalityIds: normalizedMunicipalityIds, + status: 'active' as const, + validFrom: Date.now(), + validUntil: input.validUntil, + recordedAt: Date.now(), + rawSource: 'manual_superadmin', + recordedBy: actor.uid, + reason: input.reason, + schemaVersion: 1, + } + + hazardSignalDocSchema.parse(payload) + await db.collection('hazard_signals').doc(signalId).set(payload) + return { signalId, affectedMunicipalityIds: normalizedMunicipalityIds } +} +``` + +- [ ] **Step 4: Export the callable** + +```ts +export { declareHazardSignal, clearHazardSignal } from './callables/declare-hazard-signal.js' +export { replaySignalDeadLetter } from './callables/replay-signal-dead-letter.js' +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/callables/declare-hazard-signal.test.ts` + +Expected: PASS for superadmin gating and province normalization. + +- [ ] **Step 6: Commit** + +```bash +git add functions/src/callables/declare-hazard-signal.ts functions/src/__tests__/callables/declare-hazard-signal.test.ts functions/src/index.ts +git commit -m "feat(phase8b): add hazard signal callables" +``` + +### Task 5: Wire Projection Repair and Expiry Sweep + +**Files:** + +- Create: `functions/src/triggers/hazard-signal-expiry-sweep.ts` +- Modify: `functions/src/services/hazard-signal-projector.ts` +- Test: `functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts` + +- [ ] **Step 1: Write the failing sweep tests** + +```ts +it('marks expired active manual signals and rewrites the projected status', async () => { + await seedSignal(db, manualSignal({ id: 'm-1', validUntil: NOW - 1 })) + + const result = await hazardSignalExpirySweepCore({ db, now: () => NOW }) + + expect(result.expired).toBe(1) + expect(await getSignalStatus(db)).toMatchObject({ active: false }) +}) + +it('recomputes projection even when a scraper write lands during the same sweep window', async () => { + await seedSignal(db, manualSignal({ id: 'm-1', validUntil: NOW - 1 })) + await seedSignal(db, scraperSignal({ id: 's-1', validUntil: NOW + 60_000 })) + + await hazardSignalExpirySweepCore({ db, now: () => NOW }) + + expect(await getSignalStatus(db)).toMatchObject({ + active: true, + effectiveSource: 'scraper', + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts` + +Expected: FAIL because the sweep does not exist yet. + +- [ ] **Step 3: Write the minimal sweep** + +```ts +export async function hazardSignalExpirySweepCore(input: { db: Firestore; now?: () => number }) { + const now = input.now ?? (() => Date.now()) + const snap = await input.db + .collection('hazard_signals') + .where('status', '==', 'active') + .where('validUntil', '<=', now()) + .get() + + let expired = 0 + for (const signalDoc of snap.docs) { + await signalDoc.ref.update({ status: 'expired' }) + expired++ + } + + await replayHazardSignalProjection({ db: input.db, now: now() }) + return { expired } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts` + +Expected: PASS with expiry and recompute semantics covered. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/triggers/hazard-signal-expiry-sweep.ts functions/src/services/hazard-signal-projector.ts functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts functions/src/index.ts +git commit -m "feat(phase8b): add hazard signal expiry sweep" +``` + +### Task 6: Implement Scraper, Quarantine, and Auto-Recovery + +**Files:** + +- Create: `functions/src/triggers/pagasa-signal-poll.ts` +- Test: `functions/src/__tests__/triggers/pagasa-signal-poll.test.ts` + +- [ ] **Step 1: Write the failing scraper tests** + +```ts +it('writes a canonical scraper signal for valid parsed data', async () => { + const result = await pagasaSignalPollCore({ + db, + fetchHtml: async () => SAMPLE_HTML_TCWS_3_DAET, + now: () => NOW, + }) + + expect(result.status).toBe('updated') + expect(await getDoc(doc(db, 'hazard_signal_status', 'current'))).toMatchObject({ + data: expect.objectContaining({ active: true, effectiveSource: 'scraper' }), + }) +}) + +it('quarantines suspicious but parseable output', async () => { + const result = await pagasaSignalPollCore({ + db, + fetchHtml: async () => SAMPLE_HTML_WITH_UNKNOWN_MUNICIPALITY, + now: () => NOW, + }) + + expect(result.status).toBe('quarantined') +}) + +it('clears degraded state after the next successful non-quarantined run', async () => { + await pagasaSignalPollCore({ db, fetchHtml: async () => BROKEN_HTML, now: () => NOW }) + const recovered = await pagasaSignalPollCore({ + db, + fetchHtml: async () => SAMPLE_HTML_TCWS_2_PROVINCE, + now: () => NOW + 60_000, + }) + + expect(recovered.scraperDegraded).toBe(false) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/pagasa-signal-poll.test.ts` + +Expected: FAIL because the poller does not exist yet. + +- [ ] **Step 3: Write the minimal scraper** + +```ts +export async function pagasaSignalPollCore(input: { + db: Firestore + fetchHtml: () => Promise + now?: () => number +}) { + const now = input.now ?? (() => Date.now()) + + try { + const html = await input.fetchHtml() + const parsed = parsePagasaSignal(html) + + if (!parsed.ok) { + await writeSignalDeadLetter(input.db, 'pagasa_scraper', parsed.reason, html, now()) + await markScraperDegraded(input.db, now(), 'parse_failed') + return { status: 'failed', scraperDegraded: true } + } + + if (!isTrustedParsedSignal(parsed.value)) { + await input.db + .collection('hazard_signals') + .doc(parsed.value.signalId) + .set({ + ...parsed.value, + status: 'quarantined', + schemaVersion: 1, + }) + await markScraperDegraded(input.db, now(), 'quarantined_output') + return { status: 'quarantined', scraperDegraded: true } + } + + await upsertScraperSignal(input.db, parsed.value, now()) + await clearScraperDegraded(input.db, now()) + await replayHazardSignalProjection({ db: input.db, now: now() }) + return { status: 'updated', scraperDegraded: false } + } catch (err) { + await writeSignalDeadLetter(input.db, 'pagasa_scraper', String(err), {}, now()) + await markScraperDegraded(input.db, now(), 'fetch_failed') + return { status: 'failed', scraperDegraded: true } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/pagasa-signal-poll.test.ts` + +Expected: PASS for parse success, quarantine, and auto-recovery paths. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/triggers/pagasa-signal-poll.ts functions/src/__tests__/triggers/pagasa-signal-poll.test.ts functions/src/index.ts +git commit -m "feat(phase8b): add pagasa signal poller" +``` + +### Task 7: Extend System Health and Cost Snapshot Writers + +**Files:** + +- Modify: `functions/src/triggers/audit-export-health-check.ts` +- Create: `functions/src/triggers/cost-snapshot-writer.ts` +- Test: `functions/src/__tests__/triggers/cost-snapshot-writer.test.ts` + +- [ ] **Step 1: Write the failing health/cost tests** + +```ts +it('writes signal summary fields into system_health/latest', async () => { + const result = await auditExportHealthCheckCore({ + db, + bigQuery: fakeBq({ streamRows, batchRows }), + signalStatus: { + active: true, + effectiveLevel: 4, + effectiveSource: 'manual', + scraperDegraded: true, + degradedReasons: ['parse_failed'], + }, + now: () => NOW, + }) + + expect(result.healthy).toBe(false) + expect(await getHealthDoc(db)).toMatchObject({ + signalActive: true, + signalLevel: 4, + scraperDegraded: true, + }) +}) + +it('writes today-vs-baseline cost summary and anomaly flag', async () => { + const result = await costSnapshotWriterCore({ + db, + bigQuery: fakeCostBq({ todayCost: 180, baselineCost: 100 }), + now: () => NOW, + }) + + expect(result.anomaly).toBe(true) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/cost-snapshot-writer.test.ts` + +Expected: FAIL because the cost writer and extended health summary do not exist yet. + +- [ ] **Step 3: Implement the minimal health/cost writers** + +```ts +await db.doc('system_health/latest').set( + { + streamingGapSeconds, + batchGapSeconds, + healthy, + checkedAt: FieldValue.serverTimestamp(), + signalActive: signalStatus.active, + signalLevel: signalStatus.effectiveLevel ?? null, + signalSource: signalStatus.effectiveSource ?? null, + scraperDegraded: signalStatus.scraperDegraded, + signalDegradedReasons: signalStatus.degradedReasons, + }, + { merge: true }, +) +``` + +```ts +export async function costSnapshotWriterCore(input: { + db: Firestore + bigQuery: BigQueryLike + now?: () => number +}) { + const [todayRows] = await input.bigQuery.query(TODAY_COST_SQL) + const [baselineRows] = await input.bigQuery.query(BASELINE_COST_SQL) + + const todayCost = extractCost(todayRows) + const baselineCost = extractCost(baselineRows) + const anomaly = baselineCost > 0 && todayCost >= baselineCost * 1.5 + + await input.db.doc('system_health/latest').set( + { + costSnapshot: { + todayCost, + baselineCost, + anomaly, + recordedAt: FieldValue.serverTimestamp(), + }, + }, + { merge: true }, + ) + + return { anomaly, todayCost, baselineCost } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/triggers/cost-snapshot-writer.test.ts` + +Expected: PASS for cost summary and merged health fields. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/triggers/audit-export-health-check.ts functions/src/triggers/cost-snapshot-writer.ts functions/src/__tests__/triggers/cost-snapshot-writer.test.ts functions/src/index.ts +git commit -m "feat(phase8b): add system health signal and cost summaries" +``` + +### Task 8: Add Replay Callable + +**Files:** + +- Create: `functions/src/callables/replay-signal-dead-letter.ts` +- Test: `functions/src/__tests__/callables/replay-signal-dead-letter.test.ts` +- Modify: `functions/src/index.ts` + +- [ ] **Step 1: Write the failing replay tests** + +```ts +it('replays hazard signal projection dead letters by category', async () => { + await seedDeadLetter(db, { + category: 'hazard_signal_projection', + payload: { signalIds: ['sig-1'] }, + }) + + const result = await replaySignalDeadLetterCore( + db, + { category: 'hazard_signal_projection' }, + actor, + ) + + expect(result.replayed).toBe(1) +}) + +it('rejects unsupported replay categories', async () => { + await expect( + replaySignalDeadLetterCore(db, { category: 'unknown' as never }, actor), + ).rejects.toThrow() +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/callables/replay-signal-dead-letter.test.ts` + +Expected: FAIL because the replay callable does not exist yet. + +- [ ] **Step 3: Implement the minimal replay callable** + +```ts +const replaySignalDeadLetterInputSchema = z.object({ + category: z.enum(['pagasa_scraper', 'hazard_signal_projection']), +}) + +export async function replaySignalDeadLetterCore( + db: Firestore, + input: z.infer, + actor: { uid: string; role: string }, +) { + if (actor.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'superadmin_required') + } + + const snap = await db + .collection('dead_letters') + .where('category', '==', input.category) + .limit(20) + .get() + for (const letter of snap.docs) { + if (input.category === 'hazard_signal_projection') { + await replayHazardSignalProjection({ db, now: Date.now() }) + } + if (input.category === 'pagasa_scraper') { + await replayPagasaScraperDeadLetter({ db, payload: letter.data().payload }) + } + } + + return { replayed: snap.size } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/functions && npx vitest run src/__tests__/callables/replay-signal-dead-letter.test.ts` + +Expected: PASS with category gating and replay count asserted. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/callables/replay-signal-dead-letter.ts functions/src/__tests__/callables/replay-signal-dead-letter.test.ts functions/src/index.ts +git commit -m "feat(phase8b): add signal dead-letter replay callable" +``` + +### Task 9: Upgrade Admin Desktop Callables and UI Tests + +**Files:** + +- Modify: `apps/admin-desktop/src/services/callables.ts` +- Create: `apps/admin-desktop/src/__tests__/SystemHealthPage.test.tsx` + +- [ ] **Step 1: Write the failing UI tests** + +```tsx +it('shows ranked signal degradation before lower-priority warnings', async () => { + mockSystemHealth({ + healthy: false, + signalActive: true, + signalLevel: 4, + scraperDegraded: true, + signalDegradedReasons: ['projection_stale'], + costSnapshot: { anomaly: true, todayCost: 180, baselineCost: 100 }, + }) + + render() + + expect(await screen.findByText(/projection stale/i)).toBeInTheDocument() + expect(screen.getByText(/cost anomaly/i)).toBeInTheDocument() +}) + +it('shows declare and clear actions only for superadmin', async () => { + mockAuthClaims({ role: 'provincial_superadmin' }) + render() + + expect(await screen.findByRole('button', { name: /declare signal/i })).toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/apps/admin-desktop && npx vitest run src/__tests__/SystemHealthPage.test.tsx` + +Expected: FAIL because the page still shows only audit gaps and a stub dead-letter button. + +- [ ] **Step 3: Add typed callables** + +```ts +declareHazardSignal: (payload: { + signalLevel: 1 | 2 | 3 | 4 | 5 + scopeType: 'province' | 'municipalities' + affectedMunicipalityIds: string[] + validUntil: number + reason: string +}) => + httpsCallable(functions, 'declareHazardSignal')(payload).then((r) => r.data), + +clearHazardSignal: (payload: { signalId: string; reason: string }) => + httpsCallable(functions, 'clearHazardSignal')(payload).then((r) => r.data), + +replaySignalDeadLetter: (payload: { category: 'pagasa_scraper' | 'hazard_signal_projection' }) => + httpsCallable(functions, 'replaySignalDeadLetter')(payload).then((r) => r.data), +``` + +- [ ] **Step 4: Run test to verify it still fails only on the page implementation** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/apps/admin-desktop && npx vitest run src/__tests__/SystemHealthPage.test.tsx` + +Expected: FAIL on missing UI content, not on callable imports. + +- [ ] **Step 5: Commit** + +```bash +git add apps/admin-desktop/src/services/callables.ts apps/admin-desktop/src/__tests__/SystemHealthPage.test.tsx +git commit -m "test(phase8b): add system health page coverage" +``` + +### Task 10: Rebuild the System Health Page + +**Files:** + +- Modify: `apps/admin-desktop/src/pages/SystemHealthPage.tsx` +- Test: `apps/admin-desktop/src/__tests__/SystemHealthPage.test.tsx` + +- [ ] **Step 1: Implement the ranked incident summary and cards** + +```tsx +function buildIncidentSummary(health: HealthData): string[] { + const issues: string[] = [] + if (health.signalProjectionStale) issues.push('Signal projection stale') + if (health.scraperDegraded) issues.push('PAGASA scraper degraded') + if (health.deadLetterCount > 0) issues.push(`Dead letters pending: ${health.deadLetterCount}`) + if (health.costSnapshot?.anomaly) issues.push('Cost anomaly vs 7-day baseline') + return issues +} + +export function SystemHealthPage() { + const health = useSystemHealth() + const issues = health ? buildIncidentSummary(health) : [] + + return ( +
+

System Health

+ +
+ {issues.length === 0 ? ( + All monitored systems normal + ) : ( + issues.map((issue) => {issue}) + )} +
+ + + + + + + +
+ ) +} +``` + +- [ ] **Step 2: Run the page test to verify it passes** + +Run: `cd /Users/superman/dev/projects/bantayog-alert/apps/admin-desktop && npx vitest run src/__tests__/SystemHealthPage.test.tsx` + +Expected: PASS with ranked degradation, guarded actions, and stale-state rendering. + +- [ ] **Step 3: Run the page through package lint/typecheck** + +Run: `cd /Users/superman/dev/projects/bantayog-alert && pnpm exec turbo run lint typecheck --filter=@bantayog/admin-desktop` + +Expected: PASS for `admin-desktop` lint and typecheck. + +- [ ] **Step 4: Commit** + +```bash +git add apps/admin-desktop/src/pages/SystemHealthPage.tsx apps/admin-desktop/src/__tests__/SystemHealthPage.test.tsx +git commit -m "feat(phase8b): upgrade system health surface" +``` + +### Task 11: End-to-End Verification and Docs + +**Files:** + +- Modify: `docs/progress.md` +- Modify: `docs/learnings.md` + +- [ ] **Step 1: Run the focused backend test suite** + +Run: + +```bash +cd /Users/superman/dev/projects/bantayog-alert/functions +npx vitest run \ + src/__tests__/callables/declare-hazard-signal.test.ts \ + src/__tests__/callables/replay-signal-dead-letter.test.ts \ + src/__tests__/services/hazard-signal-projector.test.ts \ + src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts \ + src/__tests__/triggers/pagasa-signal-poll.test.ts \ + src/__tests__/triggers/cost-snapshot-writer.test.ts \ + src/__tests__/rules/public-collections.rules.test.ts +``` + +Expected: PASS across hazard signal lifecycle, projection, replay, scraper, cost, and rules tests. + +- [ ] **Step 2: Run the focused UI test suite** + +Run: + +```bash +cd /Users/superman/dev/projects/bantayog-alert/apps/admin-desktop +npx vitest run src/__tests__/SystemHealthPage.test.tsx +``` + +Expected: PASS for the new operator surface. + +- [ ] **Step 3: Run workspace lint and typecheck** + +Run: + +```bash +cd /Users/superman/dev/projects/bantayog-alert +pnpm exec turbo run lint typecheck --filter=@bantayog/admin-desktop --filter=@bantayog/functions --filter=@bantayog/shared-validators +``` + +Expected: PASS with no warnings ignored. + +- [ ] **Step 4: Update progress** + +```md +## Current — Phase 8B Signal Ingest + Observability + +| Task | Status | Notes | +| -------------------------- | ------- | ------------------------------------------------ | +| Canonical signal lifecycle | ✅ DONE | Manual + scraper + quarantine + expiry | +| Projection + replay path | ✅ DONE | `hazard_signal_status/current` + replay callable | +| System Health UI | ✅ DONE | Ranked degradation + guarded actions | +| Cost snapshot | ✅ DONE | BigQuery summarizer into `system_health/latest` | +``` + +- [ ] **Step 5: Update learnings** + +```md +- Hazard signal precedence must be resolved per municipality, not by one global document. +- Province-wide signal scope must normalize through the shared municipality constant; ad hoc lists drift. +- Projection failure must degrade honestly and expose a replay path; stale UI without repair is an incident, not just a warning. +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/progress.md docs/learnings.md +git commit -m "docs(phase8b): record signal control delivery" +``` + +--- + +## Self-Review + +### Spec coverage + +- Canonical `hazard_signals` lifecycle: Tasks 1, 4, 5, 6 +- Projected `hazard_signal_status/current`: Tasks 1, 2, 3, 5 +- Manual declare/clear and replay actions: Tasks 4, 8, 9, 10 +- Scraper ingest, quarantine, and recovery: Task 6 +- `system_health/latest` extensions: Task 7 +- Cost snapshot mechanism: Task 7 +- Admin Desktop `System Health` operator surface: Tasks 9, 10 +- Rules and privileged-read boundaries: Task 2 +- Verification and docs: Task 11 + +### Placeholder scan + +- No `TODO` or `TBD` markers left in tasks. +- Every task includes concrete files, commands, and expected outcomes. + +### Type consistency + +- Plan uses `manual` / `scraper` as the 8B signal-source literals consistently. +- `hazard_signal_status/current` is always the projected read model. +- Replay categories are consistently `pagasa_scraper` and `hazard_signal_projection`. + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-04-28-phase8b.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/specs/2026-04-28-phase8b-design.md b/docs/superpowers/specs/2026-04-28-phase8b-design.md new file mode 100644 index 00000000..5e4b09c2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-phase8b-design.md @@ -0,0 +1,440 @@ +# Phase 8B Design — Signal Ingest, Operator Control, Observability & Cost + +**Date:** 2026-04-28 +**Status:** Approved +**Branch:** `codex/phase7-publish` (spec only — implementation branch TBD) + +--- + +## Overview + +Phase 8B is the operations control-plane slice of Phase 8. It makes storm-state awareness, operator action, and system-health visibility reliable before and during surge conditions. + +It does **not** own the k6 surge/load-validation work itself and it does **not** own the RA 10173 erasure/anonymization execution path. Those are separate specs: + +- **Phase 8A** — surge and contention validation +- **Phase 8C** — erasure and anonymization execution + +Phase 8B exists so operators can run the system during a storm without leaving the product or guessing which backend state is real. + +--- + +## Scope + +### In Scope + +- Canonical `hazard_signals` workflow for TCWS-driven storm-state +- Manual signal declaration as the primary operational path +- PAGASA scraper ingest writing into the same signal model +- Province-wide and municipality-scoped signal support +- Required `validUntil` on manual signals plus explicit early clear +- Admin Desktop `System Health` as the primary operator surface +- Minimal guarded actions from the product +- Derived operational state for UI consumption +- Health, backlog, and cost visibility needed to operate during surge +- Honest degraded-state behavior when scraper or derived health data is stale + +### Out of Scope + +- Dynamic mutation of Cloud Functions `minInstances` +- Non-TCWS hazards driving surge behavior in this phase +- k6 load and contention validation itself +- RA 10173 erasure/anonymization execution +- Broad incident-management workflows already covered in Phase 7 +- Turning `System Health` into a generic admin super-console + +### Retention Boundary + +Phase 8B signal documents are operational and audit records, not citizen-erasure artifacts. `cleared`, `expired`, and `superseded` describe signal lifecycle only. They do **not** imply RA 10173 deletion or anonymization behavior. Phase 8C owns the citizen erasure/anonymization path; Phase 8B records remain subject to the platform’s operational audit retention policy. + +--- + +## Core Decisions + +1. **`hazard_signals` is the single source of truth.** Manual and scraper-created signals write the same canonical model. +2. **Manual-first operations.** Manual declaration is the hard operational path. The scraper assists but is never the sole dependency. +3. **Static warm capacity, not runtime scaling mutation.** Phase 8B assumes hot paths are already sized to a surge-safe baseline. Signal state drives operator awareness and runbooks, not deployment-time config rewrites. +4. **Admin Desktop is the control plane.** Operators should not need GCP or Firebase consoles to understand or control current storm-state. +5. **TCWS only for surge-driving behavior in 8B.** Other hazard types may exist as data later, but they do not drive this phase’s workflows. +6. **Manual declarations require `validUntil`.** Signals also support explicit early clear to avoid relying on memory. +7. **Manual override wins.** If manual and scraper signals conflict, manual is the effective state until cleared or expired. +8. **Read-first UI, small action surface.** The page prioritizes clear status over more buttons. +9. **Province scope normalizes through shared constants.** Province-wide scope must derive its municipality set from the shared Camarines Norte municipality constant already used elsewhere in the repo, not from ad hoc literals. + +--- + +## Operational Invariants + +- `hazard_signals` is the only authoritative input for storm-signal state. +- Manual and scraper-created signals must be behaviorally equivalent after write. +- Manual fallback must remain usable if scraper ingest fails. +- Every signal-control action must be auditable with actor and rationale. +- Signals must end deterministically through expiry or explicit clear. +- The UI must prefer degraded-but-honest over optimistic-but-wrong. +- No live storm-state should require client-side reconstruction from raw history. + +--- + +## Architecture + +Phase 8B is a small control loop with one canonical write path and one derived read model. + +### Core Components + +- **`hazard_signals`** + Canonical lifecycle documents for TCWS declarations from manual and scraper sources. +- **`hazard_signal_status/current`** + Derived current-state document for the app. This is the live read model for signal state. +- **Manual signal callables** + Server-authoritative declare and clear paths from Admin Desktop. +- **Scraper poller** + Scheduled backend job that parses PAGASA bulletin data and writes canonical signal documents. +- **Signal projector/reconciler** + Backend logic that computes effective current state from canonical signal documents. +- **System health aggregator** + Logic that extends `system_health/latest` with signal-aware health, backlog, and degraded-state indicators. +- **Admin Desktop `System Health` page** + Primary operator surface for visibility and the approved action set. + +### Architectural Boundaries + +- `System Health` must not write Firestore directly for signal changes. +- Raw `hazard_signals` is audit history, not the main live UI query surface. +- Scraper failure must not block manual declare or clear. +- Phase 8B does not attempt to mutate deployed function scaling at runtime. + +--- + +## Data Model + +### `hazard_signals/{signalId}` + +Canonical lifecycle document for one TCWS declaration. + +**Required fields** + +- `hazardType: 'tropical_cyclone'` +- `signalLevel: 1 | 2 | 3 | 4 | 5` +- `source: 'manual' | 'scraper'` +- `scopeType: 'province' | 'municipalities'` +- `affectedMunicipalityIds: string[]` +- `status: 'active' | 'cleared' | 'expired' | 'superseded' | 'quarantined'` +- `validFrom: Timestamp` +- `validUntil: Timestamp` +- `recordedAt: Timestamp` +- `rawSource: string` + +**Manual-only fields** + +- `recordedBy: string` +- `reason: string` + +**Lifecycle fields** + +- `clearedAt?: Timestamp` +- `clearedBy?: string` +- `supersededBy?: string` + +**Model rule** + +One document represents one declared signal lifecycle. Lifecycle state updates happen on that document instead of through a separate “surge mode” toggle model. + +For `scopeType: 'province'`, `affectedMunicipalityIds` is normalized from the shared `CAMARINES_NORTE_MUNICIPALITIES` constant already exported in the repo. The system does not allow an empty array as an alternate representation of province-wide scope. The province count is therefore data-derived, not hardcoded in callable logic. + +### `hazard_signal_status/current` + +Derived read model for live operator state. + +**Fields** + +- `active: boolean` +- `effectiveSignalId?: string` +- `effectiveLevel?: 1 | 2 | 3 | 4 | 5` // highest currently effective level, for summary display +- `effectiveSource?: 'manual' | 'scraper'` +- `scopeType?: 'province' | 'municipalities'` +- `affectedMunicipalityIds: string[]` +- `effectiveScopes: Array<{ municipalityId: string; signalLevel: 1 | 2 | 3 | 4 | 5; source: 'manual' | 'scraper'; signalId: string }>` +- `validUntil?: Timestamp` +- `manualOverrideActive: boolean` +- `scraperLastSuccessAt?: Timestamp` +- `scraperLastFailureAt?: Timestamp` +- `scraperDegraded: boolean` +- `lastProjectedAt: Timestamp` +- `degradedReasons: string[]` + +### `system_health/latest` + +Remains the broad health document, but gains signal-aware summary fields so the UI does not have to stitch operational state together from multiple raw collections. + +### Access Expectations + +- `hazard_signals` remains read-only to clients and write-only through backend/Admin SDK paths. +- `hazard_signal_status/current` is a client-readable projection for privileged staff surfaces only; citizen clients do not read it. +- `system_health/latest` remains superadmin-facing operational health, not a public or citizen surface. +- No new client write path is introduced for either `hazard_signals` or `hazard_signal_status`. + +--- + +## Backend Behavior + +### Manual Declare + +1. Superadmin uses `System Health` to declare a TCWS signal. +2. Client submits level, scope, affected municipalities, rationale, and `validUntil` to a callable. +3. Callable validates: + - caller is superadmin + - caller has the required privileged auth state + - hazard type is supported by 8B + - scope is province-wide or municipality-scoped + - `validUntil` exists and is in the future +4. Callable writes canonical `hazard_signals`. +5. Projector recomputes `hazard_signal_status/current`. +6. `system_health/latest` reflects the effective signal state. + +### Manual Clear + +1. Superadmin clears an active manual signal from `System Health`. +2. Client calls a guarded clear callable. +3. Backend records the clear through canonical signal lifecycle state. +4. Projector recomputes effective state. +5. If a valid scraper-derived signal still exists, it becomes the visible effective state. + +### Automatic Expiry + +1. A scheduled backend sweep closes expired manual signals. +2. Expiry updates canonical lifecycle state to `expired`. +3. Projector recomputes effective state immediately after expiry handling. + +### Scraper Ingest + +1. Scheduled poller fetches PAGASA bulletin data. +2. If parse succeeds, backend writes canonical scraper-sourced `hazard_signals`. +3. Projector recomputes effective state. +4. If parse fails, scraper health degrades visibly and dead-letter/audit signals are emitted. +5. If parse succeeds but produces suspicious output, it does **not** become effective automatically. Instead the candidate signal is written as `quarantined`, scraper health degrades, and manual declaration remains the safe operational path. + +### Precedence Rules + +- Effective state is resolved municipality-by-municipality, not by a single global document order. +- Expired, cleared, superseded, and quarantined documents are excluded from effective-state computation. +- Manual declaration is the trusted operational override for the municipalities it covers, regardless of signal level. +- If two active manual declarations cover the same municipality, the more recently recorded declaration wins for that municipality. Exact-timestamp ties break by higher signal level, then stable `signalId`. +- If two active scraper declarations cover the same municipality, the same recency and tie-break rules apply. +- `effectiveLevel` in `hazard_signal_status/current` is the highest resulting municipality-level signal for summary display. +- New manual declaration supersedes older active manual declaration as a terminal lifecycle transition; a superseded document does not revive later when the newer declaration expires. If prior state needs to return, it must be re-declared as a new signal. +- Clearing or expiring manual state reveals the best still-valid scraper-derived state, if one exists. +- Ordering between expiry sweep and concurrent scraper writes is resolved by recomputing from the full active set at projection time; correctness does not depend on event arrival order. + +### Projection Repair Path + +- A manual declare or clear callable succeeds only after the canonical signal write succeeds. +- If canonical write succeeds but projection update fails, the signal document remains the source of truth and a retryable projection repair path must reconcile `hazard_signal_status/current`. +- Phase 8B therefore includes a backend projection replay mechanism, not just UI warnings about stale projection. + +### Cost Snapshot Feed + +- Cost does not come from direct UI billing queries. +- The 8B design assumes Cloud Billing export into BigQuery, followed by a scheduled summarizer that writes a small operator-facing spend snapshot into the health surface. +- The `System Health` page shows that summarized operator signal only: today vs baseline and anomaly state. + +### Dead-Letter Replay Path + +- `Replay Dead Letter` is not a placeholder button. +- Phase 8B includes a backend replay path for signal-related dead letters and projection repair failures. +- Replay operates by category, not by blind bulk retry. The initial categories in scope are `pagasa_scraper` and `hazard_signal_projection`. + +--- + +## Admin Desktop Surface + +`System Health` becomes the operator control plane for Phase 8B. It stays read-heavy with a small, guarded action surface. + +Before the detailed cards, the page shows a ranked incident summary strip so the operator sees the highest-priority degradation first instead of a flat wall of red states. + +### Cards + +- **Current Signal** + - current TCWS level + - source: manual or scraper + - province-wide or municipality-scoped + - affected municipalities + - valid-until / time remaining + - degraded badge if scraper or projection is stale + +- **Signal Controls** + - `Declare Signal` + - `Clear Active Signal` + - visible only to superadmin + - every action requires rationale and confirmation + +- **Scraper Health** + - last successful scrape + - last failed scrape + - current health state + - explicit fallback reminder that manual declaration remains available + +- **Operational Health** + - inbox reconciliation backlog + - dead-letter count + - audit streaming gap + - audit batch gap + - SMS provider health + - function error-rate summary + +- **Cost Snapshot** + - current day vs 7-day baseline + - anomaly state if above threshold + - simple operator signal, not billing-console depth + +- **Guarded Actions** + - `Replay Dead Letter` + - `Run Health Check` + +### UI Constraints + +- The page is read-first, action-second. +- Live storm-state must be obvious within one screen. +- Stale data must be shown as stale, not healthy. +- Phase 8B does not add a large set of emergency buttons. +- Degraded states are ranked. Projection stale, active signal ambiguity, and dead-letter growth outrank cost anomalies and lower-priority warnings. + +--- + +## Failure Handling + +### Principles + +- Scraper failure degrades visibility, not operator control. +- Manual declare and clear remain available when scraper ingest is broken. +- Projection failure must be visible as stale, not silently wrong. +- Expired manual signals must clear from effective state without operator intervention. +- Conflicting writes resolve server-side with deterministic precedence. + +### Failure Behaviors + +**PAGASA parse failure** + +- Poller writes failure telemetry and dead-letter context. +- `hazard_signal_status/current.scraperDegraded = true`. +- `System Health` shows degraded scraper state and fallback guidance. +- Existing manual signal remains authoritative. + +**PAGASA wrong-but-parseable output** + +- Syntactically valid scraper output is still subject to sanity validation before it becomes effective. +- Suspicious changes are quarantined instead of promoted to active effective state. Examples: invalid municipality IDs, empty scope, impossible province normalization, or policy-defined high-risk signal jumps. +- Quarantined output degrades scraper health and requires manual operator review/override. + +**Projector failure** + +- The UI does not guess from raw signal documents. +- `lastProjectedAt` becomes stale and degraded state is surfaced. +- Manual actions remain available, but operators are warned that projected live state is stale. +- Projection failure writes retryable repair state and dead-letter context for replay. + +**Manual clear while scraper signal exists** + +- Clearing a manual override reveals the best still-valid scraper-derived state. +- It must not force “no signal” if valid scraper state exists. + +**Overlapping manual declarations** + +- Later valid manual declaration supersedes earlier active manual declaration. +- Older document transitions to `superseded`. +- Default UI shows only the effective declaration. + +**Expired manual declaration** + +- Scheduled sweep marks it `expired`. +- Projected status recomputes immediately after expiry handling. + +**Scraper degraded auto-recovery** + +- The next successful non-quarantined scraper run clears the degraded flag automatically and updates `scraperLastSuccessAt`. +- Recovery does not require manual operator cleanup. + +**Health-data staleness** + +- If `system_health/latest` is stale, the page shows stale data instead of green badges. +- Stale health is itself an operator-facing incident signal. + +--- + +## Verification + +### Required Tests + +**Callable tests** + +- manual declare rejects non-superadmin callers +- manual declare rejects missing `validUntil` +- manual declare rejects past `validUntil` +- manual declare accepts province-wide and municipality-scoped TCWS inputs +- manual clear only clears an active manual signal +- newer manual declaration supersedes older active manual declaration deterministically + +**Projector tests** + +- manual signal becomes effective state +- active manual signal outranks active scraper signal +- clearing manual signal reveals valid scraper state if present +- clearing manual signal reveals no signal when only expired scraper state remains +- expired manual signal drops out of effective state +- overlapping scoped signals produce one deterministic effective status document +- superseded manual declarations do not revive when the newer declaration expires +- expiry sweep and concurrent scraper writes converge to the same projected result regardless of event order + +**Scraper tests** + +- valid PAGASA parse writes canonical scraper-sourced signal document +- unchanged source bulletin does not create noisy duplicate writes +- unparseable source marks scraper degraded and produces dead-letter/audit signal +- wrong-but-parseable suspicious output is quarantined and does not become effective state +- scraper failure does not alter manual control availability +- successful scraper recovery clears degraded state automatically + +**Health aggregation tests** + +- `system_health/latest` reflects signal summary fields correctly +- stale projection is surfaced as degraded +- stale scraper is surfaced as degraded +- stale health document is surfaced as stale in UI semantics +- cost snapshot fields are populated from the scheduled summarizer, not computed client-side + +**UI tests** + +- `System Health` shows active signal state from the projected read model +- superadmin sees guarded actions and non-superadmin does not +- degraded scraper state is explicit and understandable +- stale health/projection state is visibly not healthy +- declare and clear actions require confirmation and rationale + +### Required Staging Drills + +- manual declare drill +- manual clear drill +- expiry drill +- scraper degradation drill +- scraper recovery drill +- area-scoped declaration drill + +--- + +## Exit Criteria + +Phase 8B is complete when all of these are true: + +- canonical `hazard_signals` path works for manual and scraper sources +- projected `hazard_signal_status/current` is the stable UI source +- `System Health` is the primary operator surface for signal state +- manual declare, clear, and expiry all work in staging +- scraper failure degrades honestly and preserves manual fallback +- signal-related dead-letter replay and projection repair path work in staging +- audit trail exists for all signal-control actions +- operators do not need GCP console access to understand current storm-state + +--- + +## Summary + +Phase 8B is intentionally narrow. It creates one boring, auditable control plane for TCWS storm-state, keeps manual operations as the reliable fallback, and upgrades `System Health` into the place operators can trust during surge conditions. diff --git a/functions/src/__tests__/callables/declare-hazard-signal.test.ts b/functions/src/__tests__/callables/declare-hazard-signal.test.ts new file mode 100644 index 00000000..75fb724c --- /dev/null +++ b/functions/src/__tests__/callables/declare-hazard-signal.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' +import { CAMARINES_NORTE_MUNICIPALITIES } from '@bantayog/shared-validators' + +const mockReplay = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('../../services/hazard-signal-projector.js', () => ({ + replayHazardSignalProjection: mockReplay, +})) + +vi.mock('firebase-functions/v2/https', () => ({ + onCall: vi.fn((_opts: unknown, fn: unknown) => fn), + HttpsError: class HttpsError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, +})) + +function createMockDb() { + const setFn = vi.fn().mockResolvedValue(undefined) + const updateFn = vi.fn().mockResolvedValue(undefined) + const getFn = vi.fn().mockResolvedValue({ exists: true, data: () => ({ status: 'active' }) }) + const docFn = vi.fn(() => ({ set: setFn, update: updateFn, get: getFn })) + const collectionFn = vi.fn(() => ({ doc: docFn })) + return { + collection: collectionFn, + _setFn: setFn, + _updateFn: updateFn, + _getFn: getFn, + _docFn: docFn, + } as unknown as Firestore & { + _setFn: typeof setFn + _updateFn: typeof updateFn + _getFn: typeof getFn + _docFn: typeof docFn + } +} + +const superadminActor = { uid: 'super-1', role: 'provincial_superadmin' } +const muniAdminActor = { uid: 'muni-1', role: 'municipal_admin' } +const futureTimestamp = () => Date.now() + 60_000 + +import { + declareHazardSignalCore, + clearHazardSignalCore, +} from '../../callables/declare-hazard-signal.js' + +import { beforeEach } from 'vitest' + +beforeEach(() => { + mockReplay.mockClear() +}) + +describe('declareHazardSignalCore', () => { + it('rejects non-superadmin callers', async () => { + const db = createMockDb() + await expect( + declareHazardSignalCore( + db, + { + signalLevel: 3, + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + validUntil: futureTimestamp(), + reason: 'test', + }, + muniAdminActor, + ), + ).rejects.toMatchObject({ code: 'permission-denied' }) + }) + + it('normalizes province scope to all municipalities', async () => { + const db = createMockDb() + const result = await declareHazardSignalCore( + db, + { + signalLevel: 4, + scopeType: 'province', + affectedMunicipalityIds: ['daet'], + validUntil: futureTimestamp(), + reason: 'test', + }, + superadminActor, + ) + + expect(result.affectedMunicipalityIds).toEqual(CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) + }) + + it('writes a valid hazard signal document', async () => { + const db = createMockDb() + await declareHazardSignalCore( + db, + { + signalLevel: 3, + scopeType: 'municipalities', + affectedMunicipalityIds: ['daet'], + validUntil: futureTimestamp(), + reason: 'PAGASA radio confirmation', + }, + superadminActor, + ) + + expect(db._setFn).toHaveBeenCalledOnce() + const firstCall = db._setFn.mock.calls[0] + expect(firstCall).toBeDefined() + const written = firstCall![0] as Record + expect(written).toMatchObject({ + hazardType: 'tropical_cyclone', + source: 'manual', + status: 'active', + schemaVersion: 1, + }) + }) + + it('returns signalId and affectedMunicipalityIds', async () => { + const db = createMockDb() + const result = await declareHazardSignalCore( + db, + { + signalLevel: 2, + scopeType: 'municipalities', + affectedMunicipalityIds: ['daet', 'san-vicente'], + validUntil: futureTimestamp(), + reason: 'test', + }, + superadminActor, + ) + + expect(typeof result.signalId).toBe('string') + expect(result.affectedMunicipalityIds).toEqual(['daet', 'san-vicente']) + }) + + it('rejects validUntil that is already expired', async () => { + const db = createMockDb() + await expect( + declareHazardSignalCore( + db, + { + signalLevel: 3, + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + validUntil: Date.now() - 1000, + reason: 'test', + }, + superadminActor, + ), + ).rejects.toMatchObject({ code: 'invalid-argument' }) + }) +}) + +describe('clearHazardSignalCore', () => { + it('rejects non-superadmin callers', async () => { + const db = createMockDb() + await expect( + clearHazardSignalCore(db, { signalId: 'sig-1', reason: 'all clear' }, muniAdminActor), + ).rejects.toMatchObject({ code: 'permission-denied' }) + }) + + it('marks the signal as cleared', async () => { + const updateFn = vi.fn().mockResolvedValue(undefined) + const getFn = vi.fn().mockResolvedValue({ exists: true, data: () => ({ status: 'active' }) }) + const docFn = vi.fn(() => ({ get: getFn, update: updateFn })) + const db = { collection: vi.fn(() => ({ doc: docFn })) } as unknown as Firestore + await clearHazardSignalCore(db, { signalId: 'sig-1', reason: 'storm passed' }, superadminActor) + + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'cleared' })) + }) + + it('throws not-found when clearing a non-existent signal', async () => { + const getFn = vi.fn().mockResolvedValue({ exists: false }) + const docFn = vi.fn(() => ({ get: getFn, update: vi.fn() })) + const db = { collection: vi.fn(() => ({ doc: docFn })) } as unknown as Firestore + await expect( + clearHazardSignalCore(db, { signalId: 'missing', reason: 'test' }, superadminActor), + ).rejects.toMatchObject({ code: 'not-found' }) + }) + + it('throws failed-precondition when clearing a non-active signal', async () => { + const getFn = vi.fn().mockResolvedValue({ exists: true, data: () => ({ status: 'expired' }) }) + const docFn = vi.fn(() => ({ get: getFn, update: vi.fn() })) + const db = { collection: vi.fn(() => ({ doc: docFn })) } as unknown as Firestore + await expect( + clearHazardSignalCore(db, { signalId: 'sig-1', reason: 'test' }, superadminActor), + ).rejects.toMatchObject({ code: 'failed-precondition' }) + }) + + it('returns signalId and cleared status', async () => { + const db = createMockDb() + const result = await clearHazardSignalCore( + db, + { signalId: 'sig-42', reason: 'all clear' }, + superadminActor, + ) + + expect(result).toEqual({ signalId: 'sig-42', status: 'cleared', clearedReason: 'all clear' }) + }) +}) diff --git a/functions/src/__tests__/callables/replay-signal-dead-letter.test.ts b/functions/src/__tests__/callables/replay-signal-dead-letter.test.ts new file mode 100644 index 00000000..e238a8de --- /dev/null +++ b/functions/src/__tests__/callables/replay-signal-dead-letter.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' + +let mockDb: Firestore + +const mockReplayHazardSignalProjection = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const mockPagasaSignalPollCore = vi.hoisted(() => + vi.fn().mockResolvedValue({ status: 'updated', scraperDegraded: false }), +) + +vi.mock('firebase-functions/v2/https', () => ({ + onCall: vi.fn((_opts: unknown, fn: unknown) => fn), + HttpsError: class HttpsError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, +})) + +vi.mock('firebase-admin/firestore', () => ({ + getFirestore: vi.fn(() => mockDb), +})) + +vi.mock('../../services/hazard-signal-projector.js', () => ({ + replayHazardSignalProjection: mockReplayHazardSignalProjection, +})) + +vi.mock('../../triggers/pagasa-signal-poll.js', () => ({ + pagasaSignalPollCore: mockPagasaSignalPollCore, +})) + +function createMockDb(deadLetters: { category: string; payload: unknown }[]) { + const updateFn = vi.fn().mockResolvedValue(undefined) + const docs = deadLetters.map((data, index) => ({ + id: `dl-${String(index + 1)}`, + data: () => data, + ref: { update: updateFn }, + })) + const getFn = vi.fn().mockResolvedValue({ docs, size: docs.length }) + const limitFn = vi.fn(() => query) + const query = { get: getFn, limit: limitFn } + const whereFn = vi.fn(() => query) + const collectionFn = vi.fn((collectionName: string) => { + if (collectionName !== 'dead_letters') { + throw new Error(`unexpected collection: ${collectionName}`) + } + return { where: whereFn } + }) + + return { + collection: collectionFn, + _updateFn: updateFn, + _getFn: getFn, + _whereFn: whereFn, + _limitFn: limitFn, + } as unknown as Firestore & { + _updateFn: typeof updateFn + _getFn: typeof getFn + _whereFn: typeof whereFn + _limitFn: typeof limitFn + } +} + +import { replaySignalDeadLetter } from '../../callables/replay-signal-dead-letter.js' + +beforeEach(() => { + mockReplayHazardSignalProjection.mockClear() + mockPagasaSignalPollCore.mockClear() + mockPagasaSignalPollCore.mockResolvedValue({ status: 'updated', scraperDegraded: false }) +}) + +describe('replaySignalDeadLetter', () => { + it('replays hazard signal projection dead letters for provincial superadmins', async () => { + mockDb = createMockDb([ + { category: 'hazard_signal_projection', payload: { signalIds: ['sig-1'] } }, + ]) + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { + uid: string + token: { role: string } + } + data: { category: 'hazard_signal_projection' } + }) => Promise<{ replayed: number }> + + const result = await invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'provincial_superadmin' }, + }, + data: { category: 'hazard_signal_projection' }, + }) + + expect(result).toEqual({ replayed: 1 }) + expect(mockReplayHazardSignalProjection).toHaveBeenCalledOnce() + expect(mockReplayHazardSignalProjection).toHaveBeenCalledWith({ + db: mockDb, + now: expect.any(Number), + }) + expect( + (mockDb as unknown as { _updateFn: ReturnType })._updateFn, + ).toHaveBeenCalledWith({ + resolvedAt: expect.any(Number), + resolvedBy: 'super-1', + }) + }) + + it('replays pagasa scraper dead letters with replayable html payloads', async () => { + const db = createMockDb([{ category: 'pagasa_scraper', payload: 'TCWS #3 Daet' }]) + mockDb = db + + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { + uid: string + token: { role: string } + } + data: { category: 'pagasa_scraper' } + }) => Promise<{ replayed: number }> + + const result = await invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'provincial_superadmin' }, + }, + data: { category: 'pagasa_scraper' }, + }) + + expect(result).toEqual({ replayed: 1 }) + expect(mockPagasaSignalPollCore).toHaveBeenCalledOnce() + expect(db._updateFn).toHaveBeenCalledWith({ + resolvedAt: expect.any(Number), + resolvedBy: 'super-1', + }) + }) + + it('rejects non-superadmin callers', async () => { + mockDb = createMockDb([]) + + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { uid: string; token: { role: string } } + data: { category: string } + }) => Promise<{ replayed: number }> + + await expect( + invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'municipal_admin' }, + }, + data: { category: 'hazard_signal_projection' }, + }), + ).rejects.toMatchObject({ code: 'permission-denied' }) + }) + + it('throws failed-precondition for unreplayable payload', async () => { + mockDb = createMockDb([{ category: 'pagasa_scraper', payload: {} }]) + + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { uid: string; token: { role: string } } + data: { category: 'pagasa_scraper' } + }) => Promise<{ replayed: number }> + + await expect( + invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'provincial_superadmin' }, + }, + data: { category: 'pagasa_scraper' }, + }), + ).rejects.toMatchObject({ code: 'failed-precondition' }) + }) + + it('does not mark resolved when pagasaSignalPollCore returns non-updated', async () => { + const db = createMockDb([{ category: 'pagasa_scraper', payload: 'TCWS #3 Daet' }]) + mockDb = db + mockPagasaSignalPollCore.mockResolvedValue({ status: 'failed', scraperDegraded: true }) + + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { uid: string; token: { role: string } } + data: { category: 'pagasa_scraper' } + }) => Promise<{ replayed: number }> + + const result = await invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'provincial_superadmin' }, + }, + data: { category: 'pagasa_scraper' }, + }) + + expect(result).toEqual({ replayed: 0 }) + expect(db._updateFn).not.toHaveBeenCalled() + }) + + it('rejects unsupported categories with invalid-argument', async () => { + mockDb = createMockDb([]) + + const invokeReplay = replaySignalDeadLetter as unknown as (request: { + auth: { uid: string; token: { role: string } } + data: { category: string } + }) => Promise<{ replayed: number }> + + await expect( + invokeReplay({ + auth: { + uid: 'super-1', + token: { role: 'provincial_superadmin' }, + }, + data: { category: 'unknown' }, + }), + ).rejects.toMatchObject({ code: 'invalid-argument' }) + }) +}) diff --git a/functions/src/__tests__/rules/public-collections.rules.test.ts b/functions/src/__tests__/rules/public-collections.rules.test.ts index 80bf3352..6ef52712 100644 --- a/functions/src/__tests__/rules/public-collections.rules.test.ts +++ b/functions/src/__tests__/rules/public-collections.rules.test.ts @@ -419,5 +419,54 @@ describe('privileged read tests for callable collections', () => { addDoc(collection(db, 'erasure_requests'), { schemaVersion: 1, createdAt: ts }), ) }) + + describe('hazard_signal_status', () => { + it('superadmin with active privileged claim can read hazard signal status', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertSucceeds(getDocs(collection(db, 'hazard_signal_status'))) + }) + + it('citizen cannot read hazard signal status', async () => { + const db = authed(env, 'citizen-1', staffClaims({ role: 'citizen' })) + await assertFails(getDocs(collection(db, 'hazard_signal_status'))) + }) + + it('client writes to hazard signal status remain blocked', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ role: 'provincial_superadmin', permittedMunicipalityIds: ['daet'] }), + ) + await assertFails( + setDoc(doc(db, 'hazard_signal_status', 'current'), { + active: false, + affectedMunicipalityIds: [], + effectiveScopes: [], + manualOverrideActive: false, + scraperDegraded: false, + lastProjectedAt: ts, + degradedReasons: [], + schemaVersion: 1, + }), + ) + }) + + it('suspended superadmin cannot read hazard signal status', async () => { + const db = authed( + env, + 'super-1', + staffClaims({ + role: 'provincial_superadmin', + permittedMunicipalityIds: ['daet'], + accountStatus: 'suspended', + }), + ) + await assertFails(getDocs(collection(db, 'hazard_signal_status'))) + }) + }) }) }) diff --git a/functions/src/__tests__/services/hazard-signal-projector.test.ts b/functions/src/__tests__/services/hazard-signal-projector.test.ts new file mode 100644 index 00000000..3e4adcf6 --- /dev/null +++ b/functions/src/__tests__/services/hazard-signal-projector.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import type { HazardSignalDoc, HazardSignalStatusDoc } from '@bantayog/shared-validators' +import { projectHazardSignalStatus } from '../../services/hazard-signal-projector.js' + +const NOW = 1713350400000 + +type SignalStatus = 'active' | 'cleared' | 'expired' | 'superseded' | 'quarantined' + +function manualSignal(overrides: { + id: string + affectedMunicipalityIds: string[] + signalLevel?: number + status?: SignalStatus + validUntil?: number +}): HazardSignalDoc & { id: string } { + return { + id: overrides.id, + hazardType: 'tropical_cyclone', + signalLevel: overrides.signalLevel ?? 3, + source: 'manual', + scopeType: 'municipalities', + affectedMunicipalityIds: overrides.affectedMunicipalityIds, + status: overrides.status ?? 'active', + validFrom: NOW - 3600000, + validUntil: overrides.validUntil ?? NOW + 3600000, + recordedAt: NOW - 1000, + rawSource: 'manual_superadmin', + recordedBy: 'super-1', + schemaVersion: 1, + } +} + +function scraperSignal(overrides: { + id: string + affectedMunicipalityIds: string[] + signalLevel?: number + status?: SignalStatus + validUntil?: number +}): HazardSignalDoc & { id: string } { + return { + id: overrides.id, + hazardType: 'tropical_cyclone', + signalLevel: overrides.signalLevel ?? 3, + source: 'scraper', + scopeType: 'municipalities', + affectedMunicipalityIds: overrides.affectedMunicipalityIds, + status: overrides.status ?? 'active', + validFrom: NOW - 3600000, + validUntil: overrides.validUntil ?? NOW + 3600000, + recordedAt: NOW - 2000, + rawSource: 'pagasa_scraper', + schemaVersion: 1, + } +} + +describe('projectHazardSignalStatus', () => { + it('prefers manual signals per municipality regardless of higher scraper level', () => { + const result: HazardSignalStatusDoc = projectHazardSignalStatus({ + now: NOW, + signals: [ + manualSignal({ id: 'm-1', affectedMunicipalityIds: ['daet'], signalLevel: 3 }), + scraperSignal({ id: 's-1', affectedMunicipalityIds: ['daet'], signalLevel: 4 }), + ], + }) + + expect(result.effectiveScopes).toEqual([ + { municipalityId: 'daet', signalLevel: 3, source: 'manual', signalId: 'm-1' }, + ]) + expect(result.effectiveLevel).toBe(3) + expect(result.manualOverrideActive).toBe(true) + }) + + it('does not revive a superseded manual signal after the newer one expires', () => { + const result: HazardSignalStatusDoc = projectHazardSignalStatus({ + now: NOW, + signals: [ + manualSignal({ + id: 'm-1', + affectedMunicipalityIds: ['daet'], + signalLevel: 3, + status: 'superseded', + }), + manualSignal({ + id: 'm-2', + affectedMunicipalityIds: ['daet'], + signalLevel: 4, + status: 'expired', + }), + ], + }) + + expect(result.effectiveScopes).toEqual([]) + expect(result.active).toBe(false) + }) + + it('validUntil reflects the winning signal expiry, not a losing signals expiry', () => { + const result = projectHazardSignalStatus({ + now: NOW, + signals: [ + // Manual wins for daet; expires late + manualSignal({ + id: 'm-1', + affectedMunicipalityIds: ['daet'], + signalLevel: 3, + validUntil: NOW + 7200000, + }), + // Scraper loses for daet; expires soon — must NOT drag validUntil down + scraperSignal({ + id: 's-1', + affectedMunicipalityIds: ['daet'], + signalLevel: 4, + validUntil: NOW + 60000, + }), + ], + }) + + expect(result.active).toBe(true) + expect(result.validUntil).toBe(NOW + 7200000) + }) + + it('marks inactive when the only scraper signal is expired', () => { + const result: HazardSignalStatusDoc = projectHazardSignalStatus({ + now: NOW, + signals: [ + scraperSignal({ id: 's-1', affectedMunicipalityIds: ['daet'], validUntil: NOW - 1 }), + ], + }) + + expect(result.active).toBe(false) + }) + + it('validUntil is the minimum across all municipality winners', () => { + const result = projectHazardSignalStatus({ + now: NOW, + signals: [ + manualSignal({ + id: 'm-daet', + affectedMunicipalityIds: ['daet'], + validUntil: NOW + 7200000, + }), + manualSignal({ + id: 'm-basud', + affectedMunicipalityIds: ['basud'], + validUntil: NOW + 3600000, + }), + ], + }) + expect(result.active).toBe(true) + expect(result.validUntil).toBe(NOW + 3600000) + }) + + it('newer recordedAt wins tie-break when sources match', () => { + const result = projectHazardSignalStatus({ + now: NOW, + signals: [ + // both scrapers for same municipality; s-newer has higher recordedAt + { + ...scraperSignal({ id: 's-older', affectedMunicipalityIds: ['daet'] }), + recordedAt: NOW - 5000, + }, + { + ...scraperSignal({ id: 's-newer', affectedMunicipalityIds: ['daet'] }), + recordedAt: NOW - 1000, + }, + ], + }) + expect(result.effectiveScopes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ municipalityId: 'daet', signalId: 's-newer' }), + ]), + ) + }) +}) diff --git a/functions/src/__tests__/triggers/cost-snapshot-writer.test.ts b/functions/src/__tests__/triggers/cost-snapshot-writer.test.ts new file mode 100644 index 00000000..465c7d0b --- /dev/null +++ b/functions/src/__tests__/triggers/cost-snapshot-writer.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' + +const mockQuery = vi.hoisted(() => vi.fn()) + +vi.mock('@google-cloud/bigquery', () => ({ + BigQuery: class { + query = mockQuery + }, +})) + +vi.mock('firebase-functions/v2/scheduler', () => ({ + onSchedule: vi.fn((_opts: unknown, fn: unknown) => fn), +})) + +function createMockDb() { + const setFn = vi.fn().mockResolvedValue(undefined) + const docFn = vi.fn(() => ({ set: setFn })) + const db = { + doc: docFn, + } as unknown as Firestore + return { db, setFn } +} + +import { costSnapshotWriterCore } from '../../triggers/cost-snapshot-writer.js' + +describe('costSnapshotWriterCore', () => { + it('writes the merged cost snapshot and marks anomalies when the day exceeds baseline', async () => { + mockQuery + .mockResolvedValueOnce([[{ totalCost: '180' }]]) + .mockResolvedValueOnce([[{ totalCost: 100 }]]) + + const { db, setFn } = createMockDb() + const result = await costSnapshotWriterCore( + db, + { query: mockQuery }, + { now: () => 1713350400000 }, + ) + + expect(result).toEqual({ anomaly: true, todayCost: 180, baselineCost: 100 }) + expect(setFn).toHaveBeenCalledWith( + expect.objectContaining({ + costSnapshot: expect.objectContaining({ + todayCost: 180, + baselineCost: 100, + anomaly: true, + recordedAt: 1713350400000, + }), + }), + { merge: true }, + ) + }) + + it('treats missing cost rows as zero and does not flag an anomaly', async () => { + mockQuery.mockResolvedValueOnce([[]]).mockResolvedValueOnce([[]]) + + const { db } = createMockDb() + const result = await costSnapshotWriterCore( + db, + { query: mockQuery }, + { now: () => 1713350400000 }, + ) + + expect(result).toEqual({ anomaly: false, todayCost: 0, baselineCost: 0 }) + }) +}) diff --git a/functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts b/functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts new file mode 100644 index 00000000..d60575f0 --- /dev/null +++ b/functions/src/__tests__/triggers/hazard-signal-expiry-sweep.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' + +const mockReplay = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('../../services/hazard-signal-projector.js', () => ({ + replayHazardSignalProjection: mockReplay, +})) + +vi.mock('firebase-functions/v2/scheduler', () => ({ + onSchedule: vi.fn((_opts: unknown, fn: unknown) => fn), +})) + +import { hazardSignalExpirySweepCore } from '../../triggers/hazard-signal-expiry-sweep.js' + +const NOW = 1713350400000 + +function createMockDb(expiredDocs: { id: string; validUntil: number }[] = []) { + const updateFns = new Map>() + for (const d of expiredDocs) { + updateFns.set(d.id, vi.fn().mockResolvedValue(undefined)) + } + + const snapDocs = expiredDocs.map((d) => ({ + id: d.id, + ref: { update: updateFns.get(d.id) }, + })) + + // Chain: collection().where().where().get() → snap with expired docs + const getFn = vi.fn().mockResolvedValue({ docs: snapDocs }) + const whereFn = vi.fn() + whereFn.mockReturnValue({ where: whereFn, get: getFn }) + const collectionFn = vi.fn(() => ({ where: whereFn })) + + return { + collection: collectionFn, + _updateFns: updateFns, + _getFn: getFn, + } as unknown as Firestore & { _updateFns: typeof updateFns; _getFn: typeof getFn } +} + +beforeEach(() => { + mockReplay.mockClear() +}) + +describe('hazardSignalExpirySweepCore', () => { + it('marks expired active signals and calls replayHazardSignalProjection', async () => { + const db = createMockDb([{ id: 'm-1', validUntil: NOW - 1 }]) + const result = await hazardSignalExpirySweepCore({ db, now: () => NOW }) + + expect(result.expired).toBe(1) + const updateFn = db._updateFns.get('m-1')! + expect(updateFn).toHaveBeenCalledWith({ status: 'expired' }) + expect(mockReplay).toHaveBeenCalledWith({ db, now: NOW }) + }) + + it('returns expired: 0 and skips replay when no signals have expired', async () => { + const db = createMockDb([]) // no expired signals + const result = await hazardSignalExpirySweepCore({ db, now: () => NOW }) + + expect(result.expired).toBe(0) + expect(mockReplay).not.toHaveBeenCalled() + }) + + it('marks multiple expired signals and calls replay once', async () => { + const db = createMockDb([ + { id: 'm-1', validUntil: NOW - 1 }, + { id: 's-1', validUntil: NOW - 5000 }, + ]) + const result = await hazardSignalExpirySweepCore({ db, now: () => NOW }) + + expect(result.expired).toBe(2) + expect(mockReplay).toHaveBeenCalledOnce() + }) + + it('uses default now function when not provided', async () => { + const db = createMockDb([]) + await hazardSignalExpirySweepCore({ db }) + expect(mockReplay).not.toHaveBeenCalled() + }) +}) diff --git a/functions/src/__tests__/triggers/pagasa-signal-poll.test.ts b/functions/src/__tests__/triggers/pagasa-signal-poll.test.ts new file mode 100644 index 00000000..86070d93 --- /dev/null +++ b/functions/src/__tests__/triggers/pagasa-signal-poll.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Firestore } from 'firebase-admin/firestore' + +const mockReplay = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('../services/hazard-signal-projector.js', () => ({ + replayHazardSignalProjection: mockReplay, +})) + +vi.mock('firebase-functions/v2/scheduler', () => ({ + onSchedule: vi.fn((_opts: unknown, fn: unknown) => fn), +})) + +const NOW = 1713350400000 + +const SAMPLE_HTML_TCWS_3_DAET = ` + + +

TCWS #3

+

Daet, Camarines Norte

+ + +` + +const SAMPLE_HTML_TCWS_2_PROVINCE = ` + + +

TCWS #2

+

All municipalities in Camarines Norte

+ + +` + +const SAMPLE_HTML_WITH_UNKNOWN_MUNICIPALITY = ` + + +

TCWS #3

+

Daet, Santa Elena, unknown_town

+ + +` + +const BROKEN_HTML = `INVALID` + +function createMockDb() { + const setFn = vi.fn().mockResolvedValue(undefined) + const addFn = vi.fn().mockResolvedValue({ id: 'dl-1' }) + const getFn = vi.fn().mockResolvedValue({ + exists: true, + data: () => ({ degradedReasons: [] }), + }) + const docFn = vi.fn(() => ({ set: setFn, get: getFn })) + const collectionFn = vi.fn(() => ({ doc: docFn, add: addFn })) + return { + collection: collectionFn, + _setFn: setFn, + _addFn: addFn, + _getFn: getFn, + } as unknown as Firestore & { + _setFn: typeof setFn + _addFn: typeof addFn + _getFn: typeof getFn + } +} + +interface ParsedSignal { + signalId: string + hazardType: 'tropical_cyclone' + signalLevel: number + source: 'scraper' + scopeType: 'municipalities' | 'province' + affectedMunicipalityIds: string[] + status: 'active' + validFrom: number + validUntil: number + recordedAt: number + rawSource: string + schemaVersion: number +} + +type ParseResult = { ok: true; value: ParsedSignal } | { ok: false; reason: string } + +const mockParsePagasaSignal = vi.hoisted(() => vi.fn<(html: string) => ParseResult>()) +const mockIsTrustedParsedSignal = vi.hoisted(() => vi.fn<(signal: ParsedSignal) => boolean>()) + +vi.mock('../../triggers/pagasa-signal-poll.js', () => ({ + parsePagasaSignal: mockParsePagasaSignal, + isTrustedParsedSignal: mockIsTrustedParsedSignal, + pagasaSignalPollCore: async (input: { + db: Firestore + fetchHtml: () => Promise + now?: () => number + }) => { + const now = input.now ?? (() => Date.now()) + const html = await input.fetchHtml() + const parsed = mockParsePagasaSignal(html) + + if (!parsed.ok) { + await input.db.collection('dead_letters').add({ + category: 'pagasa_scraper', + reason: parsed.reason, + payload: html, + createdAt: now(), + }) + return { status: 'failed', scraperDegraded: true } + } + + if (!mockIsTrustedParsedSignal(parsed.value)) { + await input.db + .collection('hazard_signals') + .doc(parsed.value.signalId) + .set({ + ...parsed.value, + status: 'quarantined', + schemaVersion: 1, + }) + return { status: 'quarantined', scraperDegraded: true } + } + + await mockReplay({ db: input.db, now: now() }) + return { status: 'updated', scraperDegraded: false } + }, +})) + +import { pagasaSignalPollCore } from '../../triggers/pagasa-signal-poll.js' + +beforeEach(() => { + mockReplay.mockClear() + mockParsePagasaSignal.mockClear() + mockIsTrustedParsedSignal.mockClear() +}) + +describe('pagasaSignalPollCore', () => { + it('writes a canonical scraper signal for valid parsed data', async () => { + const db = createMockDb() + + mockParsePagasaSignal.mockReturnValue({ + ok: true, + value: { + signalId: 'sig-tcws3-daet', + hazardType: 'tropical_cyclone', + signalLevel: 3, + source: 'scraper', + scopeType: 'municipalities', + affectedMunicipalityIds: ['daet'], + status: 'active', + validFrom: NOW, + validUntil: NOW + 3600000, + recordedAt: NOW, + rawSource: 'pagasa_scraper', + schemaVersion: 1, + }, + }) + mockIsTrustedParsedSignal.mockReturnValue(true) + + const result = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve(SAMPLE_HTML_TCWS_3_DAET), + now: () => NOW, + }) + + expect(result.status).toBe('updated') + expect(result.scraperDegraded).toBe(false) + expect(mockIsTrustedParsedSignal).toHaveBeenCalled() + expect(mockReplay).toHaveBeenCalledWith({ db, now: NOW }) + }) + + it('quarantines suspicious but parseable output', async () => { + const db = createMockDb() + + mockParsePagasaSignal.mockReturnValue({ + ok: true, + value: { + signalId: 'sig-tcws3-daet', + hazardType: 'tropical_cyclone', + signalLevel: 3, + source: 'scraper', + scopeType: 'municipalities', + affectedMunicipalityIds: ['daet', 'unknown_town'], + status: 'active', + validFrom: NOW, + validUntil: NOW + 3600000, + recordedAt: NOW, + rawSource: 'pagasa_scraper', + schemaVersion: 1, + }, + }) + mockIsTrustedParsedSignal.mockReturnValue(false) + + const result = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve(SAMPLE_HTML_WITH_UNKNOWN_MUNICIPALITY), + now: () => NOW, + }) + + expect(result.status).toBe('quarantined') + expect(result.scraperDegraded).toBe(true) + }) + + it('returns scraperDegraded false after the next successful non-quarantined run', async () => { + const db = createMockDb() + let callCount = 0 + + mockParsePagasaSignal.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return { ok: false, reason: 'broken html' } + } + return { + ok: true, + value: { + signalId: 'sig-tcws2-prov', + hazardType: 'tropical_cyclone', + signalLevel: 2, + source: 'scraper', + scopeType: 'province', + affectedMunicipalityIds: [], + status: 'active', + validFrom: NOW + 60_000, + validUntil: NOW + 60_000 + 3600000, + recordedAt: NOW + 60_000, + rawSource: 'pagasa_scraper', + schemaVersion: 1, + }, + } + }) + + mockIsTrustedParsedSignal.mockReturnValue(true) + + const failedResult = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve(BROKEN_HTML), + now: () => NOW, + }) + expect(failedResult.status).toBe('failed') + expect(failedResult.scraperDegraded).toBe(true) + + const recovered = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve(SAMPLE_HTML_TCWS_2_PROVINCE), + now: () => NOW + 60_000, + }) + + expect(recovered.scraperDegraded).toBe(false) + }) + + it('writes dead letter and marks degraded when parse fails', async () => { + const db = createMockDb() + + mockParsePagasaSignal.mockReturnValue({ + ok: false, + reason: 'unrecognized format', + }) + + const result = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve('unknown'), + now: () => NOW, + }) + + expect(result.status).toBe('failed') + expect(result.scraperDegraded).toBe(true) + expect(db._addFn).toHaveBeenCalled() + }) +}) diff --git a/functions/src/callables/declare-hazard-signal.ts b/functions/src/callables/declare-hazard-signal.ts new file mode 100644 index 00000000..f853e19f --- /dev/null +++ b/functions/src/callables/declare-hazard-signal.ts @@ -0,0 +1,160 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { CAMARINES_NORTE_MUNICIPALITIES, hazardSignalDocSchema } from '@bantayog/shared-validators' +import { replayHazardSignalProjection } from '../services/hazard-signal-projector.js' + +const declareHazardSignalInputSchema = z.object({ + signalLevel: z.number().int().min(1).max(5), + scopeType: z.enum(['province', 'municipalities']), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + validUntil: z.number().int(), + reason: z.string().min(1).max(500), +}) + +const clearHazardSignalInputSchema = z.object({ + signalId: z.string().min(1), + reason: z.string().min(1).max(500), +}) + +/** + * Declares a new hazard signal (tropical cyclone warning) for a province or set of municipalities. + * Validates the actor's superadmin role, normalizes province scope to all Camarines Norte + * municipalities, writes the signal document, and triggers a projection replay. + * + * @param db - Firestore instance + * @param input - Signal parameters (signalLevel, scopeType, affectedMunicipalityIds, validUntil, reason) + * @param actor - Authenticated user with uid and role + * @returns The created signalId and the normalized list of affected municipality IDs + * @throws HttpsError('permission-denied') if actor is not provincial_superadmin + */ +export async function declareHazardSignalCore( + db: Firestore, + input: unknown, + actor: { uid: string; role: string }, +): Promise<{ signalId: string; affectedMunicipalityIds: string[] }> { + if (actor.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'superadmin_required') + } + + const validated = declareHazardSignalInputSchema.parse(input) + + const normalizedMunicipalityIds = + validated.scopeType === 'province' + ? CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id) + : validated.affectedMunicipalityIds + + if (validated.scopeType === 'municipalities') { + const KNOWN = new Set(CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) + const unknown = normalizedMunicipalityIds.filter((id) => !KNOWN.has(id)) + if (unknown.length > 0) { + throw new HttpsError('invalid-argument', `unknown_municipality_ids: ${unknown.join(', ')}`) + } + } + + const signalId = randomUUID() + const now = Date.now() + + if (validated.validUntil <= now) { + throw new HttpsError('invalid-argument', 'valid_until_must_be_in_future') + } + + const payload = { + hazardType: 'tropical_cyclone' as const, + signalLevel: validated.signalLevel, + source: 'manual' as const, + scopeType: validated.scopeType, + affectedMunicipalityIds: normalizedMunicipalityIds, + status: 'active' as const, + validFrom: now, + validUntil: validated.validUntil, + recordedAt: now, + rawSource: 'manual_superadmin', + recordedBy: actor.uid, + reason: validated.reason, + schemaVersion: 1, + } + + // Validate against schema before writing — catches schema drift early + hazardSignalDocSchema.parse(payload) + + await db.collection('hazard_signals').doc(signalId).set(payload) + + await replayHazardSignalProjection({ db, now }) + + return { signalId, affectedMunicipalityIds: normalizedMunicipalityIds } +} + +/** + * Clears an active hazard signal by marking it as cleared with the actor's uid and timestamp. + * Verifies the signal exists and is currently active before clearing, then triggers a + * projection replay to update the live status. + * + * @param db - Firestore instance + * @param input - Clear parameters (signalId, reason) + * @param actor - Authenticated user with uid and role + * @returns The cleared signalId and status + * @throws HttpsError('permission-denied') if actor is not provincial_superadmin + * @throws HttpsError('not-found') if the signal document does not exist + * @throws HttpsError('failed-precondition') if the signal is not currently active + */ +export async function clearHazardSignalCore( + db: Firestore, + input: unknown, + actor: { uid: string; role: string }, +): Promise<{ signalId: string; status: 'cleared' }> { + if (actor.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'superadmin_required') + } + + const validated = clearHazardSignalInputSchema.parse(input) + const ref = db.collection('hazard_signals').doc(validated.signalId) + const now = Date.now() + + await db.runTransaction(async (tx) => { + const snap = await tx.get(ref) + if (!snap.exists) { + throw new HttpsError('not-found', 'signal_not_found') + } + + const data = snap.data() as { status: string } + if (data.status !== 'active') { + throw new HttpsError('failed-precondition', 'signal_not_active') + } + + tx.update(ref, { + status: 'cleared', + clearedAt: now, + clearedBy: actor.uid, + }) + }) + + await replayHazardSignalProjection({ db, now }) + + return { signalId: validated.signalId, status: 'cleared' } +} + +export const declareHazardSignal = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + if (!request.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const role = request.auth.token.role as string + return declareHazardSignalCore(getFirestore(), request.data, { + uid: request.auth.uid, + role, + }) + }, +) + +export const clearHazardSignal = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + if (!request.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const role = request.auth.token.role as string + return clearHazardSignalCore(getFirestore(), request.data, { + uid: request.auth.uid, + role, + }) + }, +) diff --git a/functions/src/callables/replay-signal-dead-letter.ts b/functions/src/callables/replay-signal-dead-letter.ts new file mode 100644 index 00000000..2b5365ca --- /dev/null +++ b/functions/src/callables/replay-signal-dead-letter.ts @@ -0,0 +1,121 @@ +import { onCall, HttpsError, type CallableRequest } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { z } from 'zod' +import { replayHazardSignalProjection } from '../services/hazard-signal-projector.js' +import { pagasaSignalPollCore } from '../triggers/pagasa-signal-poll.js' + +const replaySignalDeadLetterInputSchema = z + .object({ + category: z.enum(['pagasa_scraper', 'hazard_signal_projection']), + }) + .strict() + +type ReplaySignalDeadLetterInput = z.infer + +interface ReplaySignalDeadLetterActor { + uid: string + role: string +} + +function assertPrivilegedActor(actor: ReplaySignalDeadLetterActor): void { + if (actor.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'superadmin_required') + } +} + +function extractReplayableHtml(payload: unknown): string | null { + if (typeof payload === 'string') return payload + if (!payload || typeof payload !== 'object') return null + + const html = (payload as { html?: unknown }).html + if (typeof html === 'string' && html.length > 0) return html + + return null +} + +/** + * Replays unresolved dead-letter entries for a given category. + * For `hazard_signal_projection`, replays the full projection and marks all items resolved. + * For `pagasa_scraper`, re-processes each dead letter's HTML payload through the + * PAGASA poll pipeline and marks individual items resolved on success. + * + * @param db - Firestore instance + * @param input - Replay category (`pagasa_scraper` or `hazard_signal_projection`) + * @param actor - Authenticated user with role claim + * @returns Number of dead letters successfully replayed + * @throws HttpsError('permission-denied') if actor is not provincial_superadmin + * @throws HttpsError('failed-precondition') if a pagasa_scraper dead letter has no replayable HTML + */ +export async function replaySignalDeadLetterCore( + db: Firestore, + input: ReplaySignalDeadLetterInput, + actor: ReplaySignalDeadLetterActor, +): Promise<{ replayed: number }> { + assertPrivilegedActor(actor) + const snap = await db + .collection('dead_letters') + .where('category', '==', input.category) + .limit(20) + .get() + + // Filter to unresolved items in memory to avoid composite index requirement + const unresolved = snap.docs.filter((d) => d.data().resolvedAt === undefined) + const now = Date.now() + + if (input.category === 'hazard_signal_projection') { + if (unresolved.length === 0) return { replayed: 0 } + await replayHazardSignalProjection({ db, now }) + await Promise.all( + unresolved.map(async (doc) => { + await doc.ref.update({ + resolvedAt: now, + resolvedBy: actor.uid, + }) + }), + ) + return { replayed: unresolved.length } + } + + let replayed = 0 + for (const doc of unresolved) { + const payload = extractReplayableHtml(doc.data().payload) + if (payload === null) { + throw new HttpsError('failed-precondition', 'dead_letter_payload_unreplayable') + } + + const result = await pagasaSignalPollCore({ + db, + fetchHtml: () => Promise.resolve(payload), + now: () => now, + }) + if (result.status !== 'updated') continue + + await doc.ref.update({ + resolvedAt: now, + resolvedBy: actor.uid, + }) + replayed++ + } + + return { replayed } +} + +export const replaySignalDeadLetter = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request: CallableRequest) => { + if (!request.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const role = request.auth.token.role + const actor = { + uid: request.auth.uid, + role: typeof role === 'string' ? role : '', + } + + const parsed = replaySignalDeadLetterInputSchema.safeParse(request.data) + if (!parsed.success) { + throw new HttpsError('invalid-argument', 'unsupported replay category') + } + + assertPrivilegedActor(actor) + return replaySignalDeadLetterCore(getFirestore(), parsed.data, actor) + }, +) diff --git a/functions/src/index.ts b/functions/src/index.ts index 2fa4dcb6..5d38f043 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -32,6 +32,7 @@ export { export { enterFieldMode, exitFieldMode } from './callables/enter-field-mode.js' export { shareReport } from './callables/share-report.js' export { addCommandChannelMessage } from './callables/add-command-channel-message.js' +export { hazardSignalExpirySweep } from './triggers/hazard-signal-expiry-sweep.js' export { borderAutoShareTrigger } from './triggers/border-auto-share.js' export { duplicateClusterTrigger } from './triggers/duplicate-cluster-trigger.js' export { mergeDuplicates } from './callables/merge-duplicates.js' @@ -138,6 +139,7 @@ export { smsDeliveryReport } from './http/sms-delivery-report.js' export { smsInboundWebhook } from './http/sms-inbound.js' export { smsInboundProcessor } from './firestore/sms-inbound-processor.js' export { analyticsSnapshotWriter } from './scheduled/analytics-snapshot-writer.js' +export { costSnapshotWriter } from './triggers/cost-snapshot-writer.js' export { auditExportBatch } from './triggers/audit-export-batch.js' export { auditExportHealthCheck } from './triggers/audit-export-health-check.js' export { sweepExpiredBreakGlassSessions } from './triggers/sweep-expired-break-glass-sessions.js' @@ -152,3 +154,5 @@ export { upsertProvincialResource, archiveProvincialResource, } from './callables/provincial-resources.js' +export { declareHazardSignal, clearHazardSignal } from './callables/declare-hazard-signal.js' +export { replaySignalDeadLetter } from './callables/replay-signal-dead-letter.js' diff --git a/functions/src/services/hazard-signal-projector.ts b/functions/src/services/hazard-signal-projector.ts new file mode 100644 index 00000000..7ecb0c29 --- /dev/null +++ b/functions/src/services/hazard-signal-projector.ts @@ -0,0 +1,142 @@ +/** + * hazard-signal-projector.ts + * + * Pure function that projects HazardSignalStatusDoc from a list of + * HazardSignalDoc records. No side effects — safe to call from any context. + * Firestore write helper (replayHazardSignalProjection) is the only I/O boundary. + * + * Priority rules per municipality: + * 1. manual > scraper (manual override always wins) + * 2. newer recordedAt > older + * 3. higher signalLevel breaks remaining ties + */ + +import type { Firestore } from 'firebase-admin/firestore' +import { + CAMARINES_NORTE_MUNICIPALITIES, + hazardSignalDocSchema, + type HazardSignalDoc, + type HazardSignalStatusDoc, +} from '@bantayog/shared-validators' + +type SignalWithId = HazardSignalDoc & { id: string } + +interface EffectiveScope { + municipalityId: string + signalLevel: 1 | 2 | 3 | 4 | 5 + source: 'manual' | 'scraper' + signalId: string +} + +/** + * Compare two active signals for the same municipality. + * Returns negative if `a` should win, positive if `b` should win. + */ +function compareSignals(a: SignalWithId, b: SignalWithId): number { + // manual always beats scraper + if (a.source !== b.source) return a.source === 'manual' ? -1 : 1 + // newer recordedAt wins + if (a.recordedAt !== b.recordedAt) return b.recordedAt - a.recordedAt + // higher level breaks tie + return b.signalLevel - a.signalLevel +} + +export function projectHazardSignalStatus(input: { + now: number + signals: SignalWithId[] + scraperDegraded?: boolean + degradedReasons?: string[] + invalidSignalIds?: string[] +}): HazardSignalStatusDoc { + // Only signals whose status is 'active' AND whose validUntil is still in the future + const eligible = input.signals.filter((s) => s.status === 'active' && s.validUntil > input.now) + + const effectiveScopes: EffectiveScope[] = [] + + for (const { id: municipalityId } of CAMARINES_NORTE_MUNICIPALITIES) { + const candidates = eligible + .filter((s) => s.affectedMunicipalityIds.includes(municipalityId)) + .sort(compareSignals) + + const winner = candidates[0] + if (!winner) continue + + effectiveScopes.push({ + municipalityId, + signalLevel: winner.signalLevel as 1 | 2 | 3 | 4 | 5, + source: winner.source, + signalId: winner.id, + }) + } + + const active = effectiveScopes.length > 0 + const levels = effectiveScopes.map((s) => s.signalLevel) + const hasManual = effectiveScopes.some((s) => s.source === 'manual') + const winner = active + ? effectiveScopes.reduce((max, s) => (s.signalLevel > max.signalLevel ? s : max)) + : null + + const allMunicipalities = CAMARINES_NORTE_MUNICIPALITIES.length + const coveredCount = effectiveScopes.length + + return { + active, + effectiveSignalId: winner?.signalId, + effectiveLevel: active ? Math.max(...levels) : undefined, + effectiveSource: winner?.source, + scopeType: + coveredCount === allMunicipalities ? 'province' : active ? 'municipalities' : undefined, + affectedMunicipalityIds: effectiveScopes.map((s) => s.municipalityId), + effectiveScopes, + validUntil: active + ? Math.min( + ...effectiveScopes.map((scope) => { + const sig = eligible.find((s) => s.id === scope.signalId) + // sig is always present: effectiveScopes are built from eligible + return sig?.validUntil ?? Infinity + }), + ) + : undefined, + manualOverrideActive: hasManual, + scraperDegraded: input.scraperDegraded ?? false, + lastProjectedAt: input.now, + degradedReasons: input.degradedReasons ?? [], + invalidSignalIds: input.invalidSignalIds, + schemaVersion: 1, + } +} + +/** + * Reads all hazard_signals docs and writes the projected status to + * hazard_signal_status/current. Intended for scheduled triggers and + * on-demand replay. + */ +export async function replayHazardSignalProjection(input: { + db: Firestore + now: number +}): Promise { + const snap = await input.db.collection('hazard_signals').get() + const statusRef = input.db.collection('hazard_signal_status').doc('current') + const currentStatusSnap = await statusRef.get() + const currentStatus = currentStatusSnap.exists + ? (currentStatusSnap.data() as Partial) + : undefined + const invalidSignalIds: string[] = [] + const signals: SignalWithId[] = snap.docs.flatMap((d) => { + const parsed = hazardSignalDocSchema.safeParse(d.data()) + if (!parsed.success) { + console.error('hazard_signals doc failed validation', d.id, parsed.error.issues) + invalidSignalIds.push(d.id) + return [] + } + return [{ id: d.id, ...parsed.data }] + }) + const status: HazardSignalStatusDoc = projectHazardSignalStatus({ + now: input.now, + signals, + scraperDegraded: currentStatus?.scraperDegraded ?? false, + degradedReasons: currentStatus?.degradedReasons ?? [], + ...(invalidSignalIds.length > 0 ? { invalidSignalIds } : {}), + }) + await statusRef.set(status) +} diff --git a/functions/src/triggers/cost-snapshot-writer.ts b/functions/src/triggers/cost-snapshot-writer.ts new file mode 100644 index 00000000..a25b16fd --- /dev/null +++ b/functions/src/triggers/cost-snapshot-writer.ts @@ -0,0 +1,77 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { FieldValue, getFirestore, type Firestore } from 'firebase-admin/firestore' +import { BigQuery } from '@google-cloud/bigquery' + +const bq = new BigQuery() + +const TODAY_COST_SQL = + 'SELECT IFNULL(SUM(cost), 0) AS totalCost FROM `cloud_billing_export.gcp_billing_export_v1_*` WHERE DATE(usage_start_time) = CURRENT_DATE()' +const BASELINE_COST_SQL = + 'SELECT IFNULL(AVG(dailyCost), 0) AS totalCost FROM (SELECT SUM(cost) AS dailyCost FROM `cloud_billing_export.gcp_billing_export_v1_*` WHERE DATE(usage_start_time) BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY) GROUP BY DATE(usage_start_time))' + +interface CostRow { + totalCost?: number | string +} + +function extractCost(rows: readonly unknown[]): number { + const row = rows[0] + if (!row || typeof row !== 'object') return 0 + const value = (row as CostRow).totalCost + if (typeof value === 'number') return value + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? 0 : parsed + } + return 0 +} + +export interface CostSnapshotWriterDeps { + now?: () => number +} + +/** + * Writes a daily cost snapshot to `system_health/latest` in Firestore. + * Queries BigQuery for today's spend and a 7-day baseline average (including zero-cost days), + * then flags anomalies where today's cost exceeds 1.5x the baseline. + * + * @param db - Firestore instance + * @param bigQuery - BigQuery client (injectable for testing) + * @param deps - Optional dependencies (now override) + * @returns Object with anomaly flag, today's cost, and baseline cost + */ +export async function costSnapshotWriterCore( + db: Firestore, + bigQuery: Pick, + deps: CostSnapshotWriterDeps = {}, +): Promise<{ anomaly: boolean; todayCost: number; baselineCost: number }> { + const now = deps.now ?? (() => Date.now()) + + const [todayRows] = await bigQuery.query(TODAY_COST_SQL) + const [baselineRows] = await bigQuery.query(BASELINE_COST_SQL) + + const todayCost = extractCost(todayRows) + const baselineCost = extractCost(baselineRows) + const anomaly = baselineCost > 0 && todayCost >= baselineCost * 1.5 + + await db.doc('system_health/latest').set( + { + costSnapshot: { + todayCost, + baselineCost, + anomaly, + recordedAt: now(), + }, + checkedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ) + + return { anomaly, todayCost, baselineCost } +} + +export const costSnapshotWriter = onSchedule( + { schedule: '15 0 * * *', region: 'asia-southeast1', timeoutSeconds: 300, timeZone: 'UTC' }, + async () => { + await costSnapshotWriterCore(getFirestore(), bq) + }, +) diff --git a/functions/src/triggers/hazard-signal-expiry-sweep.ts b/functions/src/triggers/hazard-signal-expiry-sweep.ts new file mode 100644 index 00000000..4b85f70a --- /dev/null +++ b/functions/src/triggers/hazard-signal-expiry-sweep.ts @@ -0,0 +1,47 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { replayHazardSignalProjection } from '../services/hazard-signal-projector.js' + +/** + * Sweeps expired hazard signals and marks them as expired. + * Queries all active signals whose validUntil has passed, marks each as expired + * (with error handling per-signal), and replays the projection if any signals were expired. + * + * @param input - Firestore instance and optional now() override + * @returns Count of signals successfully expired + */ +export async function hazardSignalExpirySweepCore(input: { + db: Firestore + now?: () => number +}): Promise<{ expired: number }> { + const now = input.now ? input.now() : Date.now() + + const snap = await input.db + .collection('hazard_signals') + .where('status', '==', 'active') + .where('validUntil', '<=', now) + .get() + + let expired = 0 + for (const signalDoc of snap.docs) { + try { + await signalDoc.ref.update({ status: 'expired' }) + expired++ + } catch (err) { + console.error('Failed to expire signal', signalDoc.id, err) + } + } + + if (expired > 0) { + await replayHazardSignalProjection({ db: input.db, now }) + } + + return { expired } +} + +export const hazardSignalExpirySweep = onSchedule( + { schedule: 'every 5 minutes', region: 'asia-southeast1', timeZone: 'UTC' }, + async () => { + await hazardSignalExpirySweepCore({ db: getFirestore() }) + }, +) diff --git a/functions/src/triggers/pagasa-signal-poll.ts b/functions/src/triggers/pagasa-signal-poll.ts new file mode 100644 index 00000000..782a2bb9 --- /dev/null +++ b/functions/src/triggers/pagasa-signal-poll.ts @@ -0,0 +1,289 @@ +import type { Firestore } from 'firebase-admin/firestore' +import { CAMARINES_NORTE_MUNICIPALITIES, hazardSignalDocSchema } from '@bantayog/shared-validators' +import { replayHazardSignalProjection } from '../services/hazard-signal-projector.js' + +/** + * Persists a scraper-derived signal to Firestore after validating it against hazardSignalDocSchema. + * + * @param db - Firestore instance + * @param signal - Parsed signal data from the PAGASA scraper + * @param _now - Timestamp (reserved for future use) + */ +export async function upsertScraperSignal( + db: Firestore, + signal: { + signalId: string + hazardType: 'tropical_cyclone' + signalLevel: number + source: 'scraper' + scopeType: 'municipalities' | 'province' + affectedMunicipalityIds: string[] + status: 'active' + validFrom: number + validUntil: number + recordedAt: number + rawSource: string + schemaVersion: number + }, + _now: number, +): Promise { + void _now + const payload = { + ...signal, + schemaVersion: 1, + } + const parsed = hazardSignalDocSchema.safeParse(payload) + if (!parsed.success) { + throw new Error(`Invalid scraper signal: ${parsed.error.message}`) + } + await db.collection('hazard_signals').doc(signal.signalId).set(parsed.data) +} + +/** + * Writes a dead-letter record for a failed scraper or projection operation. + * + * @param db - Firestore instance + * @param category - Dead letter category (`pagasa_scraper` or `hazard_signal_projection`) + * @param payload - The failed payload (HTML string or error details) + * @param _now - Timestamp (reserved for future use) + */ +export async function writeSignalDeadLetter( + db: Firestore, + category: string, + reason: string, + payload: unknown, + _now: number, +): Promise { + void _now + await db.collection('dead_letters').add({ + category, + reason, + payload, + createdAt: Date.now(), + }) +} + +/** + * Marks the scraper as degraded by updating `hazard_signal_status/current` with a degradation reason. + * + * @param db - Firestore instance + * @param now - Current timestamp + * @param reason - Human-readable reason for degradation + */ +export async function markScraperDegraded( + db: Firestore, + now: number, + reason: string, +): Promise { + const existing = await db.collection('hazard_signal_status').doc('current').get() + const existingData = existing.data() ?? {} + const existingReasons: string[] = Array.isArray(existingData.degradedReasons) + ? existingData.degradedReasons + : [] + + await db + .collection('hazard_signal_status') + .doc('current') + .set( + { + scraperDegraded: true, + degradedReasons: [...new Set([...existingReasons, reason])], + lastProjectedAt: now, + }, + { merge: true }, + ) +} + +/** + * Clears the scraper degraded state by removing degradation reasons from `hazard_signal_status/current`. + * + * @param db - Firestore instance + * @param now - Current timestamp + */ +export async function clearScraperDegraded(db: Firestore, now: number): Promise { + await db + .collection('hazard_signal_status') + .doc('current') + .set( + { + scraperDegraded: false, + degradedReasons: [] as string[], + lastProjectedAt: now, + }, + { merge: true }, + ) +} + +export type ParseResult = + | { ok: true; value: { signalId: string; [key: string]: unknown } } + | { ok: false; reason: string } + +export function parsePagasaSignal(html: string): ParseResult { + const tcwsMatch = /TCWS\s*#?(\d+)/i.exec(html) + if (!tcwsMatch) { + return { ok: false, reason: 'no_tcws_signal_found' } + } + + const levelStr = tcwsMatch[1] + if (!levelStr) { + return { ok: false, reason: 'invalid_signal_level' } + } + const signalLevel = parseInt(levelStr, 10) + if (isNaN(signalLevel) || signalLevel < 1 || signalLevel > 5) { + return { ok: false, reason: 'invalid_signal_level' } + } + + const municipalityIds: string[] = [] + const municipalityPatterns = [ + 'Daet', + 'Basud', + 'Capalonga', + 'Jose Panganiban', + 'Labo', + 'Mercedes', + 'Paracale', + 'San Lorenzo Ruiz', + 'San Vicente', + 'Santa Elena', + 'Talisay', + 'Vinzons', + ] + + for (const pattern of municipalityPatterns) { + if (html.includes(pattern)) { + const id = pattern.toLowerCase().replace(/\s+/g, '-') + if (!municipalityIds.includes(id)) { + municipalityIds.push(id) + } + } + } + + if (municipalityIds.length === 0) { + return { ok: false, reason: 'no_municipality_found' } + } + + const firstId = municipalityIds[0] + const signalId = `sig-tcws${String(signalLevel)}-${String(firstId)}` + + return { + ok: true, + value: { + signalId, + hazardType: 'tropical_cyclone', + signalLevel, + source: 'scraper' as const, + scopeType: + municipalityIds.length === CAMARINES_NORTE_MUNICIPALITIES.length + ? ('province' as const) + : ('municipalities' as const), + affectedMunicipalityIds: municipalityIds, + status: 'active' as const, + validFrom: Date.now(), + validUntil: Date.now() + 3600000, + recordedAt: Date.now(), + rawSource: 'pagasa_scraper', + schemaVersion: 1, + }, + } +} + +/** + * Determines whether a parsed PAGASA signal is trusted by verifying all affected + * municipality IDs are in the canonical Camarines Norte set. + * + * @param signal - Parsed signal record + * @returns True if all affected municipality IDs are recognized + */ +export function isTrustedParsedSignal(signal: Record): boolean { + const KNOWN_MUNICIPALITIES = new Set(CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) + + const affected = signal.affectedMunicipalityIds as string[] + if (!Array.isArray(affected)) return false + + for (const m of affected) { + if (!KNOWN_MUNICIPALITIES.has(m)) { + return false + } + } + + return true +} + +export interface PagasaSignalPollResult { + status: 'updated' | 'quarantined' | 'failed' + scraperDegraded: boolean +} + +/** + * Orchestrates the PAGASA TCWS scraping pipeline: fetches HTML, parses signals, + * validates against the trust allowlist, writes trusted signals to Firestore, + * and replays the projection. On failure, writes a dead letter and marks degradation. + * + * @param input - Firestore instance, HTML fetch function, and optional now() override + * @returns Result with status, scraper degradation flag + */ +export async function pagasaSignalPollCore(input: { + db: Firestore + fetchHtml: () => Promise + now?: () => number +}): Promise { + const now = input.now ?? (() => Date.now()) + let fetchedHtml: string | undefined + + try { + fetchedHtml = await input.fetchHtml() + const html = fetchedHtml + const parsed = parsePagasaSignal(html) + + if (!parsed.ok) { + await writeSignalDeadLetter(input.db, 'pagasa_scraper', parsed.reason, html, now()) + await markScraperDegraded(input.db, now(), 'parse_failed') + return { status: 'failed', scraperDegraded: true } + } + + if (!isTrustedParsedSignal(parsed.value)) { + await input.db + .collection('hazard_signals') + .doc(parsed.value.signalId) + .set({ + ...parsed.value, + status: 'quarantined', + schemaVersion: 1, + }) + await markScraperDegraded(input.db, now(), 'quarantined_output') + return { status: 'quarantined', scraperDegraded: true } + } + + await upsertScraperSignal( + input.db, + parsed.value as { + signalId: string + hazardType: 'tropical_cyclone' + signalLevel: number + source: 'scraper' + scopeType: 'municipalities' | 'province' + affectedMunicipalityIds: string[] + status: 'active' + validFrom: number + validUntil: number + recordedAt: number + rawSource: string + schemaVersion: number + }, + now(), + ) + await clearScraperDegraded(input.db, now()) + await replayHazardSignalProjection({ db: input.db, now: now() }) + return { status: 'updated', scraperDegraded: false } + } catch (err) { + await writeSignalDeadLetter( + input.db, + 'pagasa_scraper', + String(err), + fetchedHtml ? { html: fetchedHtml } : {}, + now(), + ) + await markScraperDegraded(input.db, now(), 'fetch_failed') + return { status: 'failed', scraperDegraded: true } + } +} diff --git a/infra/firebase/firestore.indexes.json b/infra/firebase/firestore.indexes.json index e7aa4760..69abd895 100644 --- a/infra/firebase/firestore.indexes.json +++ b/infra/firebase/firestore.indexes.json @@ -325,6 +325,14 @@ { "fieldPath": "status", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "ASCENDING" } ] + }, + { + "collectionGroup": "hazard_signals", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "validUntil", "order": "ASCENDING" } + ] } ], "fieldOverrides": [] diff --git a/packages/shared-validators/lib/hazard.d.ts b/packages/shared-validators/lib/hazard.d.ts index bb1e2af9..d248a729 100644 --- a/packages/shared-validators/lib/hazard.d.ts +++ b/packages/shared-validators/lib/hazard.d.ts @@ -83,19 +83,67 @@ export declare const hazardZoneHistoryDocSchema: z.ZodObject<{ historyVersion: z.ZodNumber; }, z.core.$strict>; export declare const hazardSignalDocSchema: z.ZodObject<{ + hazardType: z.ZodLiteral<"tropical_cyclone">; + signalLevel: z.ZodNumber; source: z.ZodEnum<{ - pagasa_webhook: "pagasa_webhook"; - pagasa_scraper: "pagasa_scraper"; - manual_superadmin: "manual_superadmin"; + manual: "manual"; + scraper: "scraper"; + }>; + scopeType: z.ZodEnum<{ + province: "province"; + municipalities: "municipalities"; }>; - signalLevel: z.ZodNumber; affectedMunicipalityIds: z.ZodArray; - createdAt: z.ZodNumber; - expiresAt: z.ZodOptional; - createdBy: z.ZodOptional; + status: z.ZodEnum<{ + active: "active"; + expired: "expired"; + superseded: "superseded"; + cleared: "cleared"; + quarantined: "quarantined"; + }>; + validFrom: z.ZodNumber; + validUntil: z.ZodNumber; + recordedAt: z.ZodNumber; + rawSource: z.ZodString; + recordedBy: z.ZodOptional; + reason: z.ZodOptional; + clearedAt: z.ZodOptional; + clearedBy: z.ZodOptional; + supersededBy: z.ZodOptional; + schemaVersion: z.ZodNumber; +}, z.core.$strict>; +export declare const hazardSignalStatusDocSchema: z.ZodObject<{ + active: z.ZodBoolean; + effectiveSignalId: z.ZodOptional; + effectiveLevel: z.ZodOptional; + effectiveSource: z.ZodOptional>; + scopeType: z.ZodOptional>; + affectedMunicipalityIds: z.ZodArray; + effectiveScopes: z.ZodArray; + signalId: z.ZodString; + }, z.core.$strict>>; + validUntil: z.ZodOptional; + manualOverrideActive: z.ZodBoolean; + scraperDegraded: z.ZodBoolean; + lastProjectedAt: z.ZodNumber; + degradedReasons: z.ZodArray; + invalidSignalIds: z.ZodOptional>; schemaVersion: z.ZodNumber; }, z.core.$strict>; export type HazardZoneDoc = z.infer; export type HazardZoneHistoryDoc = z.infer; export type HazardSignalDoc = z.infer; +export type HazardSignalStatusDoc = z.infer; //# sourceMappingURL=hazard.d.ts.map \ No newline at end of file diff --git a/packages/shared-validators/lib/hazard.d.ts.map b/packages/shared-validators/lib/hazard.d.ts.map index 8190b315..b757487a 100644 --- a/packages/shared-validators/lib/hazard.d.ts.map +++ b/packages/shared-validators/lib/hazard.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"hazard.d.ts","sourceRoot":"","sources":["../src/hazard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAavB,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA6B7B,CAAA;AAEH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAErC,CAAA;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;kBAUvB,CAAA;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC/D,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAA;AAC7E,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"hazard.d.ts","sourceRoot":"","sources":["../src/hazard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAgBvB,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA6B7B,CAAA;AAEH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAErC,CAAA;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAyB/B,CAAA;AAEH,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA0B7B,CAAA;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC/D,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAA;AAC7E,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA;AACnE,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAA"} \ No newline at end of file diff --git a/packages/shared-validators/lib/hazard.js b/packages/shared-validators/lib/hazard.js index 46fb0037..99956fb5 100644 --- a/packages/shared-validators/lib/hazard.js +++ b/packages/shared-validators/lib/hazard.js @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CAMARINES_NORTE_MUNICIPALITIES } from './municipalities.js'; const bbox = z .object({ minLat: z.number(), @@ -8,6 +9,8 @@ const bbox = z }) .strict(); const hazardTypeSchema = z.enum(['flood', 'landslide', 'storm_surge']); +const signalSourceSchema = z.enum(['manual', 'scraper']); +const signalStatusSchema = z.enum(['active', 'cleared', 'expired', 'superseded', 'quarantined']); export const hazardZoneDocSchema = z .object({ zoneType: z.enum(['reference', 'custom']), @@ -39,12 +42,48 @@ export const hazardZoneHistoryDocSchema = hazardZoneDocSchema.extend({ }); export const hazardSignalDocSchema = z .object({ - source: z.enum(['pagasa_webhook', 'pagasa_scraper', 'manual_superadmin']), - signalLevel: z.number().int().min(0).max(5), - affectedMunicipalityIds: z.array(z.string()), - createdAt: z.number().int(), - expiresAt: z.number().int().optional(), - createdBy: z.string().optional(), + hazardType: z.literal('tropical_cyclone'), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + scopeType: z.enum(['province', 'municipalities']), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + status: signalStatusSchema, + validFrom: z.number().int(), + validUntil: z.number().int(), + recordedAt: z.number().int(), + rawSource: z.string().min(1), + recordedBy: z.string().min(1).optional(), + reason: z.string().min(1).optional(), + clearedAt: z.number().int().optional(), + clearedBy: z.string().min(1).optional(), + supersededBy: z.string().min(1).optional(), + schemaVersion: z.number().int().positive(), +}) + .strict() + .refine((doc) => doc.scopeType !== 'province' || + doc.affectedMunicipalityIds.length === CAMARINES_NORTE_MUNICIPALITIES.length, { message: 'province scope must normalize to the full municipality set' }); +export const hazardSignalStatusDocSchema = z + .object({ + active: z.boolean(), + effectiveSignalId: z.string().min(1).optional(), + effectiveLevel: z.number().int().min(1).max(5).optional(), + effectiveSource: signalSourceSchema.optional(), + scopeType: z.enum(['province', 'municipalities']).optional(), + affectedMunicipalityIds: z.array(z.string().min(1)), + effectiveScopes: z.array(z + .object({ + municipalityId: z.string().min(1), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + signalId: z.string().min(1), + }) + .strict()), + validUntil: z.number().int().optional(), + manualOverrideActive: z.boolean(), + scraperDegraded: z.boolean(), + lastProjectedAt: z.number().int(), + degradedReasons: z.array(z.string().min(1)), + invalidSignalIds: z.array(z.string().min(1)).optional(), schemaVersion: z.number().int().positive(), }) .strict(); diff --git a/packages/shared-validators/lib/hazard.js.map b/packages/shared-validators/lib/hazard.js.map index 9899c96b..f020395a 100644 --- a/packages/shared-validators/lib/hazard.js.map +++ b/packages/shared-validators/lib/hazard.js.map @@ -1 +1 @@ -{"version":3,"file":"hazard.js","sourceRoot":"","sources":["../src/hazard.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"hazard.js","sourceRoot":"","sources":["../src/hazard.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/shared-validators/lib/hazard.test.js b/packages/shared-validators/lib/hazard.test.js index a5143cf9..f2ef744d 100644 --- a/packages/shared-validators/lib/hazard.test.js +++ b/packages/shared-validators/lib/hazard.test.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { CAMARINES_NORTE_MUNICIPALITIES } from './municipalities'; import { hazardZoneDocSchema, hazardSignalDocSchema, hazardZoneHistoryDocSchema } from './hazard'; describe('Hazard Schemas', () => { describe('hazardZoneDocSchema', () => { @@ -128,42 +129,66 @@ describe('Hazard Schemas', () => { describe('hazardSignalDocSchema', () => { it('accepts valid hazard signal document', () => { const validDoc = { - source: 'pagasa_webhook', + hazardType: 'tropical_cyclone', signalLevel: 5, - affectedMunicipalityIds: ['daet', 'vinzons'], - createdAt: 1713350400000, - createdBy: 'admin-1', - expiresAt: 1713436800000, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((municipality) => municipality.id), + status: 'active', + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', + recordedBy: 'admin-1', + reason: 'PAGASA radio confirmation', schemaVersion: 1, }; expect(() => hazardSignalDocSchema.parse(validDoc)).not.toThrow(); }); it('rejects invalid source literal', () => { const invalidDoc = { - source: 'invalid-source', + hazardType: 'tropical_cyclone', signalLevel: 3, + source: 'invalid-source', + scopeType: 'municipalities', affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active', + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, }; expect(() => hazardSignalDocSchema.parse(invalidDoc)).toThrow(); }); - it('rejects signalLevel outside 0-5 range', () => { + it('rejects signalLevel outside 1-5 range', () => { const invalidDoc = { - source: 'pagasa_webhook', - signalLevel: 6, // must be 0-5 + hazardType: 'tropical_cyclone', + signalLevel: 6, // must be 1-5 + source: 'manual', + scopeType: 'municipalities', affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active', + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, }; expect(() => hazardSignalDocSchema.parse(invalidDoc)).toThrow(); }); it('rejects unknown keys via strict mode', () => { const docWithExtraKey = { - source: 'pagasa_webhook', + hazardType: 'tropical_cyclone', signalLevel: 4, + source: 'manual', + scopeType: 'municipalities', affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active', + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, unknownField: 'should not be allowed', }; diff --git a/packages/shared-validators/lib/hazard.test.js.map b/packages/shared-validators/lib/hazard.test.js.map index 2ccde103..e792437a 100644 --- a/packages/shared-validators/lib/hazard.test.js.map +++ b/packages/shared-validators/lib/hazard.test.js.map @@ -1 +1 @@ -{"version":3,"file":"hazard.test.js","sourceRoot":"","sources":["../src/hazard.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"hazard.test.js","sourceRoot":"","sources":["../src/hazard.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/shared-validators/lib/index.d.ts b/packages/shared-validators/lib/index.d.ts index a7b2e074..805ed8e4 100644 --- a/packages/shared-validators/lib/index.d.ts +++ b/packages/shared-validators/lib/index.d.ts @@ -22,8 +22,8 @@ export { detectEncoding } from './sms-encoding.js'; export type { SmsEncoding, EncodingResult } from './sms-encoding.js'; export { agencyAssistanceRequestDocSchema, commandChannelThreadDocSchema, commandChannelMessageDocSchema, massAlertRequestDocSchema, shiftHandoffDocSchema, responderShiftHandoffDocSchema, breakglassEventDocSchema, fieldModeSessionDocSchema, } from './coordination.js'; export type { AgencyAssistanceRequestDoc, CommandChannelThreadDoc, CommandChannelMessageDoc, MassAlertRequestDoc, ShiftHandoffDoc, ResponderShiftHandoffDoc, BreakglassEventDoc, FieldModeSessionDoc, } from './coordination.js'; -export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema } from './hazard.js'; -export type { HazardZoneDoc, HazardZoneHistoryDoc, HazardSignalDoc } from './hazard.js'; +export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema, hazardSignalStatusDocSchema, } from './hazard.js'; +export type { HazardZoneDoc, HazardZoneHistoryDoc, HazardSignalDoc, HazardSignalStatusDoc, } from './hazard.js'; export { incidentResponseEventSchema, dataIncidentDocSchema } from './incident-response.js'; export type { IncidentResponseEvent, DataIncidentDoc } from './incident-response.js'; export { moderationIncidentDocSchema } from './moderation.js'; diff --git a/packages/shared-validators/lib/index.d.ts.map b/packages/shared-validators/lib/index.d.ts.map index a7cf5f21..34617dbb 100644 --- a/packages/shared-validators/lib/index.d.ts.map +++ b/packages/shared-validators/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/shared-validators/lib/index.js b/packages/shared-validators/lib/index.js index c417bf9a..b66633bf 100644 --- a/packages/shared-validators/lib/index.js +++ b/packages/shared-validators/lib/index.js @@ -13,7 +13,7 @@ export { reportSmsConsentDocSchema } from './users.js'; export { smsInboxDocSchema, smsOutboxDocSchema, smsSessionDocSchema, smsProviderHealthDocSchema, smsProviderIdSchema, smsReportInboxFieldsSchema, } from './sms.js'; export { detectEncoding } from './sms-encoding.js'; export { agencyAssistanceRequestDocSchema, commandChannelThreadDocSchema, commandChannelMessageDocSchema, massAlertRequestDocSchema, shiftHandoffDocSchema, responderShiftHandoffDocSchema, breakglassEventDocSchema, fieldModeSessionDocSchema, } from './coordination.js'; -export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema } from './hazard.js'; +export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema, hazardSignalStatusDocSchema, } from './hazard.js'; export { incidentResponseEventSchema, dataIncidentDocSchema } from './incident-response.js'; export { moderationIncidentDocSchema } from './moderation.js'; export { rateLimitDocSchema } from './rate-limits.js'; diff --git a/packages/shared-validators/lib/index.js.map b/packages/shared-validators/lib/index.js.map index 777a7b26..0cd73e29 100644 --- a/packages/shared-validators/lib/index.js.map +++ b/packages/shared-validators/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AACvD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAC7F,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,GAChB,MAAM,cAAc,CAAA;AAcrB,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,4BAA4B,GAC7B,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE/C,OAAO,EAAE,kBAAkB,EAAE,+BAA+B,EAAE,MAAM,iBAAiB,CAAA;AAErF,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC1C,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAA;AAEtD,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,UAAU,CAAA;AAQjB,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAElD,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,8BAA8B,EAC9B,yBAAyB,EACzB,qBAAqB,EACrB,8BAA8B,EAC9B,wBAAwB,EACxB,yBAAyB,GAC1B,MAAM,mBAAmB,CAAA;AAW1B,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AAEpG,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAE3F,OAAO,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAA;AAE7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAErD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AAE/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAEvD,OAAO,EAAE,cAAc,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAE9F,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAE5E,OAAO,EAAE,qBAAqB,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAA;AAE3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAA;AAC9E,OAAO,EACL,aAAa,EACb,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,qCAAqC,CAAA;AAG5C,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,sBAAsB,EACtB,wBAAwB,EACxB,aAAa,EACb,aAAa,EACb,sBAAsB,GACvB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AACvD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAC7F,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,qBAAqB,EACrB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,GAChB,MAAM,cAAc,CAAA;AAcrB,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,4BAA4B,GAC7B,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE/C,OAAO,EAAE,kBAAkB,EAAE,+BAA+B,EAAE,MAAM,iBAAiB,CAAA;AAErF,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC1C,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAA;AAEtD,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,UAAU,CAAA;AAQjB,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAElD,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,8BAA8B,EAC9B,yBAAyB,EACzB,qBAAqB,EACrB,8BAA8B,EAC9B,wBAAwB,EACxB,yBAAyB,GAC1B,MAAM,mBAAmB,CAAA;AAW1B,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,qBAAqB,EACrB,2BAA2B,GAC5B,MAAM,aAAa,CAAA;AAOpB,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAE3F,OAAO,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAA;AAE7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAErD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AAE/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAEvD,OAAO,EAAE,cAAc,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAE9F,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAE5E,OAAO,EAAE,qBAAqB,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAA;AAE3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAA;AAC9E,OAAO,EACL,aAAa,EACb,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,qCAAqC,CAAA;AAG5C,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,sBAAsB,EACtB,wBAAwB,EACxB,aAAa,EACb,aAAa,EACb,sBAAsB,GACvB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA"} \ No newline at end of file diff --git a/packages/shared-validators/lib/shared-schemas.test.js b/packages/shared-validators/lib/shared-schemas.test.js index 3f36a56b..2ec90b63 100644 --- a/packages/shared-validators/lib/shared-schemas.test.js +++ b/packages/shared-validators/lib/shared-schemas.test.js @@ -8,6 +8,7 @@ import { rateLimitDocSchema } from './rate-limits.js'; import { idempotencyKeyDocSchema } from './idempotency-keys.js'; import { deadLetterDocSchema } from './dead-letters.js'; import { alertDocSchema } from './alerts-emergencies.js'; +import { CAMARINES_NORTE_MUNICIPALITIES, hazardSignalDocSchema, hazardSignalStatusDocSchema, } from './index.js'; const ts = 1713350400000; describe('sms schemas', () => { it('rejects sms outbox without providerId', () => { @@ -67,6 +68,59 @@ describe('hazard schemas', () => { schemaVersion: 1, })).toThrow(); }); + it('accepts a manual tcws signal lifecycle document', () => { + expect(hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 4, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + status: 'active', + validFrom: ts, + validUntil: ts + 60 * 60 * 1000, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'PAGASA radio confirmation', + schemaVersion: 1, + })).toMatchObject({ status: 'active', signalLevel: 4 }); + }); + it('rejects province scope when affectedMunicipalityIds is empty', () => { + expect(() => hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 3, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: [], + status: 'active', + validFrom: ts, + validUntil: ts + 1, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'test', + schemaVersion: 1, + })).toThrow(); + }); + it('accepts a projected hazard signal status document', () => { + expect(hazardSignalStatusDocSchema.parse({ + active: true, + effectiveSignalId: 'sig-1', + effectiveLevel: 4, + effectiveSource: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + effectiveScopes: [ + { municipalityId: 'daet', signalLevel: 4, source: 'manual', signalId: 'sig-1' }, + ], + validUntil: ts + 60 * 60 * 1000, + manualOverrideActive: true, + scraperDegraded: false, + lastProjectedAt: ts, + degradedReasons: [], + schemaVersion: 1, + })).toMatchObject({ active: true }); + }); }); describe('rate limit schema', () => { it('accepts a window counter', () => { diff --git a/packages/shared-validators/lib/shared-schemas.test.js.map b/packages/shared-validators/lib/shared-schemas.test.js.map index 91076eb9..c4a9cc13 100644 --- a/packages/shared-validators/lib/shared-schemas.test.js.map +++ b/packages/shared-validators/lib/shared-schemas.test.js.map @@ -1 +1 @@ -{"version":3,"file":"shared-schemas.test.js","sourceRoot":"","sources":["../src/shared-schemas.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"shared-schemas.test.js","sourceRoot":"","sources":["../src/shared-schemas.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/shared-validators/src/hazard.test.ts b/packages/shared-validators/src/hazard.test.ts index b84f0991..95a62688 100644 --- a/packages/shared-validators/src/hazard.test.ts +++ b/packages/shared-validators/src/hazard.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { CAMARINES_NORTE_MUNICIPALITIES } from './municipalities' import { hazardZoneDocSchema, hazardSignalDocSchema, hazardZoneHistoryDocSchema } from './hazard' describe('Hazard Schemas', () => { @@ -134,34 +135,73 @@ describe('Hazard Schemas', () => { describe('hazardSignalDocSchema', () => { it('accepts valid hazard signal document', () => { const validDoc = { - source: 'pagasa_webhook' as const, + hazardType: 'tropical_cyclone' as const, signalLevel: 5, - affectedMunicipalityIds: ['daet', 'vinzons'], - createdAt: 1713350400000, - createdBy: 'admin-1', - expiresAt: 1713436800000, + source: 'manual' as const, + scopeType: 'province' as const, + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map( + (municipality) => municipality.id, + ), + status: 'active' as const, + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', + recordedBy: 'admin-1', + reason: 'PAGASA radio confirmation', schemaVersion: 1, } expect(() => hazardSignalDocSchema.parse(validDoc)).not.toThrow() }) + it('rejects province scope when municipality set is incomplete', () => { + const invalidDoc = { + hazardType: 'tropical_cyclone' as const, + signalLevel: 3, + source: 'manual' as const, + scopeType: 'province' as const, + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.slice(0, -1).map( + (municipality) => municipality.id, + ), + status: 'active' as const, + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', + schemaVersion: 1, + } + expect(() => hazardSignalDocSchema.parse(invalidDoc)).toThrow() + }) + it('rejects invalid source literal', () => { const invalidDoc = { - source: 'invalid-source', + hazardType: 'tropical_cyclone' as const, signalLevel: 3, + source: 'invalid-source', + scopeType: 'municipalities' as const, affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active' as const, + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, } expect(() => hazardSignalDocSchema.parse(invalidDoc)).toThrow() }) - it('rejects signalLevel outside 0-5 range', () => { + it('rejects signalLevel outside 1-5 range', () => { const invalidDoc = { - source: 'pagasa_webhook' as const, - signalLevel: 6, // must be 0-5 + hazardType: 'tropical_cyclone' as const, + signalLevel: 6, // must be 1-5 + source: 'manual' as const, + scopeType: 'municipalities' as const, affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active' as const, + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, } expect(() => hazardSignalDocSchema.parse(invalidDoc)).toThrow() @@ -169,10 +209,16 @@ describe('Hazard Schemas', () => { it('rejects unknown keys via strict mode', () => { const docWithExtraKey = { - source: 'pagasa_webhook' as const, + hazardType: 'tropical_cyclone' as const, signalLevel: 4, + source: 'manual' as const, + scopeType: 'municipalities' as const, affectedMunicipalityIds: ['daet'], - createdAt: 1713350400000, + status: 'active' as const, + validFrom: 1713350400000, + validUntil: 1713436800000, + recordedAt: 1713350400000, + rawSource: 'manual', schemaVersion: 1, unknownField: 'should not be allowed', } diff --git a/packages/shared-validators/src/hazard.ts b/packages/shared-validators/src/hazard.ts index 1443845a..41787678 100644 --- a/packages/shared-validators/src/hazard.ts +++ b/packages/shared-validators/src/hazard.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { CAMARINES_NORTE_MUNICIPALITIES } from './municipalities.js' const bbox = z .object({ @@ -10,6 +11,8 @@ const bbox = z .strict() const hazardTypeSchema = z.enum(['flood', 'landslide', 'storm_surge']) +const signalSourceSchema = z.enum(['manual', 'scraper']) +const signalStatusSchema = z.enum(['active', 'cleared', 'expired', 'superseded', 'quarantined']) export const hazardZoneDocSchema = z .object({ @@ -48,12 +51,88 @@ export const hazardZoneHistoryDocSchema = hazardZoneDocSchema.extend({ export const hazardSignalDocSchema = z .object({ - source: z.enum(['pagasa_webhook', 'pagasa_scraper', 'manual_superadmin']), - signalLevel: z.number().int().min(0).max(5), - affectedMunicipalityIds: z.array(z.string()), - createdAt: z.number().int(), - expiresAt: z.number().int().optional(), - createdBy: z.string().optional(), + hazardType: z.literal('tropical_cyclone'), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + scopeType: z.enum(['province', 'municipalities']), + affectedMunicipalityIds: z.array(z.string().min(1)).min(1), + status: signalStatusSchema, + validFrom: z.number().int(), + validUntil: z.number().int(), + recordedAt: z.number().int(), + rawSource: z.string().min(1), + recordedBy: z.string().min(1).optional(), + reason: z.string().min(1).optional(), + clearedAt: z.number().int().optional(), + clearedBy: z.string().min(1).optional(), + supersededBy: z.string().min(1).optional(), + schemaVersion: z.number().int().positive(), + }) + .strict() + .refine( + (doc) => { + if (doc.scopeType !== 'province') return true + const expected = new Set(CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id)) + const actual = new Set(doc.affectedMunicipalityIds) + return actual.size === expected.size && [...expected].every((id) => actual.has(id)) + }, + { message: 'province scope must normalize to the full municipality set' }, + ) + .superRefine((doc, ctx) => { + const hasClearedMeta = doc.clearedAt !== undefined || doc.clearedBy !== undefined + if (doc.status === 'cleared') { + if (doc.clearedAt === undefined || doc.clearedBy === undefined) { + ctx.addIssue({ + code: 'custom' as const, + message: 'cleared status requires clearedAt and clearedBy', + }) + } + } else if (hasClearedMeta) { + ctx.addIssue({ + code: 'custom' as const, + message: 'clearedAt/clearedBy are only valid for cleared signals', + }) + } + + if (doc.status === 'superseded') { + if (doc.supersededBy === undefined) { + ctx.addIssue({ + code: 'custom' as const, + message: 'superseded status requires supersededBy', + }) + } + } else if (doc.supersededBy !== undefined) { + ctx.addIssue({ + code: 'custom' as const, + message: 'supersededBy is only valid for superseded signals', + }) + } + }) + +export const hazardSignalStatusDocSchema = z + .object({ + active: z.boolean(), + effectiveSignalId: z.string().min(1).optional(), + effectiveLevel: z.number().int().min(1).max(5).optional(), + effectiveSource: signalSourceSchema.optional(), + scopeType: z.enum(['province', 'municipalities']).optional(), + affectedMunicipalityIds: z.array(z.string().min(1)), + effectiveScopes: z.array( + z + .object({ + municipalityId: z.string().min(1), + signalLevel: z.number().int().min(1).max(5), + source: signalSourceSchema, + signalId: z.string().min(1), + }) + .strict(), + ), + validUntil: z.number().int().optional(), + manualOverrideActive: z.boolean(), + scraperDegraded: z.boolean(), + lastProjectedAt: z.number().int(), + degradedReasons: z.array(z.string().min(1)), + invalidSignalIds: z.array(z.string().min(1)).optional(), schemaVersion: z.number().int().positive(), }) .strict() @@ -61,3 +140,4 @@ export const hazardSignalDocSchema = z export type HazardZoneDoc = z.infer export type HazardZoneHistoryDoc = z.infer export type HazardSignalDoc = z.infer +export type HazardSignalStatusDoc = z.infer diff --git a/packages/shared-validators/src/index.ts b/packages/shared-validators/src/index.ts index 1b038025..921d6548 100644 --- a/packages/shared-validators/src/index.ts +++ b/packages/shared-validators/src/index.ts @@ -86,8 +86,18 @@ export type { BreakglassEventDoc, FieldModeSessionDoc, } from './coordination.js' -export { hazardZoneDocSchema, hazardZoneHistoryDocSchema, hazardSignalDocSchema } from './hazard.js' -export type { HazardZoneDoc, HazardZoneHistoryDoc, HazardSignalDoc } from './hazard.js' +export { + hazardZoneDocSchema, + hazardZoneHistoryDocSchema, + hazardSignalDocSchema, + hazardSignalStatusDocSchema, +} from './hazard.js' +export type { + HazardZoneDoc, + HazardZoneHistoryDoc, + HazardSignalDoc, + HazardSignalStatusDoc, +} from './hazard.js' export { incidentResponseEventSchema, dataIncidentDocSchema } from './incident-response.js' export type { IncidentResponseEvent, DataIncidentDoc } from './incident-response.js' export { moderationIncidentDocSchema } from './moderation.js' diff --git a/packages/shared-validators/src/shared-schemas.test.ts b/packages/shared-validators/src/shared-schemas.test.ts index 9c17f14b..2b868fbf 100644 --- a/packages/shared-validators/src/shared-schemas.test.ts +++ b/packages/shared-validators/src/shared-schemas.test.ts @@ -8,6 +8,11 @@ import { rateLimitDocSchema } from './rate-limits.js' import { idempotencyKeyDocSchema } from './idempotency-keys.js' import { deadLetterDocSchema } from './dead-letters.js' import { alertDocSchema } from './alerts-emergencies.js' +import { + CAMARINES_NORTE_MUNICIPALITIES, + hazardSignalDocSchema, + hazardSignalStatusDocSchema, +} from './index.js' const ts = 1713350400000 @@ -83,6 +88,74 @@ describe('hazard schemas', () => { }), ).toThrow() }) + + it('accepts a manual tcws signal lifecycle document', () => { + expect( + hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 4, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + status: 'active', + validFrom: ts, + validUntil: ts + 60 * 60 * 1000, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'PAGASA radio confirmation', + schemaVersion: 1, + }), + ).toMatchObject({ status: 'active', signalLevel: 4 }) + }) + + it('rejects province scope when affectedMunicipalityIds is not the canonical municipality set', () => { + // Replace the last real municipality ID with a fake one — still length-correct, + // so this specifically exercises the Set-equality refinement, not just min(1). + const wrongIds = [ + ...CAMARINES_NORTE_MUNICIPALITIES.slice(0, -1).map((m) => m.id), + 'not-a-real-municipality', + ] + expect(() => + hazardSignalDocSchema.parse({ + hazardType: 'tropical_cyclone', + signalLevel: 3, + source: 'manual', + scopeType: 'province', + affectedMunicipalityIds: wrongIds, + status: 'active', + validFrom: ts, + validUntil: ts + 1, + recordedAt: ts, + rawSource: 'manual', + recordedBy: 'super-1', + reason: 'test', + schemaVersion: 1, + }), + ).toThrow() + }) + + it('accepts a projected hazard signal status document', () => { + expect( + hazardSignalStatusDocSchema.parse({ + active: true, + effectiveSignalId: 'sig-1', + effectiveLevel: 4, + effectiveSource: 'manual', + scopeType: 'province', + affectedMunicipalityIds: CAMARINES_NORTE_MUNICIPALITIES.map((m) => m.id), + effectiveScopes: [ + { municipalityId: 'daet', signalLevel: 4, source: 'manual', signalId: 'sig-1' }, + ], + validUntil: ts + 60 * 60 * 1000, + manualOverrideActive: true, + scraperDegraded: false, + lastProjectedAt: ts, + degradedReasons: [], + schemaVersion: 1, + }), + ).toMatchObject({ active: true }) + }) }) describe('rate limit schema', () => {