Send typed broadcast updates for survey, job, and LOI writes - #2588
rfontanarosa wants to merge 13 commits into
Conversation
Replace the generic empty-payload broadcastSurveyUpdate() with a single
broadcastUpdate() that tags each FCM message with a type ('survey', 'job',
or 'loi'), the affected entity id, and the triggering event's commit time.
LOI updates also carry a deleted flag. Clients that don't inspect the
payload keep working exactly as before (full survey resync); clients that
do can use the hint to fetch just what changed instead of resyncing
everything.
Messages still share one collapse key per survey so bursts of writes (e.g.
importing LOIs) collapse into a single wake-up.
|
I see we opted to stick with hard-delete via FCM (push) rather than soft delete + pull? What happens if many entites are deleted (e.g. an entire survey)? Wouldn't clients get flooded with messages? Same question on batch create and write - is there a way to debounce the create and write update messages? |
# Conflicts: # functions/src/on-create-loi.spec.ts
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request refactors the Location of Interest (LOI) event handling by splitting the generic onWriteLoi trigger into specific onUpdateLoi and onDeleteLoi triggers, introducing a more granular broadcast system, and correcting audit timestamps using Firestore event times. Feedback on these changes highlights several safety improvements: handling potential null or undefined values in propertiesPbToObject to prevent runtime crashes and avoid losing empty string properties, replacing risky non-null assertions on loiPb.geometry and jobDoc.data() with safe checks, and robustly parsing eventTime in withServerTimestamp to prevent NaN values from propagating to Firestore timestamps.
| export function propertiesPbToObject(pb: { | ||
| [k: string]: Pb.LocationOfInterest.IProperty; | ||
| }): Properties { | ||
| const properties: { [k: string]: string | number } = {}; | ||
| for (const k of Object.keys(pb).sort()) { | ||
| const v = pb[k].stringValue || pb[k].numericValue; | ||
| if (v !== null && v !== undefined) { | ||
| properties[k] = v; | ||
| } | ||
| } | ||
| return properties; | ||
| } |
There was a problem hiding this comment.
The propertiesPbToObject function has two issues:
- It does not handle a
nullorundefinedpbargument, which will cause a runtime crash (TypeError: Cannot convert undefined or null to object) whenloiPb.propertiesis missing or empty. - It uses the logical OR operator (
||) to fall back tonumericValue. IfstringValueis an empty string (""), it is falsy, so the function will incorrectly fall back tonumericValue(orundefined), causing empty string properties to be lost.
Using nullish coalescing (??) and adding a defensive check for pb resolves both issues.
| export function propertiesPbToObject(pb: { | |
| [k: string]: Pb.LocationOfInterest.IProperty; | |
| }): Properties { | |
| const properties: { [k: string]: string | number } = {}; | |
| for (const k of Object.keys(pb).sort()) { | |
| const v = pb[k].stringValue || pb[k].numericValue; | |
| if (v !== null && v !== undefined) { | |
| properties[k] = v; | |
| } | |
| } | |
| return properties; | |
| } | |
| export function propertiesPbToObject(pb?: { | |
| [k: string]: Pb.LocationOfInterest.IProperty; | |
| } | null): Properties { | |
| if (!pb) return {}; | |
| const properties: { [k: string]: string | number } = {}; | |
| for (const k of Object.keys(pb).sort()) { | |
| const v = pb[k].stringValue ?? pb[k].numericValue; | |
| if (v !== null && v !== undefined) { | |
| properties[k] = v; | |
| } | |
| } | |
| return properties; | |
| } |
| const geometry = toGeoJsonGeometry(loiPb.geometry!); | ||
|
|
||
| let properties = propertiesPbToObject(loiPb.properties) || {}; | ||
|
|
||
| const jobDoc = await db.fetchJob(surveyId, loiPb.jobId); | ||
| const jobPb = toMessage(jobDoc.data()!, Pb.Job) as Pb.Job; | ||
| const enabledIntegrationIds = new Set( | ||
| jobPb.enabledIntegrations.map(i => i.id) | ||
| ); |
There was a problem hiding this comment.
Using non-null assertions (!) on loiPb.geometry and jobDoc.data() is risky. If the LOI lacks geometry or if the referenced job does not exist in the database, this will cause a runtime crash. It is safer to check for their existence and handle the missing cases gracefully.
if (!loiPb.geometry) {
logger.error(`loiId=${loiId} has no geometry`);
return {};
}
const geometry = toGeoJsonGeometry(loiPb.geometry);
let properties = propertiesPbToObject(loiPb.properties);
const jobDoc = await db.fetchJob(surveyId, loiPb.jobId);
if (!jobDoc.exists) {
logger.error(`loiId=${loiId} referenced job ${loiPb.jobId} not found`);
return properties;
}
const jobPb = toMessage(jobDoc.data()!, Pb.Job) as Pb.Job;
const enabledIntegrationIds = new Set(
jobPb.enabledIntegrations.map(i => i.id)
);| export function withServerTimestamp( | ||
| auditInfo: Pb.IAuditInfo, | ||
| eventTime: string | ||
| ): Pb.AuditInfo { | ||
| return new Pb.AuditInfo({ | ||
| userId: auditInfo.userId, | ||
| displayName: auditInfo.displayName, | ||
| photoUrl: auditInfo.photoUrl, | ||
| emailAddress: auditInfo.emailAddress, | ||
| clientTimestamp: auditInfo.clientTimestamp, | ||
| serverTimestamp: toTimestampPb(Date.parse(eventTime)), | ||
| }); | ||
| } |
There was a problem hiding this comment.
If eventTime is not a valid ISO date string or is malformed, Date.parse(eventTime) will return NaN. This will propagate to toTimestampPb and result in an invalid Firestore timestamp. Adding a fallback to Date.now() ensures robustness.
export function withServerTimestamp(
auditInfo: Pb.IAuditInfo,
eventTime: string
): Pb.AuditInfo {
const parsedTime = Date.parse(eventTime);
const millis = isNaN(parsedTime) ? Date.now() : parsedTime;
return new Pb.AuditInfo({
userId: auditInfo.userId,
displayName: auditInfo.displayName,
photoUrl: auditInfo.photoUrl,
emailAddress: auditInfo.emailAddress,
clientTimestamp: auditInfo.clientTimestamp,
serverTimestamp: toTimestampPb(millis),
});
}
closes #2580
Summary
broadcastSurveyUpdate()with a typedbroadcastUpdate()sent from the survey, job, and LOI write triggers.type(survey|job|loi), the affected entity's id, and the triggering event's commit time (eventTime); LOI updates also carrydeleted.lois/{loiId}trigger:onWriteLoiis replaced byonUpdateLoiandonDeleteLoi, andonCreateLoinow announces too. Both were previously registered on the same path, so every created LOI was broadcast twice, the first time before its generated properties had been written.onCreateLoistays silent when it writes the fix-up and leaves the announcement toonUpdateLoi, and announces the LOI itself when there is nothing to fix up.common/loi-properties.ts, soexport-geojsonno longer imports it from a trigger module.Compatibility
Verified against
ground-android:FirebaseMessagingService.onMessageReceived()only readsremoteMessage.from(the topic) to decide which survey to resync, and never inspectsremoteMessage.data. The added fields are additive and ignored by the current app, so existing clients keep doing a full resync exactly as before. Newer clients can use the type/id hints to sync just what changed.