Skip to content
48 changes: 48 additions & 0 deletions functions/src/common/audit-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright 2026 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { GroundProtos } from '@ground/proto';

import Pb = GroundProtos.ground.v1beta1;

/**
* Returns a copy of `auditInfo` with `serverTimestamp` set from `eventTime`
* (a Firestore trigger's commit time), leaving every other field unchanged.
* Clients can only guess the server time from their own clock when they
* write an AuditInfo, so this corrects it to the time the server actually
* committed the write.
*/
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)),
});
}

export function toTimestampPb(
millis: number
): GroundProtos.google.protobuf.Timestamp {
return new GroundProtos.google.protobuf.Timestamp({
seconds: Math.floor(millis / 1000),
});
}
36 changes: 0 additions & 36 deletions functions/src/common/broadcast-survey-update.ts

This file was deleted.

69 changes: 69 additions & 0 deletions functions/src/common/broadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Copyright 2026 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { getMessaging } from 'firebase-admin/messaging';

/**
* A change to announce to clients watching a survey. `type` tells them what
* changed, so they can fetch just that instead of the whole survey. Clients
* which don't recognize a message fall back to syncing everything, so new
* types can be added without breaking older ones.
*/
export type SurveyUpdate =
| { type: 'survey'; surveyId: string }
| { type: 'job'; surveyId: string; jobId: string }
| { type: 'loi'; surveyId: string; loiId: string; deleted: boolean };

/**
* Announces `update` to clients subscribed to its survey's topic, stamped with
* the time the triggering write was committed.
*
* Messages share one collapse key per survey so that a burst of writes - an
* import may trigger thousands - is delivered as a single wake-up. Clients must
* therefore treat the payload as a hint about one of the changes, never as the
* complete set: the ids in a collapsed message are whichever arrived last.
* See also: https://firebase.google.com/docs/cloud-messaging/concept-options#collapsible_and_non-collapsible_messages
*/
export async function broadcastUpdate(
update: SurveyUpdate,
eventTime: string
): Promise<string> {
const { surveyId } = update;

if (process.env.FUNCTIONS_EMULATOR === 'true') {
console.debug(`Skipping FCM message to ${surveyId} (emulator mode)`);
return '';
}

console.debug(`Sending ${update.type} update to ${surveyId}`);

return getMessaging().send({
topic: surveyId,
data: toFcmData(update, eventTime),
android: { collapseKey: surveyId, priority: 'normal' },
});
}

/** FCM data payloads carry strings only, so all values are stringified. */
function toFcmData(
update: SurveyUpdate,
eventTime: string
): { [k: string]: string } {
return Object.fromEntries([
...Object.entries(update).map(([k, v]) => [k, String(v)]),
['eventTime', eventTime],
]);
}
6 changes: 5 additions & 1 deletion functions/src/common/datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,11 @@ export class Datastore {
loiDoc: DocumentData
) {
const loiRef = this.db_.doc(loi(surveyId, loiId));
await loiRef.update({ [l.properties]: loiDoc[l.properties] });
const update: DocumentData = { [l.properties]: loiDoc[l.properties] };
if (l.created in loiDoc) update[l.created] = loiDoc[l.created];
if (l.lastModified in loiDoc)
update[l.lastModified] = loiDoc[l.lastModified];
await loiRef.update(update);
}

static toFirestoreMap(geometry: any) {
Expand Down
136 changes: 136 additions & 0 deletions functions/src/common/loi-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Copyright 2026 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as logger from 'firebase-functions/logger';
import { Datastore } from './datastore';
import { GroundProtos } from '@ground/proto';
import { toGeoJsonGeometry, toMessage } from '@ground/lib';
import {
Properties,
PropertyGeneratorConfig,
propertyGeneratorHandlers,
} from '../property-generators';

import Pb = GroundProtos.ground.v1beta1;

/**
* Returns the properties of `loiPb` with those of every property generator
* enabled on its job merged in. Generators are called over the network, so a
* failing one is logged and skipped rather than failing the whole set.
*/
export async function regenerateLoiProperties(
db: Datastore,
surveyId: string,
loiId: string,
loiPb: Pb.LocationOfInterest
): Promise<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)
);

const propertyGenerators = await db.fetchPropertyGenerators();

for (const propertyGeneratorDoc of propertyGenerators.docs) {
const generatorId = propertyGeneratorDoc.id;
const config = propertyGeneratorDoc.data() as PropertyGeneratorConfig;
const handler = propertyGeneratorHandlers[generatorId];

if (!handler) {
continue;
}

if (!enabledIntegrationIds.has(generatorId)) {
continue;
}

try {
const newProperties = await handler(config, geometry, loiId);
properties = updateProperties(properties, newProperties, config.prefix);
} catch (e) {
logger.error(
`loiId=${loiId} property generator '${generatorId}' failed:`,
e
);
}

Object.keys(properties)
.filter(key => typeof properties[key] === 'object')
.forEach(key => (properties[key] = JSON.stringify(properties[key])));
}

return properties;
}

/** Returns whether both property maps hold the same keys and values. */
export function propertiesEqual(a: Properties, b: Properties): boolean {
const keys = Object.keys(a);

return (
keys.length === Object.keys(b).length && keys.every(k => a[k] === b[k])
);
}

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;
}

function updateProperties(
properties: Properties,
newProperties: Properties,
prefix?: string
): Properties {
if (prefix) properties = removePrefixedKeys(properties, prefix);

return {
...properties,
...(prefix ? prefixKeys(newProperties, prefix) : newProperties),
};
}

/**
* Returns a new object with all keys of the original object prefixed with the given value.
*/
function prefixKeys(obj: Properties, prefix: string): Properties {
return Object.keys(obj).reduce(
(a, k) => ((a[`${prefix}${k}`] = obj[k]), a),
{} as Properties
);
}

/**
* Returns a new object containing only the keys that do not start with the specified prefix.
*/
function removePrefixedKeys(obj: Properties, prefix: string): Properties {
Object.keys(obj).forEach(k => {
if (k.startsWith(prefix)) delete obj[k];
});
return obj;
}
2 changes: 1 addition & 1 deletion functions/src/export-geojson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from './common/context';
import { getTempFilePath } from './common/temp-storage';
import { isAccessibleLoi } from './common/utils';
import { propertiesPbToObject } from './on-create-loi';
import { propertiesPbToObject } from './common/loi-properties';
import { DecodedIdToken } from 'firebase-admin/auth';
import { StatusCodes } from 'http-status-codes';
import { toMessage } from '@ground/lib';
Expand Down
7 changes: 1 addition & 6 deletions functions/src/import-geojson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Request } from 'firebase-functions/v2/https';
import type { Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import { getDatastore } from './common/context';
import { toTimestampPb } from './common/audit-info';
import Busboy from 'busboy';
import JSONStream from 'jsonstream-ts';
import { canImport } from './common/auth';
Expand Down Expand Up @@ -267,12 +268,6 @@ function toMillis(value: string | undefined): number | null {
return millis;
}

function toTimestampPb(millis: number): GroundProtos.google.protobuf.Timestamp {
return new GroundProtos.google.protobuf.Timestamp({
seconds: Math.floor(millis / 1000),
});
}

export function toLoiPbProperties(properties: GeoJsonProperties): {
[k: string]: Pb.LocationOfInterest.Property;
} {
Expand Down
18 changes: 14 additions & 4 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { onSchedule } from 'firebase-functions/scheduler';
import {
onDocumentCreated,
onDocumentDeleted,
onDocumentUpdated,
onDocumentWritten,
} from 'firebase-functions/v2/firestore';
import { onHttpsRequest, onHttpsRequestAsync } from './handlers';
Expand All @@ -30,11 +31,12 @@ import { exportGeojsonHandler } from './export-geojson';
import { cleanTempHandler } from './clean-temp';
import { cleanOrphanMediaHandler } from './clean-orphan-media';
import { onCall } from 'firebase-functions/v2/https';
import { onCreateLoiHandler } from './on-create-loi';
import { onCreatePasslistEntryHandler } from './on-create-passlist-entry';
import { onDeleteSubmissionHandler } from './on-delete-submission';
import { onWriteJobHandler } from './on-write-job';
import { onWriteLoiHandler } from './on-write-loi';
import { onCreateLoiHandler } from './on-create-loi';
import { onDeleteLoiHandler } from './on-delete-loi';
import { onUpdateLoiHandler } from './on-update-loi';
import { onWriteSubmissionHandler } from './on-write-submission';
import { onWriteSurveyHandler } from './on-write-survey';
import {
Expand Down Expand Up @@ -91,14 +93,22 @@ export const exportGeojson = onHttpsRequest(exportGeojsonHandler, {
cpu: 2,
});

export const onWriteJob = onDocumentWritten(jobPathTemplate, onWriteJobHandler);

export const onCreateLoi = onDocumentCreated(
loiPathTemplate,
onCreateLoiHandler
);

export const onWriteJob = onDocumentWritten(jobPathTemplate, onWriteJobHandler);
export const onUpdateLoi = onDocumentUpdated(
loiPathTemplate,
onUpdateLoiHandler
);

export const onWriteLoi = onDocumentWritten(loiPathTemplate, onWriteLoiHandler);
export const onDeleteLoi = onDocumentDeleted(
loiPathTemplate,
onDeleteLoiHandler
);

export const onWriteSubmission = onDocumentWritten(
submissionPathTemplate,
Expand Down
Loading
Loading