diff --git a/.envrc.local.template b/.envrc.local.template index 584a5c81e29e3c936c0d0c6576f9dd46afd86bf7..f0c3c0d61ebb2ab380de3384d49342189ebd62af 100644 --- a/.envrc.local.template +++ b/.envrc.local.template @@ -1,7 +1,12 @@ +export CACHE_ENABLED=false + export VOLUNTEERING_VOLTASTICS_API_URL= export VOLUNTEERING_VOLTASTICS_API_KEY= + export DB_HOST= export DB_PORT= export DB_NAME= export DB_USERNAME= export DB_PASSWORD= + +export GEO_API_ENDPOINT_URL= diff --git a/README.md b/README.md index 5c0d30094d4305749632a6485f3b54f2584cf80b..4a1a188f3d1dafcf06609e5c69d35b8de5ba5715 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,16 @@ service to run. Please see the [configuration section](#configuration) to learn about the possible environment values, their purpose and where to find the values. +### ML Recommendations + +In the context of HOLI-8190 recommendations were introduced. Recommendations +are based on embeddings that were previously created by an ML pipeline and +stored in a Postgres database within Google Cloud. These are currently not +locally available. The endpoint `engagementRecommendations` relies on a +connection to the aforementioned database that is only accessible to holi +employees (unless running the service with the environment variable +`FAKE=true`). + ### Running To watch for file changes during development, run @@ -184,11 +194,12 @@ if "you know what you're doing". ### Configuration -| Environment Variable | Default Value | Description | -| ------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | -| PORT | 8004 | the port to listen on | -| CACHE_ENABLED | true | wether or not to enable caching | -| CACHE_TTL_MS | 60 seconds | time-to-live in ms | -| VOLUNTEERING_VOLTASTICS_API_URL | undefined | Voltastics API base URL | -| VOLUNTEERING_VOLTASTICS_API_KEY | undefined | Voltastics API Token | -| IMAGE_PROXY_BASE_URL | https://images.holi.social (production), https://dev-images.holi.social (all other envs) | Base URL for the image proxy server | +| Environment Variable | Default Value | Description | +|----------------------------------| ---------------------------------------------------------------------------------------- |----------------------------------------| +| PORT | 8004 | the port to listen on | +| CACHE_ENABLED | true | wether or not to enable caching | +| CACHE_TTL_MS | 60 seconds | time-to-live in ms | +| VOLUNTEERING_VOLTASTICS_API_URL | undefined | Voltastics API base URL | +| VOLUNTEERING_VOLTASTICS_API_KEY | undefined | Voltastics API Token | +| IMAGE_PROXY_BASE_URL | https://images.holi.social (production), https://dev-images.holi.social (all other envs) | Base URL for the image proxy server | +| GEO_API_ENDPOINT_URL | undefined | GraphQL Endpoint URL for holis Geo API | diff --git a/app/deps.ts b/app/deps.ts index c3a1a08f43c5d89d766ca771d4ee4b0f43b11b2c..515034b748ff2bf6378e364f24302b1b4f36bf07 100644 --- a/app/deps.ts +++ b/app/deps.ts @@ -3,4 +3,7 @@ export { createSchema, createYoga } from "npm:graphql-yoga@5.1.1"; export { useResponseCache } from "npm:@graphql-yoga/plugin-response-cache@1.0.0"; export { GraphQLError } from "npm:graphql@16.8.1"; export * as turf from "https://esm.sh/@turf/turf@6.5.0"; -export { Client } from "https://deno.land/x/postgres/mod.ts"; +export { + Client, + type QueryObjectResult, +} from "https://deno.land/x/postgres/mod.ts"; diff --git a/app/dev_deps.ts b/app/dev_deps.ts index df97030726db7e77b2c16df8e160b01135347258..dd30673135247255a2a88a65530e2d7b506744f8 100644 --- a/app/dev_deps.ts +++ b/app/dev_deps.ts @@ -1,6 +1,9 @@ export { assertSpyCall, + assertSpyCalls, returnsNext, + type Spy, + spy, stub, } from "https://deno.land/std@0.155.0/testing/mock.ts"; export type { Stub } from "https://deno.land/std@0.155.0/testing/mock.ts"; diff --git a/app/geo_api_client.ts b/app/geo_api_client.ts new file mode 100644 index 0000000000000000000000000000000000000000..427e954c5527a8b44c73cf8d303d8eb5779f5509 --- /dev/null +++ b/app/geo_api_client.ts @@ -0,0 +1,39 @@ +import { GeoLocation } from "./volunteering_db.ts"; + +type GeoAPIResponse = { + data?: { + placeDetails?: { + geolocation?: { + properties?: { + lat?: number; + lon?: number; + }; + }; + }; + }; +}; + +export const resolveGeoLocationViaGeoAPI = + (geoAPIEndpointUrl: string) => + async (geolocationId: string): Promise<GeoLocation> => { + const graphQLQuery = + `{ placeDetails(id: "${geolocationId}") { geolocation } }`; + const response = await fetch(geoAPIEndpointUrl, { + "body": JSON.stringify({ query: graphQLQuery }), + "headers": { + "Accept": "application/graphql-response+json, application/json", + "Content-Type": "application/json", + }, + "method": "POST", + }); + const responseJSON = await response.json() as GeoAPIResponse; + const { lat, lon } = + responseJSON.data?.placeDetails?.geolocation?.properties || {}; + if (lat && lon) { + return { lat, lon }; + } else { + return Promise.reject( + `Resolution of lat/lng failed (no data included in response for geolocationId=${geolocationId})`, + ); + } + }; diff --git a/app/main.ts b/app/main.ts index bb3eff44d771afbc83e4ea06f7a4ee4988cd49b2..c3efe304aa0d37d86fb25b3213c54d0cb87f02fc 100644 --- a/app/main.ts +++ b/app/main.ts @@ -64,11 +64,27 @@ const serverConfigFromEnv = (): ServerConfig => { : "https://dev-images.holi.social", ), volunteeringDB: { - hostname: requiredEnv("DB_HOST", String), - port: requiredEnv("DB_PORT", String), - database: requiredEnv("DB_NAME", String), - username: requiredEnv("DB_USERNAME", String), - password: requiredEnv("DB_PASSWORD", String), + hostname: requiredEnv( + "DB_HOST", + String, + fake ? "dummy value" : undefined, + ), + port: requiredEnv("DB_PORT", String, fake ? "31337" : undefined), + database: requiredEnv( + "DB_NAME", + String, + fake ? "dummy value" : undefined, + ), + username: requiredEnv( + "DB_USERNAME", + String, + fake ? "dummy value" : undefined, + ), + password: requiredEnv( + "DB_PASSWORD", + String, + fake ? "dummy value" : undefined, + ), connectionAttempts: requiredEnv( "DB_CONNECTION_ATTEMPTS", Number, @@ -76,6 +92,11 @@ const serverConfigFromEnv = (): ServerConfig => { ), }, fake, + geoAPIEndpointUrl: requiredEnv( + "GEO_API_ENDPOINT_URL", + String, + fake ? "dummy value" : undefined, + ), }; }; diff --git a/app/server.ts b/app/server.ts index be9f6e012729c4301f30f50d8b61b60fb517fd72..adcc31d9579eb457d20e920ffbb83be5b010ddc0 100644 --- a/app/server.ts +++ b/app/server.ts @@ -21,6 +21,8 @@ import { } from "./voltastics.ts"; import { logger } from "./logging.ts"; import { VolunteeringDB, VolunteeringDBConfig } from "./volunteering_db.ts"; +import { resolveGeoLocationViaGeoAPI } from "./geo_api_client.ts"; +import { Client } from "https://deno.land/x/postgres@v0.19.3/client.ts"; const typeDefs = ` type Organizer { @@ -75,7 +77,7 @@ const typeDefs = ` type Query { # uses offset-based pagination as described in https://www.apollographql.com/docs/react/pagination/offset-based engagementOpportunities(offset: Int! = 0, limit: Int! = 10, location: GeoJSON, category: String): EngagementsResponse! - engagementRecommendations(offset: Int! = 0, limit: Int! = 10, topics: [String!], skills: [String!], location: GeoJSON): EngagementsResponse! + engagementRecommendations(offset: Int! = 0, limit: Int! = 10, topics: [String!], skills: [String!], geolocationId: String): EngagementsResponse! engagement(id: String!): Engagement categories: CategoriesResponse! engagements(offset: Int! = 0, limit: Int! = 10, location: String, category: String, longitude: Float, latitude: Float, radiusKm: Float): EngagementsResponse! @deprecated(reason: "Use engagementOpportunities instead, as its support will be discontinued after 1.5.1.") @@ -167,6 +169,7 @@ export interface ServerConfig { imageProxyBaseUrl: string; fake: boolean; // For local development. If set, the API returns dummy data volunteeringDB: VolunteeringDBConfig; + geoAPIEndpointUrl: string; } export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { @@ -184,9 +187,28 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { }), ] : []; + const client = new Client({ + hostname: config.volunteeringDB.hostname, + port: config.volunteeringDB.port, + database: config.volunteeringDB.database, + user: config.volunteeringDB.username, + password: config.volunteeringDB.password, + controls: { + debug: { + queries: false, + notices: false, + results: false, + queryInError: true, + }, + }, + connection: { + attempts: config.volunteeringDB.connectionAttempts, + }, + }); const volunteeringDB = new VolunteeringDB( - config.volunteeringDB, + client, config.imageProxyBaseUrl, + resolveGeoLocationViaGeoAPI(config.geoAPIEndpointUrl), ); const resolvers = createResolvers(config, volunteeringDB); return createYoga({ diff --git a/app/types.ts b/app/types.ts index 9c425fd2eb8854f6f009b5650420b41020fa66e9..078a6d171d96b2722c485c614a1643cca37c147b 100644 --- a/app/types.ts +++ b/app/types.ts @@ -58,7 +58,7 @@ export type EngagementRecommendationsParameters = { offset: number; skills: string[]; topics: string[]; - location?: PlaceDetails; + geolocationId?: string; }; export type PlaceDetails = { diff --git a/app/voltastics_test.ts b/app/voltastics_test.ts index 2cfd2949eaa14c033fa02c9e8875d8ef4bf7b676..3150b8f345c613d76d1bcb138772f5c7d17fb7e8 100644 --- a/app/voltastics_test.ts +++ b/app/voltastics_test.ts @@ -143,6 +143,7 @@ const serverConfigMock: ServerConfig = { username: "admin", connectionAttempts: 10, }, + geoAPIEndpointUrl: "fakeUrl", }; const noCacheServerConfig = { @@ -160,6 +161,7 @@ const noCacheServerConfig = { username: "admin", connectionAttempts: 10, }, + geoAPIEndpointUrl: "fakeUrl", }; describe("voltastics", () => { diff --git a/app/volunteering_db.ts b/app/volunteering_db.ts index 3576b4ce2a13194c2d25bef69ea1d4e431d74c1c..0d0d8af3be520a3d82bee2681768f953036338dc 100644 --- a/app/volunteering_db.ts +++ b/app/volunteering_db.ts @@ -1,4 +1,5 @@ import { Client } from "./deps.ts"; +import type { QueryObjectResult } from "./deps.ts"; import { Engagement, EngagementRecommendationsParameters, @@ -164,64 +165,131 @@ const toEngagement = longitude: row.longitude, }); +export type GeoLocation = { + lat: number; + lon: number; +}; + +export type GeoLocationResolver = ( + geoLocationId: string, +) => Promise<GeoLocation>; + export class VolunteeringDB { private client: Client; private readonly imageProxyBaseUrl: string; + private resolveGeoLocation: GeoLocationResolver; - constructor(config: VolunteeringDBConfig, imageProxyBaseUrl: string) { - this.client = new Client({ - hostname: config.hostname, - port: config.port, - database: config.database, - user: config.username, - password: config.password, - controls: { - debug: { - queries: true, - notices: true, - results: true, - queryInError: true, - }, - }, - connection: { - attempts: config.connectionAttempts, - }, - }); + constructor( + client: Client, + imageProxyBaseUrl: string, + resolveGeoLocation: GeoLocationResolver, + ) { + this.client = client; this.imageProxyBaseUrl = imageProxyBaseUrl; + this.resolveGeoLocation = resolveGeoLocation; } async findRecommendations( - params: EngagementRecommendationsParameters, + { offset, limit, topics, skills, geolocationId }: + EngagementRecommendationsParameters, ): Promise<EngagementsResponse> { - const recos = await this.queryRecommendations( - params.topics, - params.skills, - params.offset < 0 ? 0 : params.offset, - params.limit > 100 || params.limit < 1 ? 10 : params.limit, - ) - .catch((reason) => { - console.error("Error executing query", reason); - throw reason; - }); + const result = await this.queryRecos( + offset < 0 ? 0 : offset, + limit > 100 || limit < 1 ? 10 : limit, + topics, + skills, + geolocationId, + ).catch((reason) => { + console.error("Error retrieving recommendations. Reason: ", reason); + throw reason; + }); + const recos = result.rows.map(toEngagement(this.imageProxyBaseUrl)); return Promise.resolve({ totalResults: recos.length, data: recos, }); } - // Function to perform cosine similarity search - private async queryRecommendations( + private async queryRecos( + offset: number, + limit: number, topics: string[], skills: string[], + geolocationId?: string, + ): Promise<QueryObjectResult<VolunteeringDBRow>> { + const beforeResolve = Date.now(); + const geoLocation = geolocationId + ? await this.resolveGeoLocation(geolocationId) + .then((geoLocation) => { + const afterResolve = Date.now(); + console.debug( + `[${ + (afterResolve - beforeResolve).toString().padStart(4, " ") + } ms] Successfully resolved ${ + JSON.stringify(geoLocation) + } for geolocationId=${geolocationId}`, + ); + return geoLocation; + }) + .catch((error) => { + console.warn(error); + if (topics.length == 0 && skills.length == 0) { + throw new Error( + "Can't retrieve recommendations: geo location resolution failed and no topics or skills given", + ); + } + // gracefully catch error so that recommendations will still be delivered but without location factored in + return undefined; + }) + : undefined; + + const beforeQuery = Date.now(); + const logQueryDuration = ( + result: QueryObjectResult<VolunteeringDBRow>, + ): QueryObjectResult<VolunteeringDBRow> => { + const afterQuery = Date.now(); + console.debug( + `[${ + (afterQuery - beforeQuery).toString().padStart(4, " ") + } ms] Successfully retrieved ${result.rows.length} recommendations from DB`, + ); + return result; + }; + + if (geoLocation && (topics.length > 0 || skills.length > 0)) { + return this.queryRecosBasedOnTopicsSkillsAndLocation( + offset, + limit, + topics, + skills, + geoLocation, + ).then(logQueryDuration); + } else if (geoLocation) { + return this.queryRecosBasedOnLocation(offset, limit, geoLocation).then( + logQueryDuration, + ); + } else if (topics.length > 0 || skills.length > 0) { + return this.queryRecosBasedOnTopicsAndSkills( + offset, + limit, + topics, + skills, + ).then(logQueryDuration); + } else { + return Promise.reject( + "It is required to provide at least one of (topics, skills, or location) in order to retrieve recommendations", + ); + } + } + + private async queryRecosBasedOnTopicsAndSkills( offset: number, limit: number, - ): Promise<Engagement[]> { + topics: string[], + skills: string[], + ): Promise<QueryObjectResult<VolunteeringDBRow>> { const queryVector = JSON.stringify(recosQueryVector(topics, skills)); - console.debug(queryVector); - - // special syntax for Denos postgres client, resulting in an SQL injection-safe prepared statement - // see https://deno-postgres.com/#/?id=template-strings - const result = await this.client.queryObject<VolunteeringDBRow>` + return await this.client.queryObject<VolunteeringDBRow>` WITH vector_matches AS ( SELECT *, 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity FROM volunteering_voltastics_with_classification @@ -232,6 +300,60 @@ export class VolunteeringDB { SELECT * FROM vector_matches; `; - return result.rows.map(toEngagement(this.imageProxyBaseUrl)); + } + + private async queryRecosBasedOnTopicsSkillsAndLocation( + offset: number, + limit: number, + topics: string[], + skills: string[], + geoLocation: GeoLocation, + ): Promise<QueryObjectResult<VolunteeringDBRow>> { + const rankingWeightCosineSimilarity = 0.7; // Weight for cosine similarity + const rankingWeightDistance = 0.3; // Weight for proximity + const maxDistanceInMeters = 50_000; // in meters (e.g., 50 km) + const queryVector = JSON.stringify(recosQueryVector(topics, skills)); + const { lat, lon } = geoLocation; + + // Useful knowledge + // - PostGIS uses lon, lat (NOT lat, lon) + // - <#> uses index scans if used in an ORDER BY clause, ST_Distance does not + // - Database stores in GPS-Coordinates (SRID 4326) + // - In order to calculate a distance in meters, a geometry needs to be projected by transforming using + // ST_Transform(geom, 3857) where we use the SRID 3857 for Pseudo-Mercator + return await this.client.queryObject<VolunteeringDBRow>` + WITH calculations AS ( + SELECT *, + 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity, + ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters + FROM volunteering_voltastics_with_classification + ), scored AS ( + SELECT *, + (${rankingWeightCosineSimilarity} * cosine_similarity) + + (${rankingWeightDistance} * (1 - LEAST(distance_in_meters, ${maxDistanceInMeters})) / ${maxDistanceInMeters}) AS weighted_score + FROM calculations + ) + SELECT * + FROM scored + ORDER BY weighted_score DESC + OFFSET ${offset} + LIMIT ${limit}; + `; + } + + private async queryRecosBasedOnLocation( + offset: number, + limit: number, + geoLocation: GeoLocation, + ): Promise<QueryObjectResult<VolunteeringDBRow>> { + const { lat, lon } = geoLocation; + return await this.client.queryObject<VolunteeringDBRow>` + SELECT *, + ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters + FROM volunteering_voltastics_with_classification + ORDER BY distance_in_meters ASC + OFFSET ${offset} + LIMIT ${limit}; + `; } } diff --git a/app/volunteering_db_test.ts b/app/volunteering_db_test.ts index 4df332aa7127ff81862e60d8237fd1929da373c9..f26e4399ce7c54379dcc194a827c332f46d0fa51 100644 --- a/app/volunteering_db_test.ts +++ b/app/volunteering_db_test.ts @@ -1,5 +1,51 @@ -import { assertEquals, describe, it } from "./dev_deps.ts"; -import { exportedForTesting } from "./volunteering_db.ts"; +import type { Spy } from "./dev_deps.ts"; +import { + assertEquals, + assertRejects, + assertSpyCall, + assertSpyCalls, + describe, + it, + spy, +} from "./dev_deps.ts"; +import { + exportedForTesting, + GeoLocationResolver, + VolunteeringDB, +} from "./volunteering_db.ts"; +import { Client } from "./deps.ts"; + +type ResolveLocationSpy = Spy< + unknown, + [_geoLocationId: string], + Promise<{ lat: number; lon: number }> +>; + +const succeedingGeoLocationResolver: GeoLocationResolver = ( + _geoLocationId: string, +) => Promise.resolve({ lat: 123, lon: 321 }); +const failingGeoLocationResolver: GeoLocationResolver = ( + _geoLocationId: string, +) => Promise.reject("boom!"); + +const withMockedDependencies = ( + geoLocationResolver: GeoLocationResolver, + test: ( + vDB: VolunteeringDB, + resolveLocationSpy: ResolveLocationSpy, + ) => unknown | Promise<unknown>, +) => { + const geoLocationResolverSpy = spy(geoLocationResolver); + const mockClient = { + queryObject: () => Promise.resolve({ rows: [] }), + } as unknown as Client; + const volunteeringDB = new VolunteeringDB( + mockClient, + "mock-image-proxy-base-url", + geoLocationResolverSpy, + ); + test(volunteeringDB, geoLocationResolverSpy); +}; describe("VolunteeringDB", () => { it("correctly constructs a query vector", () => { @@ -28,4 +74,70 @@ describe("VolunteeringDB", () => { assertEquals(actualVector.length, expectedLength); assertEquals(actualVector, expectedVector); }); + it("resolves latitude/longitude using the GeoAPI client when geoLocationId is given", () => { + withMockedDependencies( + succeedingGeoLocationResolver, + (volunteeringDB, resolveLocationSpy) => { + volunteeringDB.findRecommendations({ + limit: 10, + offset: 0, + topics: [], + skills: [], + geolocationId: "mock-geolocation-id", + }); + assertSpyCall(resolveLocationSpy, 0, { + args: ["mock-geolocation-id"], + }); + }, + ); + }); + it("does not resolve latitude/longitude when geoLocationId is not given", () => { + withMockedDependencies( + succeedingGeoLocationResolver, + (volunteeringDB, resolveLocationSpy) => { + volunteeringDB.findRecommendations({ + limit: 10, + offset: 0, + topics: ["agriculture-food"], + skills: [], + }); + assertSpyCalls(resolveLocationSpy, 0); + }, + ); + }); + it("falls back to recommend based only on topics and skills if given and geo location resolution fails", () => { + withMockedDependencies( + failingGeoLocationResolver, + async (volunteeringDB, _resolveLocationSpy) => { + const result = await volunteeringDB.findRecommendations({ + limit: 10, + offset: 0, + topics: ["agriculture-food"], + skills: [], + geolocationId: "mock-geolocation-id", + }); + assertEquals(result.totalResults, 0); + assertEquals(result.data, []); + }, + ); + }); + it("fails with error message if no topics and skills are given and geo location resolution fails", () => { + withMockedDependencies( + failingGeoLocationResolver, + async (volunteeringDB, _resolveLocationSpy) => { + await assertRejects( + () => + volunteeringDB.findRecommendations({ + limit: 10, + offset: 0, + topics: [], + skills: [], + geolocationId: "mock-geolocation-id", + }), + Error, + "geo location resolution failed and no topics or skills given", + ); + }, + ); + }); }); diff --git a/deno.json b/deno.json index b409918fc270a1a693f99087b4094bc5fa926759..184e3eda5b4a4ee8464ba50b1fc65aa248b3c1f0 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,7 @@ "docker": "docker build -t volunteering-api . && docker run -it --init -p 8004:8004 volunteering-api", "coverage": "deno test --coverage=coverage && deno coverage coverage", "pre-commit": { - "cmd": "vr lint && vr fmt:check", + "cmd": "vr lint && vr fmt:check && deno test", "gitHook": "pre-commit" } } diff --git a/terraform/environments/deployment.tf b/terraform/environments/deployment.tf index ec78777c8b69e5aab7e419629d1aa35b5e38dcac..86a05f82e347259aca5ac3fe5b7f730be48f197a 100644 --- a/terraform/environments/deployment.tf +++ b/terraform/environments/deployment.tf @@ -114,6 +114,10 @@ resource "google_cloud_run_service" "volunteering_api" { } } } + env { + name = "GEO_API_ENDPOINT_URL" + value = data.terraform_remote_state.holi_geo_api_environments_state.outputs.api_endpoint_url + } resources { limits = { # cpu can only be scaled down to 1000m as long as container_concurrency is set to != 1 @@ -136,7 +140,7 @@ resource "google_cloud_run_service" "volunteering_api" { # Use the VPC Connector "run.googleapis.com/vpc-access-connector" = data.terraform_remote_state.holi_infra_state.outputs.vpc_access_connector_name # possible values: all-traffic/private-ranges-only(default) https://cloud.google.com/sdk/gcloud/reference/run/services/update#--vpc-egress - "run.googleapis.com/vpc-access-egress" = "private-ranges-only" + "run.googleapis.com/vpc-access-egress" = "all-traffic" } } } diff --git a/terraform/environments/init.tf b/terraform/environments/init.tf index 1fd30f206489a57033642104d1acfef3a65a26a0..955dd9a410307fb4c71c87ec7395a5bde786b787 100644 --- a/terraform/environments/init.tf +++ b/terraform/environments/init.tf @@ -21,6 +21,15 @@ data "terraform_remote_state" "holi_volunteering_api_common_state" { } } +data "terraform_remote_state" "holi_geo_api_environments_state" { + backend = "gcs" + workspace = terraform.workspace == "production" ? "production" : "staging" + config = { + bucket = "holi-shared-terraform-state" + prefix = "geo-api-environments" + } +} + # provider google including beta features provider "google" { region = local.default_region