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 ?? '',
|
telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '',
|
||||||
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
|
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
|
||||||
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
|
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
|
||||||
|
googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '',
|
||||||
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
|
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
|
||||||
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
|
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
|
||||||
telegramAuthMaxAgeSeconds: Number(
|
telegramAuthMaxAgeSeconds: Number(
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
import { prisma } from '../prisma.js';
|
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(
|
function serializeVoiceExperience(
|
||||||
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
|
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() {
|
export async function listVoiceExperiences() {
|
||||||
const experiences = await prisma.voiceExperience.findMany({
|
const experiences = await prisma.voiceExperience.findMany({
|
||||||
include: { place: true, user: true },
|
include: { place: true, user: true },
|
||||||
@@ -36,3 +150,27 @@ export async function listVoiceExperiences() {
|
|||||||
|
|
||||||
return experiences.map(serializeVoiceExperience);
|
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,
|
createTelegramBotLogin,
|
||||||
getTelegramBotLoginStatus,
|
getTelegramBotLoginStatus,
|
||||||
} from '../auth/telegram-bot-login.js';
|
} 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';
|
import { createVoiceExperience } from './voice-experiences.js';
|
||||||
|
|
||||||
export type GraphqlContext = {
|
export type GraphqlContext = {
|
||||||
@@ -70,6 +74,12 @@ export const schema = /* GraphQL */ `
|
|||||||
audioObjectKey: String!
|
audioObjectKey: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input NearbyPlacesInput {
|
||||||
|
latitude: Float!
|
||||||
|
longitude: Float!
|
||||||
|
radiusMeters: Int!
|
||||||
|
}
|
||||||
|
|
||||||
input AuthenticateTelegramInput {
|
input AuthenticateTelegramInput {
|
||||||
initData: String!
|
initData: String!
|
||||||
}
|
}
|
||||||
@@ -105,6 +115,7 @@ export const schema = /* GraphQL */ `
|
|||||||
me: User!
|
me: User!
|
||||||
telegramBotLoginStatus(token: String!): TelegramBotLoginStatus!
|
telegramBotLoginStatus(token: String!): TelegramBotLoginStatus!
|
||||||
places: [Place!]!
|
places: [Place!]!
|
||||||
|
nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
|
||||||
voiceExperiences: [VoiceExperience!]!
|
voiceExperiences: [VoiceExperience!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +142,15 @@ export const resolvers = {
|
|||||||
await requireTelegramUser(graphqlContext);
|
await requireTelegramUser(graphqlContext);
|
||||||
return listPlaces();
|
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) => {
|
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
|
||||||
const graphqlContext = context as GraphqlContext;
|
const graphqlContext = context as GraphqlContext;
|
||||||
await requireTelegramUser(graphqlContext);
|
await requireTelegramUser(graphqlContext);
|
||||||
|
|||||||
Reference in New Issue
Block a user