Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2e53ac9
docs(phase-4a): add outbound SMS design spec
claude Apr 19, 2026
2dda361
docs(phase-4a): fix spec gaps flagged in review
claude Apr 19, 2026
a8ae289
feat(phase-4a): add msisdn normalization and hashing
claude Apr 19, 2026
f314f82
feat(phase-4a): add GSM-7 vs UCS-2 encoding detection
claude Apr 19, 2026
cbc945c
feat(phase-4a): add SMS template renderer with tl/en locales
claude Apr 19, 2026
9edc3f8
fix(sms-templates): enforce 8-char publicRef and correct error ordering
claude Apr 19, 2026
0660295
feat(phase-4a): bump sms schemas to v2 and add minute-window schema
claude Apr 19, 2026
5d9da79
feat(phase-4a): add optional contact with smsConsent literal-true to …
claude Apr 19, 2026
b0cbcd9
feat(phase-4a): add SmsProvider interface, fake provider, real-provid…
claude Apr 19, 2026
fd0707c
feat(phase-4a): add SMS health service with pickProvider and minute-w…
claude Apr 19, 2026
0e5a896
feat(phase-4a): add enqueueSms service with deterministic idempotency
claude Apr 19, 2026
09844e6
feat(phase-4a): add dispatchSmsOutbox trigger with CAS-guarded provid…
claude Apr 19, 2026
2599412
feat(phase-4a): add evaluateSmsProviderHealth scheduled trigger with …
claude Apr 19, 2026
95aa690
feat(phase-4a): add reconcileSmsDeliveryStatus scheduled trigger for …
claude Apr 19, 2026
7e33b56
feat(phase-4a): add cleanupSmsMinuteWindows hourly TTL sweep
claude Apr 19, 2026
f40bc6c
feat(phase-4a): add smsDeliveryReport HTTP webhook with constant-time…
claude Apr 19, 2026
21c2a98
feat(phase-4a): enqueue receipt_ack SMS when citizen grants consent
claude Apr 19, 2026
1c24c31
feat(phase-4a): wire enqueueSms into verifyReport and processInboxItem
claude Apr 19, 2026
b4d50a8
feat(phase-4a): wire enqueueSms into dispatchResponder and closeReport
claude Apr 19, 2026
19e01c5
feat(citizen-pwa): add phone + SMS consent fields to report submission
claude Apr 19, 2026
1cff813
feat(phase-4a): lock sms_outbox/sms_provider_health/minute_windows/re…
claude Apr 19, 2026
cfc5548
feat(phase-4a): add SMS secrets and log metrics to Terraform
claude Apr 19, 2026
7c547f0
feat(phase-4a): add bootstrap and acceptance gate scripts
claude Apr 21, 2026
aff8dd7
docs(phase-4a): update progress with Phase 4a implementation summary
claude Apr 21, 2026
c63dbe2
feat(phase-4a): phase 4a acceptance test fixes
claude Apr 21, 2026
7d96e39
fix(firebase): move sms-delivery-report webhook before catch-all rewrite
claude Apr 21, 2026
bf60f5b
chore(firestore.rules): regenerate from build-rules.ts
claude Apr 21, 2026
a0b3bb7
fix(phase-4a-acceptance.ts): fix ~17 TypeScript errors - wrong API ca…
claude Apr 21, 2026
967c43e
fix(phase-4a-acceptance.test.ts): fix TS errors - assert import, staf…
claude Apr 21, 2026
beda824
fix(secret-manager): rename secrets to SCREAMING_SNAKE_CASE to match …
claude Apr 21, 2026
66b8651
fix(sms-templates): correct typo pag-uurat → pag-uulat
claude Apr 21, 2026
a7a947e
fix(send-sms): make outbox write idempotent with merge:true
claude Apr 21, 2026
d1f1d4f
fix(cleanup-sms-minute-windows): fix loop condition - only first batc…
claude Apr 21, 2026
6136d06
fix(sms-providers): add disabled mode + NaN validation
claude Apr 21, 2026
22e5532
fix(reconcile-sms-delivery-status): prevent orphan sweep race conditi…
claude Apr 21, 2026
c745a38
fix(sms-health): track true maxLatencyMs using transaction
claude Apr 21, 2026
e86a5fc
fix(sms-health): track true maxLatencyMs using transaction
claude Apr 21, 2026
8cc78c7
fix(dispatch-sms-outbox): real provider values, clear PII on terminal…
claude Apr 21, 2026
dac5259
fix(tests): restore SMS_MSISDN_HASH_SALT after each test suite
claude Apr 21, 2026
982989f
fix(sms-provider-fake.test): clean up new FAKE_SMS_* vars after each …
claude Apr 21, 2026
13215a4
fix(cleanup-sms-minute-windows.integration.test): fix WriteBatch reus…
claude Apr 21, 2026
6f5baf9
feat(phase-4a): add --allow-prod guard to bootstrap script
claude Apr 21, 2026
935f104
scripts/phase-4a/acceptance.ts: batch WriteBatch for minute_windows d…
claude Apr 21, 2026
e591055
functions/src/http/sms-delivery-report.ts: remove unreachable null ch…
claude Apr 21, 2026
6683f88
packages/shared-validators/src/msisdn.ts: add defensive validation in…
claude Apr 21, 2026
e325664
packages/shared-validators/src/sms-encoding.ts: fix GSM-7 apostrophe …
claude Apr 21, 2026
3eeab0b
fix: add missing afterEach imports and SmsProviderRuntimeId 'disabled'
claude Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/citizen-pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@bantayog/shared-firebase": "workspace:*",
"@bantayog/shared-types": "workspace:*",
"@bantayog/shared-ui": "workspace:*",
"@bantayog/shared-validators": "workspace:*",
"firebase": "^12.12.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
Expand Down
37 changes: 37 additions & 0 deletions apps/citizen-pwa/src/components/SubmitReportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { addDoc, collection } from 'firebase/firestore'
import { httpsCallable } from 'firebase/functions'
import { db, fns, ensureSignedIn } from '../services/firebase.js'
import { submitReport, type SubmitReportDeps } from '../services/submit-report.js'
import { normalizeMsisdn } from '@bantayog/shared-validators'
import type { ReportType, Severity } from '@bantayog/shared-types'

function randomPublicRef(): string {
Expand Down Expand Up @@ -44,6 +45,9 @@ export function SubmitReportForm() {
const [photo, setPhoto] = useState<File | null>(null)
const [lat, setLat] = useState<number | null>(null)
const [lng, setLng] = useState<number | null>(null)
const [phone, setPhone] = useState('')
const [smsConsent, setSmsConsent] = useState(false)
const [phoneError, setPhoneError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)

Expand Down Expand Up @@ -72,6 +76,14 @@ export function SubmitReportForm() {
setError('Please capture your location.')
return
}
if (phone) {
try {
normalizeMsisdn(phone)
} catch {
setPhoneError('Enter a valid PH mobile number (e.g. 09171234567 or +639171234567)')
return
}
}
setBusy(true)
setError(null)
try {
Expand Down Expand Up @@ -101,6 +113,7 @@ export function SubmitReportForm() {
description,
publicLocation: { lat, lng },
...(photo ? { photo } : {}),
...(phone && smsConsent ? { contact: { phone, smsConsent: true as const } } : {}),
})
// eslint-disable-next-line @typescript-eslint/no-floating-promises
nav('/receipt', { state: result })
Expand Down Expand Up @@ -165,6 +178,30 @@ export function SubmitReportForm() {
}}
/>
</label>
<label>
Mobile number (optional — for SMS alerts)
<input
type="tel"
value={phone}
placeholder="09171234567 or +639171234567"
onChange={(e) => {
setPhone(e.target.value)
setPhoneError(null)
}}
/>
</label>
{phoneError && <p role="alert">{phoneError}</p>}
<label>
<input
type="checkbox"
checked={smsConsent}
onChange={(e) => {
setSmsConsent(e.target.checked)
}}
disabled={!phone}
/>
Send me SMS updates about this report
</label>
<button
type="button"
onClick={() => {
Expand Down
57 changes: 57 additions & 0 deletions apps/citizen-pwa/src/services/submit-report.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { submitReport, type SubmitReportDeps } from './submit-report.js'
import { normalizeMsisdn } from '@bantayog/shared-validators'

describe('submitReport', () => {
it('calls requestUploadUrl when a photo is provided, PUTs the photo, and writes inbox', async () => {
Expand Down Expand Up @@ -69,4 +70,60 @@ describe('submitReport', () => {
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(deps.putBlob).not.toHaveBeenCalled()
})

it('normalizes phone and sets smsConsent in contact when provided', async () => {
const deps: SubmitReportDeps = {
ensureSignedIn: vi.fn().mockResolvedValue('citizen-1'),
requestUploadUrl: vi.fn(),
putBlob: vi.fn(),
writeInbox: vi.fn().mockResolvedValue('ibx-3'),
randomUUID: vi.fn().mockReturnValue('uuid-c'),
randomPublicRef: vi.fn().mockReturnValue('ref1234'),
randomSecret: vi.fn().mockReturnValue('s3'),
sha256Hex: vi.fn().mockResolvedValue('i'.repeat(64)),
now: () => 1,
}
await submitReport(deps, {
reportType: 'flood',
severity: 'low',
description: 'z',
publicLocation: { lat: 14.1, lng: 122.9 },
contact: { phone: '09171234567', smsConsent: true },
})
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(deps.writeInbox).toHaveBeenCalledOnce()
const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0]![0]! as {
payload: { contact?: { phone: string; smsConsent: true } }
}
expect(inboxDoc.payload.contact).toEqual({
phone: normalizeMsisdn('09171234567'),
smsConsent: true,
})
Comment on lines +99 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Avoid computing expected value with the same normalizer used in implementation.

This can mask defects in normalization. Assert against the concrete normalized literal (e.g., +639171234567) so the test fails if normalization regresses.

As per coding guidelines, "Write tests that verify the new code is actually invoked, not tests that pass trivially."

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

In `@apps/citizen-pwa/src/services/submit-report.test.ts` around lines 99 - 102,
The test currently computes the expected phone value using the same normalizer
(normalizeMsisdn) which can mask regressions; update the assertion on
inboxDoc.payload.contact to use the concrete normalized literal (e.g.,
"+639171234567") instead of calling normalizeMsisdn('09171234567'), keeping
smsConsent: true, so the test verifies actual normalization occurs in the
implementation (check the assertion around inboxDoc.payload.contact and replace
the normalizeMsisdn reference accordingly).

})

it('omits contact when not provided', async () => {
const deps: SubmitReportDeps = {
ensureSignedIn: vi.fn().mockResolvedValue('citizen-1'),
requestUploadUrl: vi.fn(),
putBlob: vi.fn(),
writeInbox: vi.fn().mockResolvedValue('ibx-4'),
randomUUID: vi.fn().mockReturnValue('uuid-d'),
randomPublicRef: vi.fn().mockReturnValue('ref5678'),
randomSecret: vi.fn().mockReturnValue('s4'),
sha256Hex: vi.fn().mockResolvedValue('j'.repeat(64)),
now: () => 1,
}
await submitReport(deps, {
reportType: 'flood',
severity: 'low',
description: 'z',
publicLocation: { lat: 14.1, lng: 122.9 },
})
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(deps.writeInbox).toHaveBeenCalledOnce()
const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0]![0]! as { payload: Record<string, unknown> }
expect(inboxDoc.payload.contact).toBeUndefined()
Comment on lines +125 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert property absence, not undefined readback.

payload.contact being undefined passes both when omitted and when explicitly set to undefined. Use expect(inboxDoc.payload).not.toHaveProperty('contact') to verify omission semantics.

As per coding guidelines, "Write tests that verify the new code is actually invoked, not tests that pass trivially."

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

In `@apps/citizen-pwa/src/services/submit-report.test.ts` around lines 125 - 127,
The test currently asserts absence of contact by checking
inboxDoc.payload.contact === undefined which passes if the property is omitted
or explicitly set to undefined; update the assertion to verify omission
semantics by replacing that check with
expect(inboxDoc.payload).not.toHaveProperty('contact') using the existing
inboxDoc (extracted from deps.writeInbox.mock.calls[0][0]) so the test fails if
the contact property is present in the payload.

})
})
11 changes: 11 additions & 0 deletions apps/citizen-pwa/src/services/submit-report.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { normalizeMsisdn } from '@bantayog/shared-validators'

export interface SubmitReportInput {
reportType: string
severity: 'low' | 'medium' | 'high'
description: string
publicLocation: { lat: number; lng: number }
photo?: Blob
contact?: { phone: string; smsConsent: true }
}

export interface SubmitReportDeps {
Expand Down Expand Up @@ -65,6 +68,14 @@ export async function submitReport(
source: 'web',
publicLocation: input.publicLocation,
pendingMediaIds,
...(input.contact
? {
contact: {
phone: normalizeMsisdn(input.contact.phone),
smsConsent: true as const,
},
}
: {}),
},
})

Expand Down
Loading
Loading