Split place list and detail queries
Build and deploy Backend / build (push) Successful in 1m13s

This commit is contained in:
Ruslan Bakiev
2026-06-22 14:03:13 +07:00
parent 440796d0b4
commit d1f563925f
4 changed files with 149 additions and 59 deletions
+128 -49
View File
@@ -1,24 +1,24 @@
import { prisma } from '../prisma.js'; import { prisma } from "../prisma.js";
import { config } from '../config.js'; import { config } from "../config.js";
import type { Prisma } from '../generated/prisma/client.js'; import type { Prisma } from "../generated/prisma/client.js";
import { classifyPlaceIntent } from '../ai/place-intent.js'; import { classifyPlaceIntent } from "../ai/place-intent.js";
import { transcribeAudioContent } from '../audio/deepgram.js'; import { transcribeAudioContent } from "../audio/deepgram.js";
import { import {
fetchGooglePlaceDetails, fetchGooglePlaceDetails,
normalizeGooglePlaceDetails, normalizeGooglePlaceDetails,
type GooglePlacePhoto, type GooglePlacePhoto,
} from '../google/places.js'; } from "../google/places.js";
const googleNearbySearchUrl = const googleNearbySearchUrl =
'https://places.googleapis.com/v1/places:searchNearby'; "https://places.googleapis.com/v1/places:searchNearby";
const maxNearbyRadiusMeters = 500; const maxNearbyRadiusMeters = 500;
const nearbyPlaceTypes = [ const nearbyPlaceTypes = [
'restaurant', "restaurant",
'cafe', "cafe",
'bar', "bar",
'bakery', "bakery",
'meal_takeaway', "meal_takeaway",
'meal_delivery', "meal_delivery",
]; ];
type NearbyPlacesInput = { type NearbyPlacesInput = {
@@ -69,8 +69,28 @@ type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
}; };
}>; }>;
type PlaceSummary = Prisma.PlaceGetPayload<{
select: {
id: true;
googlePlaceId: true;
name: true;
latitude: true;
longitude: true;
googlePrimaryType: true;
googleTypes: true;
favoriteUsers: true;
experiences: {
select: {
analysis: true;
};
};
};
}>;
function serializeVoiceExperience( function serializeVoiceExperience(
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number], experience: Awaited<
ReturnType<typeof prisma.voiceExperience.findMany>
>[number],
) { ) {
return { return {
...experience, ...experience,
@@ -96,25 +116,70 @@ function serializePlace(place: PlaceWithRecentExperiences, userId?: string) {
}; };
} }
function serializePlaceSummary(place: PlaceSummary, userId: string) {
return {
...place,
googleBusinessStatus: null,
googleRating: null,
googleUserRatingCount: null,
googleRegularOpeningHours: null,
googleCurrentOpeningHours: null,
googlePayload: null,
googlePhotos: null,
photoUrls: [],
photoAttributions: [],
traits: placeTraits(place),
isFavorite: place.favoriteUsers.some(
(favorite) => favorite.userId === userId,
),
experiences: [],
};
}
export async function listPlaces(userId: string) { export async function listPlaces(userId: string) {
const places = await prisma.place.findMany({ const places = await prisma.place.findMany({
where: { where: {
experiences: { some: {} }, experiences: { some: {} },
}, },
include: { select: {
id: true,
googlePlaceId: true,
name: true,
latitude: true,
longitude: true,
googlePrimaryType: true,
googleTypes: true,
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, select: { analysis: true },
}, },
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: "desc" },
}); });
await enrichPlacesMissingGooglePayload(places); return places.map((place) => serializePlaceSummary(place, userId));
const refreshedPlaces = await loadPlacesByIds(places.map((place) => place.id)); }
return refreshedPlaces.map((place) => serializePlace(place, userId));
export async function getPlace(placeId: string, userId: string) {
const place = await prisma.place.findUnique({
where: { id: placeId },
select: {
id: true,
googlePlaceId: true,
name: true,
latitude: true,
longitude: true,
googlePayloadFetchedAt: true,
},
});
if (!place) {
throw new Error(`Place ${placeId} was not found.`);
}
await enrichPlacesMissingGooglePayload([place]);
return loadSerializedPlace(placeId, userId);
} }
export async function listFavoritePlaces(userId: string) { export async function listFavoritePlaces(userId: string) {
@@ -125,14 +190,14 @@ export async function listFavoritePlaces(userId: string) {
include: { include: {
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, include: { user: true },
}, },
}, },
}, },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}); });
return favorites.map((favorite) => serializePlace(favorite.place, userId)); return favorites.map((favorite) => serializePlace(favorite.place, userId));
@@ -143,7 +208,7 @@ export async function listNearbyPlaces(
userId: string, userId: string,
) { ) {
if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) { if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) {
throw new Error('Nearby place search requires a valid coordinate.'); throw new Error("Nearby place search requires a valid coordinate.");
} }
if ( if (
!Number.isInteger(input.radiusMeters) || !Number.isInteger(input.radiusMeters) ||
@@ -154,27 +219,29 @@ export async function listNearbyPlaces(
`Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`, `Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`,
); );
} }
if (config.googlePlacesApiKey === '') { if (config.googlePlacesApiKey === "") {
throw new Error('GOOGLE_PLACES_API_KEY is required for nearby place search.'); throw new Error(
"GOOGLE_PLACES_API_KEY is required for nearby place search.",
);
} }
const response = await fetch(googleNearbySearchUrl, { const response = await fetch(googleNearbySearchUrl, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-Goog-Api-Key': config.googlePlacesApiKey, "X-Goog-Api-Key": config.googlePlacesApiKey,
'X-Goog-FieldMask': [ "X-Goog-FieldMask": [
'places.id', "places.id",
'places.displayName', "places.displayName",
'places.location', "places.location",
'places.primaryType', "places.primaryType",
'places.types', "places.types",
].join(','), ].join(","),
}, },
body: JSON.stringify({ body: JSON.stringify({
includedTypes: nearbyPlaceTypes, includedTypes: nearbyPlaceTypes,
maxResultCount: 20, maxResultCount: 20,
rankPreference: 'DISTANCE', rankPreference: "DISTANCE",
locationRestriction: { locationRestriction: {
circle: { circle: {
center: { center: {
@@ -203,7 +270,7 @@ export async function listNearbyPlaces(
include: { include: {
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, include: { user: true },
}, },
@@ -224,7 +291,7 @@ export async function listNearbyPlaces(
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 },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 100, take: 100,
}); });
@@ -244,12 +311,12 @@ export async function recommendPlacesByVoice(
include: { include: {
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, include: { user: true },
}, },
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: "desc" },
take: 50, take: 50,
}); });
@@ -379,7 +446,7 @@ async function loadSerializedPlace(placeId: string, userId: string) {
include: { include: {
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, include: { user: true },
}, },
@@ -401,12 +468,12 @@ async function loadPlacesByIds(placeIds: string[]) {
include: { include: {
favoriteUsers: true, favoriteUsers: true,
experiences: { experiences: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { user: true }, include: { user: true },
}, },
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: "desc" },
}); });
const order = new Map(placeIds.map((id, index) => [id, index])); const order = new Map(placeIds.map((id, index) => [id, index]));
return places.sort((left, right) => { return places.sort((left, right) => {
@@ -415,7 +482,17 @@ async function loadPlacesByIds(placeIds: string[]) {
} }
async function enrichPlacesMissingGooglePayload( async function enrichPlacesMissingGooglePayload(
places: Array<Pick<PlaceWithRecentExperiences, 'id' | 'googlePlaceId' | 'name' | 'latitude' | 'longitude' | 'googlePayloadFetchedAt'>>, places: Array<
Pick<
PlaceWithRecentExperiences,
| "id"
| "googlePlaceId"
| "name"
| "latitude"
| "longitude"
| "googlePayloadFetchedAt"
>
>,
) { ) {
for (const place of places) { for (const place of places) {
if (place.googlePayloadFetchedAt) { if (place.googlePayloadFetchedAt) {
@@ -473,7 +550,7 @@ export async function enrichPlaceFromGoogle(input: {
function googlePhotoUrls(value: Prisma.JsonValue | null) { function googlePhotoUrls(value: Prisma.JsonValue | null) {
return googlePhotos(value).map((photo) => { return googlePhotos(value).map((photo) => {
return `${config.publicApiUrl}/google/place-photo?name=${encodeURIComponent(photo.name ?? '')}&maxWidthPx=900`; return `${config.publicApiUrl}/google/place-photo?name=${encodeURIComponent(photo.name ?? "")}&maxWidthPx=900`;
}); });
} }
@@ -491,7 +568,9 @@ function googlePhotos(value: Prisma.JsonValue | null): GooglePlacePhoto[] {
}); });
} }
function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) { function placeTraits(place: {
experiences: Array<{ analysis: Prisma.JsonValue | null }>;
}) {
const tags = new Set<string>(); const tags = new Set<string>();
for (const experience of place.experiences) { for (const experience of place.experiences) {
const analysis = jsonObject(experience.analysis); const analysis = jsonObject(experience.analysis);
@@ -500,7 +579,7 @@ function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) {
continue; continue;
} }
for (const tag of analysisTags) { for (const tag of analysisTags) {
if (typeof tag === 'string') { if (typeof tag === "string") {
tags.add(tag); tags.add(tag);
} }
} }
@@ -541,7 +620,7 @@ function rankPlacesByVoiceTags(
} }
function jsonObject(value: unknown): Record<string, unknown> | null { function jsonObject(value: unknown): Record<string, unknown> | null {
if (!value || Array.isArray(value) || typeof value !== 'object') { if (!value || Array.isArray(value) || typeof value !== "object") {
return null; return null;
} }
return value as Record<string, unknown>; return value as Record<string, unknown>;
+13 -7
View File
@@ -1,26 +1,27 @@
import { GraphQLJSONObject } from './scalars.js'; import { GraphQLJSONObject } from "./scalars.js";
import { typeDefs } from './type-defs.js'; import { typeDefs } from "./type-defs.js";
import { import {
getOrCreateTelegramLoginUser, getOrCreateTelegramLoginUser,
getOrCreateTelegramUser, getOrCreateTelegramUser,
requireAdminTelegramUser, requireAdminTelegramUser,
requireTelegramUser, requireTelegramUser,
type TelegramLoginData, type TelegramLoginData,
} from '../auth/telegram.js'; } from "../auth/telegram.js";
import { import {
completeTelegramBotLogin, completeTelegramBotLogin,
createTelegramBotLogin, createTelegramBotLogin,
} from '../auth/telegram-bot-login.js'; } from "../auth/telegram-bot-login.js";
import { import {
addFavoritePlace, addFavoritePlace,
getPlace,
listFavoritePlaces, listFavoritePlaces,
listNearbyPlaces, listNearbyPlaces,
listPlaces, listPlaces,
listVoiceExperiences, listVoiceExperiences,
recommendPlacesByVoice, recommendPlacesByVoice,
removeFavoritePlace, removeFavoritePlace,
} from './places.js'; } from "./places.js";
import { createVoiceExperience } from './voice-experiences.js'; import { createVoiceExperience } from "./voice-experiences.js";
export type GraphqlContext = { export type GraphqlContext = {
telegramInitData?: string; telegramInitData?: string;
@@ -33,7 +34,7 @@ export const schema = typeDefs;
export const resolvers = { export const resolvers = {
JSON: GraphQLJSONObject, JSON: GraphQLJSONObject,
Query: { Query: {
health: () => 'ok', health: () => "ok",
me: async (_: unknown, __: unknown, context: unknown) => { me: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
return requireTelegramUser(graphqlContext); return requireTelegramUser(graphqlContext);
@@ -43,6 +44,11 @@ export const resolvers = {
const user = await requireTelegramUser(graphqlContext); const user = await requireTelegramUser(graphqlContext);
return listPlaces(user.id); return listPlaces(user.id);
}, },
place: async (_: unknown, args: { id: string }, context: unknown) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return getPlace(args.id, user.id);
},
favoritePlaces: async (_: unknown, __: unknown, context: unknown) => { favoritePlaces: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext); const user = await requireTelegramUser(graphqlContext);
+7 -2
View File
@@ -124,6 +124,7 @@ export const typeDefs = /* GraphQL */ `
health: String! health: String!
me: User! me: User!
places: [Place!]! places: [Place!]!
place(id: ID!): Place!
favoritePlaces: [Place!]! favoritePlaces: [Place!]!
nearbyPlaces(input: NearbyPlacesInput!): [Place!]! nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
voiceExperiences: [VoiceExperience!]! voiceExperiences: [VoiceExperience!]!
@@ -133,9 +134,13 @@ export const typeDefs = /* GraphQL */ `
startTelegramBotLogin: TelegramBotLoginPayload! startTelegramBotLogin: TelegramBotLoginPayload!
completeTelegramBotLogin(token: String!): TelegramBotLoginSession! completeTelegramBotLogin(token: String!): TelegramBotLoginSession!
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload! authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload! authenticateTelegramLogin(
input: AuthenticateTelegramLoginInput!
): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience! createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
recommendPlacesByVoice(input: VoicePlaceRecommendationsInput!): VoicePlaceRecommendationsPayload! recommendPlacesByVoice(
input: VoicePlaceRecommendationsInput!
): VoicePlaceRecommendationsPayload!
addFavoritePlace(placeId: ID!): Place! addFavoritePlace(placeId: ID!): Place!
removeFavoritePlace(placeId: ID!): Place! removeFavoritePlace(placeId: ID!): Place!
} }