diff --git a/functions/src/common/datastore.ts b/functions/src/common/datastore.ts index 063009caa..a0f6c33e8 100644 --- a/functions/src/common/datastore.ts +++ b/functions/src/common/datastore.ts @@ -17,6 +17,7 @@ import { UserRecord } from 'firebase-admin/auth'; import { firestore } from 'firebase-admin'; import { DocumentData, FieldPath, GeoPoint } from 'firebase-admin/firestore'; +import type { Geometry } from 'geojson'; import { registry } from '@ground/lib'; import { GroundProtos } from '@ground/proto'; @@ -300,7 +301,7 @@ export class Datastore { * * @returns GeoJSON geometry object (with geometry as list of lists) */ - static fromFirestoreMap(geoJsonGeometry: any): any { + static fromFirestoreMap(geoJsonGeometry: object): Geometry { const geometryObject = geoJsonGeometry as pseudoGeoJsonGeometry; if (!geometryObject) { throw new Error( @@ -312,7 +313,7 @@ export class Datastore { geometryObject.coordinates ); - return geometryObject; + return geometryObject as unknown as Geometry; } static fromFirestoreValue(coordinates: any) { diff --git a/functions/src/on-create-loi.spec.ts b/functions/src/on-create-loi.spec.ts index 7e89692cd..a48dfd62e 100644 --- a/functions/src/on-create-loi.spec.ts +++ b/functions/src/on-create-loi.spec.ts @@ -65,6 +65,12 @@ describe('onCreateLoiHandler()', () => { url: 'https://whisp.example.com/api', }; + const geoIdConfig = { + name: 'geoId', + prefix: 'geoId_', + url: 'https://geoid.example.com/api', + }; + beforeEach(() => { mockFirestore = createMockFirestore(); stubAdminApi(mockFirestore); @@ -75,6 +81,9 @@ describe('onCreateLoiHandler()', () => { mockFirestore .doc('config/integrations/propertyGenerators/whisp') .set(whispConfig); + mockFirestore + .doc('config/integrations/propertyGenerators/geoId') + .set(geoIdConfig); }); afterEach(() => { @@ -118,4 +127,48 @@ describe('onCreateLoiHandler()', () => { [pr.numericValue]: 100, }); }); + + it('runs geoId property generator and updates LOI properties when integration is enabled', async () => { + mockFirestore.doc(JOB_PATH).set({ + [j.enabledIntegrations]: [{ [intgr.id]: 'geoId' }], + }); + spyOn(globalThis, 'fetch').and.returnValue( + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ id: '019d4e5a-d6bb-7000-99d2-3c0d4081586b' }), + } as Response) + ); + + await onCreateLoiHandler({ + data: newDocumentSnapshot(loiDoc) as unknown as QueryDocumentSnapshot, + params: { surveyId: SURVEY_ID, loiId: LOI_ID }, + } as unknown as FirestoreEvent); + + const loiData = (await mockFirestore.doc(LOI_PATH).get()).data(); + expect(loiData?.[l.properties]?.['geoId_id']).toEqual({ + [pr.stringValue]: '019d4e5a-d6bb-7000-99d2-3c0d4081586b', + }); + }); + + it('skips geoId property generator when fetch fails', async () => { + mockFirestore.doc(JOB_PATH).set({ + [j.enabledIntegrations]: [{ [intgr.id]: 'geoId' }], + }); + spyOn(globalThis, 'fetch').and.returnValue( + Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + } as Response) + ); + + await onCreateLoiHandler({ + data: newDocumentSnapshot(loiDoc) as unknown as QueryDocumentSnapshot, + params: { surveyId: SURVEY_ID, loiId: LOI_ID }, + } as unknown as FirestoreEvent); + + const loiData = (await mockFirestore.doc(LOI_PATH).get()).data(); + expect(loiData?.[l.properties]?.['geoId_id']).toBeUndefined(); + }); }); diff --git a/functions/src/on-create-loi.ts b/functions/src/on-create-loi.ts index 405002672..1de30ec24 100644 --- a/functions/src/on-create-loi.ts +++ b/functions/src/on-create-loi.ts @@ -64,15 +64,27 @@ export async function onCreateLoiHandler( const propertyGenerators = await db.fetchPropertyGenerators(); for (const propertyGeneratorDoc of propertyGenerators.docs) { + const generatorId = propertyGeneratorDoc.id; const config = propertyGeneratorDoc.data() as PropertyGeneratorConfig; - const handler = propertyGeneratorHandlers[propertyGeneratorDoc.id]; + const handler = propertyGeneratorHandlers[generatorId]; - if (!handler || !enabledIntegrationIds.has(propertyGeneratorDoc.id)) + if (!handler) { continue; + } - const newProperties = await handler(config, geometry); + if (!enabledIntegrationIds.has(generatorId)) { + continue; + } - properties = updateProperties(properties, newProperties, config.prefix); + try { + const newProperties = await handler(config, geometry); + properties = updateProperties(properties, newProperties, config.prefix); + } catch (e) { + console.error( + `LOI ${loiId}: property generator '${generatorId}' failed:`, + e + ); + } Object.keys(properties) .filter(key => typeof properties[key] === 'object') diff --git a/functions/src/property-generators/geo-id.ts b/functions/src/property-generators/geo-id.ts new file mode 100644 index 000000000..5755f75a1 --- /dev/null +++ b/functions/src/property-generators/geo-id.ts @@ -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 * as logger from 'firebase-functions/logger'; +import type { Geometry } from 'geojson'; +import type { Headers, Properties, PropertyGeneratorConfig } from './types'; + +const defaultHeaders = { 'Content-Type': 'application/json' }; + +export async function geoIdHandler( + config: PropertyGeneratorConfig, + geometry: Geometry +): Promise { + const { headers, url } = config; + + return fetchGeoIdProperties( + url, + { ...defaultHeaders, ...headers }, + { type: 'Feature', geometry, properties: {} } + ); +} + +async function fetchGeoIdProperties( + url: string, + headers: Headers, + body: object +): Promise { + const bodyJson = JSON.stringify(body); + logger.debug(`geoId: POST ${url} body=${bodyJson}`); + + const response = await fetch(url, { + method: 'POST', + headers, + body: bodyJson, + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error( + `geoId: request failed with status ${response.status}: ${errorBody}` + ); + return {}; + } + + const responseJson = await response.json(); + const id = responseJson?.id; + if (!id) { + logger.error( + `geoId: response missing id field, body=${JSON.stringify(responseJson)}` + ); + return {}; + } + logger.debug(`geoId: received id=${id}`); + + return { id }; +} diff --git a/functions/src/property-generators/geoid.ts b/functions/src/property-generators/geoid.ts deleted file mode 100644 index f1c6860dc..000000000 --- a/functions/src/property-generators/geoid.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 type { Properties, PropertyGeneratorHandler } from './types'; - -// TODO: Implement geoid property generation. -export const geoidHandler: PropertyGeneratorHandler = - async (): Promise => ({}); diff --git a/functions/src/property-generators/index.ts b/functions/src/property-generators/index.ts index 03541288e..c9b808396 100644 --- a/functions/src/property-generators/index.ts +++ b/functions/src/property-generators/index.ts @@ -22,7 +22,7 @@ export type { PropertyGeneratorHandler, } from './types'; -import { geoidHandler } from './geoid'; +import { geoIdHandler } from './geo-id'; import { whispHandler } from './whisp'; import type { PropertyGeneratorHandler } from './types'; @@ -30,6 +30,6 @@ export const propertyGeneratorHandlers: Record< string, PropertyGeneratorHandler > = { - geoid: geoidHandler, + geoId: geoIdHandler, whisp: whispHandler, }; diff --git a/functions/src/property-generators/types.ts b/functions/src/property-generators/types.ts index a53606546..327312223 100644 --- a/functions/src/property-generators/types.ts +++ b/functions/src/property-generators/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type { Geometry } from 'geojson'; + export type Properties = Record; export type Headers = Record; @@ -30,5 +32,5 @@ export type PropertyGeneratorConfig = { export type PropertyGeneratorHandler = ( config: PropertyGeneratorConfig, - geometry: object + geometry: Geometry ) => Promise; diff --git a/functions/src/property-generators/whisp.ts b/functions/src/property-generators/whisp.ts index 8e96c189c..f8013d551 100644 --- a/functions/src/property-generators/whisp.ts +++ b/functions/src/property-generators/whisp.ts @@ -15,7 +15,7 @@ */ import { geojsonToWKT } from '@terraformer/wkt'; -import { Datastore } from '../common/datastore'; +import type { Geometry } from 'geojson'; import type { Body, Headers, @@ -36,11 +36,11 @@ const defaultHeaders = { 'Content-Type': 'application/json' }; export async function whispHandler( config: PropertyGeneratorConfig, - geometry: object + geometry: Geometry ): Promise { const { body, headers, url } = config; - const wkt = geojsonToWKT(Datastore.fromFirestoreMap(geometry)); + const wkt = geojsonToWKT(geometry); return fetchWhispProperties( url, diff --git a/web/src/app/components/shared/job-integration-editor/job-integration-editor.component.ts b/web/src/app/components/shared/job-integration-editor/job-integration-editor.component.ts index e81143d3a..206db9a38 100644 --- a/web/src/app/components/shared/job-integration-editor/job-integration-editor.component.ts +++ b/web/src/app/components/shared/job-integration-editor/job-integration-editor.component.ts @@ -42,7 +42,7 @@ export class JobIntegrationEditorComponent { description: $localize`:@@app.cards.whispIntegration.description:Enable Whisp integration for this job.`, }, { - id: 'geoid', + id: 'geoId', title: $localize`:@@app.cards.geoidIntegration.title:GeoID integration`, description: $localize`:@@app.cards.geoidIntegration.description:Enable GeoID integration for this job.`, },