This commit is contained in:
+1
-1
Submodule graphql-contracts updated: d694c7d78c...57dcd3aba8
+128
-49
@@ -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
@@ -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);
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user