From 9384a42e39118da018c40dc65dd0192a32f5f14e Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 15:19:12 +0700 Subject: [PATCH] Add Google nearby place search --- src/config.ts | 1 + src/graphql/places.ts | 138 ++++++++++++++++++++++++++++++++++++++++++ src/graphql/schema.ts | 22 ++++++- 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index bfc6ad1..c2f97aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ export const config = { telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '', telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot', telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '', + googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '', webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn', publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn', telegramAuthMaxAgeSeconds: Number( diff --git a/src/graphql/places.ts b/src/graphql/places.ts index 3ca437a..b0d7791 100644 --- a/src/graphql/places.ts +++ b/src/graphql/places.ts @@ -1,4 +1,38 @@ import { prisma } from '../prisma.js'; +import { config } from '../config.js'; + +const googleNearbySearchUrl = + 'https://places.googleapis.com/v1/places:searchNearby'; +const maxNearbyRadiusMeters = 500; +const nearbyPlaceTypes = [ + 'restaurant', + 'cafe', + 'bar', + 'bakery', + 'meal_takeaway', + 'meal_delivery', +]; + +type NearbyPlacesInput = { + latitude: number; + longitude: number; + radiusMeters: number; +}; + +type GoogleNearbyPlace = { + id?: string; + displayName?: { + text?: string; + }; + location?: { + latitude?: number; + longitude?: number; + }; +}; + +type GoogleNearbyResponse = { + places?: GoogleNearbyPlace[]; +}; function serializeVoiceExperience( experience: Awaited>[number], @@ -27,6 +61,86 @@ export async function listPlaces() { })); } +export async function listNearbyPlaces(input: NearbyPlacesInput) { + if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) { + throw new Error('Nearby place search requires a valid coordinate.'); + } + if ( + !Number.isInteger(input.radiusMeters) || + input.radiusMeters <= 0 || + input.radiusMeters > maxNearbyRadiusMeters + ) { + 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.'); + } + + const response = await fetch(googleNearbySearchUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': config.googlePlacesApiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.location', + ].join(','), + }, + body: JSON.stringify({ + includedTypes: nearbyPlaceTypes, + maxResultCount: 20, + rankPreference: 'DISTANCE', + locationRestriction: { + circle: { + center: { + latitude: input.latitude, + longitude: input.longitude, + }, + radius: input.radiusMeters, + }, + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Google Nearby Search failed: ${response.status} ${body}`); + } + + 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; + } + + 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); +} + export async function listVoiceExperiences() { const experiences = await prisma.voiceExperience.findMany({ include: { place: true, user: true }, @@ -36,3 +150,27 @@ export async function listVoiceExperiences() { return experiences.map(serializeVoiceExperience); } + +function distanceMeters( + fromLatitude: number, + fromLongitude: number, + toLatitude: number, + toLongitude: number, +) { + const earthRadiusMeters = 6371000; + const fromLat = degreesToRadians(fromLatitude); + const toLat = degreesToRadians(toLatitude); + const deltaLat = degreesToRadians(toLatitude - fromLatitude); + const deltaLon = degreesToRadians(toLongitude - fromLongitude); + const a = + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(fromLat) * + Math.cos(toLat) * + Math.sin(deltaLon / 2) * + Math.sin(deltaLon / 2); + return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function degreesToRadians(value: number) { + return (value * Math.PI) / 180; +} diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index a1972f6..9d8145e 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -9,7 +9,11 @@ import { createTelegramBotLogin, getTelegramBotLoginStatus, } from '../auth/telegram-bot-login.js'; -import { listPlaces, listVoiceExperiences } from './places.js'; +import { + listNearbyPlaces, + listPlaces, + listVoiceExperiences, +} from './places.js'; import { createVoiceExperience } from './voice-experiences.js'; export type GraphqlContext = { @@ -70,6 +74,12 @@ export const schema = /* GraphQL */ ` audioObjectKey: String! } + input NearbyPlacesInput { + latitude: Float! + longitude: Float! + radiusMeters: Int! + } + input AuthenticateTelegramInput { initData: String! } @@ -105,6 +115,7 @@ export const schema = /* GraphQL */ ` me: User! telegramBotLoginStatus(token: String!): TelegramBotLoginStatus! places: [Place!]! + nearbyPlaces(input: NearbyPlacesInput!): [Place!]! voiceExperiences: [VoiceExperience!]! } @@ -131,6 +142,15 @@ export const resolvers = { await requireTelegramUser(graphqlContext); return listPlaces(); }, + nearbyPlaces: async ( + _: unknown, + args: { input: Parameters[0] }, + context: unknown, + ) => { + const graphqlContext = context as GraphqlContext; + await requireTelegramUser(graphqlContext); + return listNearbyPlaces(args.input); + }, voiceExperiences: async (_: unknown, __: unknown, context: unknown) => { const graphqlContext = context as GraphqlContext; await requireTelegramUser(graphqlContext);