Add Google nearby place search
All checks were successful
Build and deploy Backend / build (push) Successful in 29s
All checks were successful
Build and deploy Backend / build (push) Successful in 29s
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<ReturnType<typeof prisma.voiceExperience.findMany>>[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;
|
||||
}
|
||||
|
||||
@@ -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<typeof listNearbyPlaces>[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);
|
||||
|
||||
Reference in New Issue
Block a user