From c3be28ba603aebe97a66eb06a88d91d7ec882570 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Thu, 21 May 2026 01:55:37 -0400 Subject: [PATCH] feat(google-places): source API key from MP config first, env var as fallback Reworks GooglePlacesService key resolution to prefer the dp_Configuration_Settings row (Application_Code='COMMON', Key_Name='GoogleMapsAPIKey') before falling back to the GOOGLE_PLACES_API_KEY environment variable. When neither is set the feature stays disabled and the address autocomplete UI falls back to a plain text input as before. - Service now holds an MPHelper and a resolveApiKey() helper that caches the lookup on the singleton (MP is queried at most once per process). - isEnabled() and getProvider() are now async; MP lookup failures are swallowed so a permissions issue does not break the env-var fallback. - Updates the one synchronous caller in addeditfamily/actions.ts. - Documents the new precedence order in .env.example. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 9 +++- src/app/(web)/tools/addeditfamily/actions.ts | 2 +- src/services/googlePlacesService.ts | 53 +++++++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 11c6e12..c1157ca 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,13 @@ MINISTRY_PLATFORM_DEV_CLIENT_SECRET= # Enables address autocomplete on the Address Line 1 field in the Add/Edit # Family tool (and any other tool that uses GooglePlacesService). # -# When unset, the tool falls back to a plain text input — no errors, no UI -# change beyond losing the suggestion dropdown. +# Key resolution order (first non-empty wins): +# 1. MinistryPlatform dp_Configuration_Settings +# (Application_Code='COMMON', Key_Name='GoogleMapsAPIKey') +# 2. This GOOGLE_PLACES_API_KEY environment variable +# 3. If neither is set, the feature is disabled — the tool falls back to +# a plain text input with no errors and no UI change beyond losing the +# suggestion dropdown. # # Get a key at https://console.cloud.google.com and enable the "Places API # (New)" — the v1 REST endpoints. Calls go through our server actions, so diff --git a/src/app/(web)/tools/addeditfamily/actions.ts b/src/app/(web)/tools/addeditfamily/actions.ts index d70d856..9fe97a5 100644 --- a/src/app/(web)/tools/addeditfamily/actions.ts +++ b/src/app/(web)/tools/addeditfamily/actions.ts @@ -100,7 +100,7 @@ export async function placeAutocomplete( await getSession(); if (input.trim().length < 3) return []; const service = await GooglePlacesService.getInstance(); - if (!service.isEnabled()) return []; + if (!(await service.isEnabled())) return []; return service.autocomplete(input, sessionToken); } diff --git a/src/services/googlePlacesService.ts b/src/services/googlePlacesService.ts index d8d47e4..1c3f8f1 100644 --- a/src/services/googlePlacesService.ts +++ b/src/services/googlePlacesService.ts @@ -1,29 +1,66 @@ +import { MPHelper } from "@/lib/providers/ministry-platform"; import { GooglePlacesProvider } from "@/lib/providers/google-places"; import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places"; export class GooglePlacesService { private static instance: GooglePlacesService; + private mp: MPHelper | null = null; private provider: GooglePlacesProvider | null = null; + // undefined = not yet resolved; null = resolved with no key (feature disabled) + private resolvedKey: string | null | undefined = undefined; private constructor() {} public static async getInstance(): Promise { if (!GooglePlacesService.instance) { GooglePlacesService.instance = new GooglePlacesService(); + GooglePlacesService.instance.mp = new MPHelper(); } return GooglePlacesService.instance; } - public isEnabled(): boolean { - return Boolean(process.env.GOOGLE_PLACES_API_KEY); + /** + * Resolves the Google Places API key with this precedence: + * 1. MP dp_Configuration_Settings (Application_Code='COMMON', Key_Name='GoogleMapsAPIKey') + * 2. GOOGLE_PLACES_API_KEY environment variable + * 3. null (feature disabled) + * + * Cached on the singleton so MP is queried at most once per process lifetime. + */ + private async resolveApiKey(): Promise { + if (this.resolvedKey !== undefined) return this.resolvedKey; + + try { + const rows = await this.mp!.getTableRecords<{ Value: string | null }>({ + table: "dp_Configuration_Settings", + select: "Value", + filter: "Application_Code='COMMON' AND Key_Name='GoogleMapsAPIKey'", + top: 1, + }); + const mpKey = rows[0]?.Value?.trim(); + if (mpKey) { + this.resolvedKey = mpKey; + return this.resolvedKey; + } + } catch { + // Swallow lookup failures (e.g. table permissions) and fall through to env var. + } + + const envKey = process.env.GOOGLE_PLACES_API_KEY?.trim(); + this.resolvedKey = envKey ? envKey : null; + return this.resolvedKey; + } + + public async isEnabled(): Promise { + return (await this.resolveApiKey()) !== null; } - private getProvider(): GooglePlacesProvider { + private async getProvider(): Promise { if (this.provider) return this.provider; - const apiKey = process.env.GOOGLE_PLACES_API_KEY; + const apiKey = await this.resolveApiKey(); if (!apiKey) { throw new Error( - "GOOGLE_PLACES_API_KEY is not configured. Add it to .env.local to enable address autocomplete.", + "Google Places API key is not configured. Set the 'GoogleMapsAPIKey' setting in MinistryPlatform (Application_Code='COMMON') or define GOOGLE_PLACES_API_KEY in .env.local.", ); } this.provider = new GooglePlacesProvider(apiKey); @@ -31,10 +68,12 @@ export class GooglePlacesService { } async autocomplete(input: string, sessionToken: string): Promise { - return this.getProvider().autocomplete(input, sessionToken); + const provider = await this.getProvider(); + return provider.autocomplete(input, sessionToken); } async getPlaceDetails(placeId: string, sessionToken: string): Promise { - return this.getProvider().getPlaceDetails(placeId, sessionToken); + const provider = await this.getProvider(); + return provider.getPlaceDetails(placeId, sessionToken); } }