diff --git a/graphql-contracts b/graphql-contracts index d694c7d..57dcd3a 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit d694c7d78c3e234ae19bf41af6b0e555d048109c +Subproject commit 57dcd3aba861200379c7dc82a403b56d4c5cd40e diff --git a/src/graphql/places.ts b/src/graphql/places.ts index d9607c2..5237362 100644 --- a/src/graphql/places.ts +++ b/src/graphql/places.ts @@ -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>[number], + experience: Awaited< + ReturnType + >[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>, + 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) { +function placeTraits(place: { + experiences: Array<{ analysis: Prisma.JsonValue | null }>; +}) { const tags = new Set(); for (const experience of place.experiences) { const analysis = jsonObject(experience.analysis); @@ -500,7 +579,7 @@ function placeTraits(place: Pick) { 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 | null { - if (!value || Array.isArray(value) || typeof value !== 'object') { + if (!value || Array.isArray(value) || typeof value !== "object") { return null; } return value as Record; diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index 81e40a1..478dc63 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -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); diff --git a/src/graphql/type-defs.ts b/src/graphql/type-defs.ts index 67bc937..8ec1e74 100644 --- a/src/graphql/type-defs.ts +++ b/src/graphql/type-defs.ts @@ -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! }