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 { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js';
import { classifyPlaceIntent } from '../ai/place-intent.js';
import { transcribeAudioContent } from '../audio/deepgram.js';
import { prisma } from "../prisma.js";
import { config } from "../config.js";
import type { Prisma } from "../generated/prisma/client.js";
import { classifyPlaceIntent } from "../ai/place-intent.js";
import { transcribeAudioContent } from "../audio/deepgram.js";
import {
fetchGooglePlaceDetails,
normalizeGooglePlaceDetails,
type GooglePlacePhoto,
} from '../google/places.js';
} from "../google/places.js";
const googleNearbySearchUrl =
'https://places.googleapis.com/v1/places:searchNearby';
"https://places.googleapis.com/v1/places:searchNearby";
const maxNearbyRadiusMeters = 500;
const nearbyPlaceTypes = [
'restaurant',
'cafe',
'bar',
'bakery',
'meal_takeaway',
'meal_delivery',
"restaurant",
"cafe",
"bar",
"bakery",
"meal_takeaway",
"meal_delivery",
];
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(
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
experience: Awaited<
ReturnType<typeof prisma.voiceExperience.findMany>
>[number],
) {
return {
...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) {
const places = await prisma.place.findMany({
where: {
experiences: { some: {} },
},
include: {
select: {
id: true,
googlePlaceId: true,
name: true,
latitude: true,
longitude: true,
googlePrimaryType: true,
googleTypes: true,
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
select: { analysis: true },
},
},
orderBy: { updatedAt: 'desc' },
orderBy: { updatedAt: "desc" },
});
await enrichPlacesMissingGooglePayload(places);
const refreshedPlaces = await loadPlacesByIds(places.map((place) => place.id));
return refreshedPlaces.map((place) => serializePlace(place, userId));
return places.map((place) => serializePlaceSummary(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) {
@@ -125,14 +190,14 @@ export async function listFavoritePlaces(userId: string) {
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
});
return favorites.map((favorite) => serializePlace(favorite.place, userId));
@@ -143,7 +208,7 @@ export async function listNearbyPlaces(
userId: string,
) {
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 (
!Number.isInteger(input.radiusMeters) ||
@@ -154,27 +219,29 @@ export async function listNearbyPlaces(
`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.');
if (config.googlePlacesApiKey === "") {
throw new Error(
"GOOGLE_PLACES_API_KEY is required for nearby place search.",
);
}
const response = await fetch(googleNearbySearchUrl, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': config.googlePlacesApiKey,
'X-Goog-FieldMask': [
'places.id',
'places.displayName',
'places.location',
'places.primaryType',
'places.types',
].join(','),
"Content-Type": "application/json",
"X-Goog-Api-Key": config.googlePlacesApiKey,
"X-Goog-FieldMask": [
"places.id",
"places.displayName",
"places.location",
"places.primaryType",
"places.types",
].join(","),
},
body: JSON.stringify({
includedTypes: nearbyPlaceTypes,
maxResultCount: 20,
rankPreference: 'DISTANCE',
rankPreference: "DISTANCE",
locationRestriction: {
circle: {
center: {
@@ -203,7 +270,7 @@ export async function listNearbyPlaces(
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
},
@@ -224,7 +291,7 @@ export async function listNearbyPlaces(
export async function listVoiceExperiences() {
const experiences = await prisma.voiceExperience.findMany({
include: { place: true, user: true },
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 100,
});
@@ -244,12 +311,12 @@ export async function recommendPlacesByVoice(
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
orderBy: { updatedAt: "desc" },
take: 50,
});
@@ -379,7 +446,7 @@ async function loadSerializedPlace(placeId: string, userId: string) {
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
},
@@ -401,12 +468,12 @@ async function loadPlacesByIds(placeIds: string[]) {
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
orderBy: { updatedAt: "desc" },
});
const order = new Map(placeIds.map((id, index) => [id, index]));
return places.sort((left, right) => {
@@ -415,7 +482,17 @@ async function loadPlacesByIds(placeIds: string[]) {
}
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) {
if (place.googlePayloadFetchedAt) {
@@ -473,7 +550,7 @@ export async function enrichPlaceFromGoogle(input: {
function googlePhotoUrls(value: Prisma.JsonValue | null) {
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>();
for (const experience of place.experiences) {
const analysis = jsonObject(experience.analysis);
@@ -500,7 +579,7 @@ function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) {
continue;
}
for (const tag of analysisTags) {
if (typeof tag === 'string') {
if (typeof tag === "string") {
tags.add(tag);
}
}
@@ -541,7 +620,7 @@ function rankPlacesByVoiceTags(
}
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 value as Record<string, unknown>;
+13 -7
View File
@@ -1,26 +1,27 @@
import { GraphQLJSONObject } from './scalars.js';
import { typeDefs } from './type-defs.js';
import { GraphQLJSONObject } from "./scalars.js";
import { typeDefs } from "./type-defs.js";
import {
getOrCreateTelegramLoginUser,
getOrCreateTelegramUser,
requireAdminTelegramUser,
requireTelegramUser,
type TelegramLoginData,
} from '../auth/telegram.js';
} from "../auth/telegram.js";
import {
completeTelegramBotLogin,
createTelegramBotLogin,
} from '../auth/telegram-bot-login.js';
} from "../auth/telegram-bot-login.js";
import {
addFavoritePlace,
getPlace,
listFavoritePlaces,
listNearbyPlaces,
listPlaces,
listVoiceExperiences,
recommendPlacesByVoice,
removeFavoritePlace,
} from './places.js';
import { createVoiceExperience } from './voice-experiences.js';
} from "./places.js";
import { createVoiceExperience } from "./voice-experiences.js";
export type GraphqlContext = {
telegramInitData?: string;
@@ -33,7 +34,7 @@ export const schema = typeDefs;
export const resolvers = {
JSON: GraphQLJSONObject,
Query: {
health: () => 'ok',
health: () => "ok",
me: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
return requireTelegramUser(graphqlContext);
@@ -43,6 +44,11 @@ export const resolvers = {
const user = await requireTelegramUser(graphqlContext);
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) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
+7 -2
View File
@@ -124,6 +124,7 @@ export const typeDefs = /* GraphQL */ `
health: String!
me: User!
places: [Place!]!
place(id: ID!): Place!
favoritePlaces: [Place!]!
nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
voiceExperiences: [VoiceExperience!]!
@@ -133,9 +134,13 @@ export const typeDefs = /* GraphQL */ `
startTelegramBotLogin: TelegramBotLoginPayload!
completeTelegramBotLogin(token: String!): TelegramBotLoginSession!
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
authenticateTelegramLogin(
input: AuthenticateTelegramLoginInput!
): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
recommendPlacesByVoice(input: VoicePlaceRecommendationsInput!): VoicePlaceRecommendationsPayload!
recommendPlacesByVoice(
input: VoicePlaceRecommendationsInput!
): VoicePlaceRecommendationsPayload!
addFavoritePlace(placeId: ID!): Place!
removeFavoritePlace(placeId: ID!): Place!
}