From 4d5aa433e8aa3cd70be8ef66ac7b91340d140848 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 16:05:50 +0700 Subject: [PATCH] Persist Google nearby places --- src/graphql/places.ts | 175 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 34 deletions(-) diff --git a/src/graphql/places.ts b/src/graphql/places.ts index b0d7791..30f62c6 100644 --- a/src/graphql/places.ts +++ b/src/graphql/places.ts @@ -1,5 +1,6 @@ import { prisma } from '../prisma.js'; import { config } from '../config.js'; +import type { Prisma } from '../generated/prisma/client.js'; const googleNearbySearchUrl = 'https://places.googleapis.com/v1/places:searchNearby'; @@ -34,6 +35,23 @@ type GoogleNearbyResponse = { places?: GoogleNearbyPlace[]; }; +type PersistableGooglePlace = { + googlePlaceId: string; + name: string; + latitude: number; + longitude: number; +}; + +type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{ + include: { + experiences: { + include: { + user: true; + }; + }; + }; +}>; + function serializeVoiceExperience( experience: Awaited>[number], ) { @@ -43,6 +61,13 @@ function serializeVoiceExperience( }; } +function serializePlace(place: PlaceWithRecentExperiences) { + return { + ...place, + experiences: place.experiences.map(serializeVoiceExperience), + }; +} + export async function listPlaces() { const places = await prisma.place.findMany({ include: { @@ -55,10 +80,7 @@ export async function listPlaces() { orderBy: { updatedAt: 'desc' }, }); - return places.map((place) => ({ - ...place, - experiences: place.experiences.map(serializeVoiceExperience), - })); + return places.map(serializePlace); } export async function listNearbyPlaces(input: NearbyPlacesInput) { @@ -70,7 +92,9 @@ export async function listNearbyPlaces(input: NearbyPlacesInput) { input.radiusMeters <= 0 || input.radiusMeters > maxNearbyRadiusMeters ) { - throw new Error(`Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`); + throw new Error( + `Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`, + ); } if (config.googlePlacesApiKey === '') { throw new Error('GOOGLE_PLACES_API_KEY is required for nearby place search.'); @@ -109,36 +133,10 @@ export async function listNearbyPlaces(input: NearbyPlacesInput) { } const payload = (await response.json()) as GoogleNearbyResponse; - const places = payload.places ?? []; - return places - .map((place) => { - const googlePlaceId = place.id; - const name = place.displayName?.text; - const latitude = place.location?.latitude; - const longitude = place.location?.longitude; - if ( - !googlePlaceId || - !name || - latitude === undefined || - longitude === undefined - ) { - return null; - } + const googlePlaces = parseGoogleNearbyPlaces(input, payload); + await upsertGooglePlaces(googlePlaces); - return { - id: googlePlaceId, - googlePlaceId, - name, - latitude, - longitude, - experiences: [], - distanceMeters: distanceMeters(input.latitude, input.longitude, latitude, longitude), - }; - }) - .filter((place) => place !== null) - .filter((place) => place.distanceMeters <= input.radiusMeters) - .sort((a, b) => a.distanceMeters - b.distanceMeters) - .map(({ distanceMeters: _distanceMeters, ...place }) => place); + return listPersistedNearbyPlaces(input); } export async function listVoiceExperiences() { @@ -174,3 +172,112 @@ function distanceMeters( function degreesToRadians(value: number) { return (value * Math.PI) / 180; } + +function parseGoogleNearbyPlaces( + input: NearbyPlacesInput, + payload: GoogleNearbyResponse, +) { + const placesByGoogleId = new Map(); + for (const place of payload.places ?? []) { + const googlePlaceId = place.id; + const name = place.displayName?.text; + const latitude = place.location?.latitude; + const longitude = place.location?.longitude; + if ( + !googlePlaceId || + !name || + latitude === undefined || + longitude === undefined + ) { + continue; + } + if ( + distanceMeters(input.latitude, input.longitude, latitude, longitude) > + input.radiusMeters + ) { + continue; + } + + placesByGoogleId.set(googlePlaceId, { + googlePlaceId, + name, + latitude, + longitude, + }); + } + + return [...placesByGoogleId.values()]; +} + +async function upsertGooglePlaces(places: PersistableGooglePlace[]) { + await Promise.all( + places.map((place) => + prisma.place.upsert({ + where: { googlePlaceId: place.googlePlaceId }, + create: place, + update: { + name: place.name, + latitude: place.latitude, + longitude: place.longitude, + }, + }), + ), + ); +} + +async function listPersistedNearbyPlaces(input: NearbyPlacesInput) { + const bounds = coordinateBounds( + input.latitude, + input.longitude, + input.radiusMeters, + ); + const places = await prisma.place.findMany({ + where: { + latitude: { gte: bounds.minLatitude, lte: bounds.maxLatitude }, + longitude: { gte: bounds.minLongitude, lte: bounds.maxLongitude }, + }, + include: { + experiences: { + orderBy: { createdAt: 'desc' }, + take: 10, + include: { user: true }, + }, + }, + }); + + return places + .map((place) => ({ + place, + distanceMeters: distanceMeters( + input.latitude, + input.longitude, + place.latitude, + place.longitude, + ), + })) + .filter(({ distanceMeters }) => distanceMeters <= input.radiusMeters) + .sort((a, b) => a.distanceMeters - b.distanceMeters) + .map(({ place }) => serializePlace(place)); +} + +function coordinateBounds( + latitude: number, + longitude: number, + radiusMeters: number, +) { + const metersPerLatitudeDegree = 111320; + const latitudeDelta = radiusMeters / metersPerLatitudeDegree; + const longitudeScale = Math.max( + Math.cos(degreesToRadians(latitude)), + 0.000001, + ); + const longitudeDelta = + radiusMeters / (metersPerLatitudeDegree * longitudeScale); + + return { + minLatitude: latitude - latitudeDelta, + maxLatitude: latitude + latitudeDelta, + minLongitude: longitude - longitudeDelta, + maxLongitude: longitude + longitudeDelta, + }; +}