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 { 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
@@ -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);
|
||||||
|
|||||||
@@ -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!
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user