Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
131 changes: 127 additions & 4 deletions apps/citizen-pwa/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
const CACHE_NAME = 'bantayog-shell-v1'
const CACHE_NAME = 'bantayog_shell_v1'
// WARNING: This must match the localforage instance name in
// apps/citizen-pwa/src/services/draft-store.ts ('bantayog-drafts').
// The SW uses raw IndexedDB; localforage wraps IndexedDB with its own
// internal schema. Sharing data between them requires care — the SW
// reads the same DB name but accesses a separate object store created
// when the app explicitly writes sync-state metadata for the SW.
const DB_NAME = 'bantayog-drafts'
const DB_STORE = 'drafts'

self.addEventListener('install', (event) => {
self.skipWaiting()
Expand All @@ -11,7 +19,11 @@ self.addEventListener('activate', (event) => {
.then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name.startsWith('bantayog-shell-') && name !== CACHE_NAME)
.filter(
(name) =>
(name.startsWith('bantayog_shell_') || name.startsWith('bantayog-shell-')) &&
name !== CACHE_NAME,
)
.map((name) => caches.delete(name)),
),
)
Expand All @@ -29,11 +41,122 @@ self.addEventListener('fetch', (event) => {
}
return response
})
.catch((err) => {
.catch(() => {
if (event.request.method === 'GET') {
return caches.match(event.request)
}
throw err
throw new Error('not found')
}),
)
})

// Background Sync — complementary to in-app retry machine.
// Chrome/Edge only; iOS Safari falls back to in-app machine.
// Idempotency key (draft.id) on the write ensures dedup if both
// the SW and the in-app machine both succeed.
self.addEventListener('sync', (event) => {
if (event.tag !== 'submit-report') return
event.waitUntil(submitQueuedDrafts())
})

async function openDraftsDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1)
req.onupgradeneeded = () => {
const store = req.result.createObjectStore(DB_STORE, { keyPath: 'id' })
store.createIndex('syncState', 'syncState', { unique: false })
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}

async function submitQueuedDrafts() {
const db = await openDraftsDB()
const tx = db.transaction(DB_STORE, 'readonly')
const store = tx.objectStore(DB_STORE)
const index = store.index('syncState')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const syncingReq = index.getAll('syncing')
const localOnlyReq = index.getAll('local_only')

const syncingDrafts = await readRequest(syncingReq)
const localOnlyDrafts = await readRequest(localOnlyReq)
const queuedDrafts = [...syncingDrafts, ...localOnlyDrafts]

await Promise.allSettled(
queuedDrafts.map((draft) =>
submitDraft(draft).then(() => updateDraftSyncState(db, draft.id, 'synced')),
),
)
}

function readRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result ?? [])
req.onerror = () => reject(req.error)
})
}

async function submitDraft(draft) {
const inboxDoc = {
reporterUid: draft.reporterUid ?? '',
clientCreatedAt: draft.clientCreatedAt,
idempotencyKey: draft.idempotencyKey,
publicRef: draft.publicRef,
secretHash: draft.secretHash,
correlationId: draft.correlationId,
payload: {
reportType: draft.reportType,
description: draft.description,
severity: draft.severity,
source: 'web',
clientDraftRef: draft.clientDraftRef,
...(draft.publicLocation ? { publicLocation: draft.publicLocation } : {}),
...(draft.municipalityId ? { municipalityId: draft.municipalityId } : {}),
...(draft.barangayId ? { barangayId: draft.barangayId } : {}),
...(draft.nearestLandmark ? { nearestLandmark: draft.nearestLandmark } : {}),
...(draft.reporterName ? { reporterName: draft.reporterName } : {}),
...(draft.reporterMsisdnHash ? { reporterMsisdnHash: draft.reporterMsisdnHash } : {}),
},
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Firebase Firestore REST API — no Firebase JS SDK bundle needed.
const projectId = self.location.hostname.includes('localhost')
? 'demo-project'
: self.location.hostname.includes('staging')
? 'bantayog-staging'
: 'bantayog-alert'

const response = await fetch(
`https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents/report_inbox/${draft.id}?documentId=${draft.id}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(inboxDoc),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

if (!response.ok) {
const text = await response.text()
throw new Error(`Firestore REST ${response.status}: ${text}`)
}
}

async function updateDraftSyncState(db, draftId, syncState) {
const tx = db.transaction(DB_STORE, 'readwrite')
const store = tx.objectStore(DB_STORE)
const getReq = store.get(draftId)
const draft = await new Promise((resolve, reject) => {
getReq.onsuccess = () => resolve(getReq.result)
getReq.onerror = () => reject(getReq.error)
})
if (!draft) return
draft.syncState = syncState
draft.updatedAt = Date.now()
await new Promise((resolve, reject) => {
const putReq = store.put(draft)
putReq.onsuccess = () => resolve(undefined)
putReq.onerror = () => reject(putReq.error)
})
}
22 changes: 21 additions & 1 deletion apps/citizen-pwa/src/components/LookupScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ interface LookupResult {
municipalityLabel: string
}

const FRIENDLY_ERROR = "We couldn't find that report. Check your codes and try again."

// Map server-side callable errors to friendly strings. Never surface raw error
// messages — they may leak internal state and confuse non-technical users.
function friendlyLookupError(err: unknown): string {
if (!hasFirebaseConfig()) return FIREBASE_ENV_ERROR_MESSAGE
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''
// Treat permission-denied like not-found to avoid leaking existence.
if (code === 'functions/not-found' || code === 'not-found') return FRIENDLY_ERROR
if (code === 'functions/permission-denied' || code === 'permission-denied') return FRIENDLY_ERROR
if (code === 'functions/unauthenticated' || code === 'unauthenticated') {
return 'Please refresh and try again.'
}
if (code === 'functions/resource-exhausted' || code === 'resource-exhausted') {
return 'Too many attempts. Please wait a minute and try again.'
}
return 'Something went wrong. Please try again or call the hotline.'
}

export function LookupScreen() {
const [publicRef, setPublicRef] = useState('')
const [secret, setSecret] = useState('')
Expand Down Expand Up @@ -40,7 +59,8 @@ export function LookupScreen() {
})
setResult(res.data as LookupResult)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'lookup failed')
console.error('[LookupScreen] requestLookup failed:', e)
setError(friendlyLookupError(e))
} finally {
setLoading(false)
}
Expand Down
5 changes: 5 additions & 0 deletions apps/citizen-pwa/src/components/RevealSheet.lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { lazy } from 'react'

export const RevealSheet = lazy(() =>
import('./RevealSheet.js').then((m) => ({ default: m.RevealSheet })),
)
Loading
Loading