Persist Google nearby places
All checks were successful
Build and deploy Backend / build (push) Successful in 26s

This commit is contained in:
Ruslan Bakiev
2026-05-09 16:05:50 +07:00
parent 9384a42e39
commit 4d5aa433e8

View File

@@ -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<ReturnType<typeof prisma.voiceExperience.findMany>>[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<string, PersistableGooglePlace>();
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,
};
}