Add place enrichment favorites and AI search
Build and deploy Backend / build (push) Successful in 1m1s

This commit is contained in:
Ruslan Bakiev
2026-06-12 21:08:56 +07:00
parent 80a729fa6e
commit 98bfb032c6
17 changed files with 3313 additions and 236 deletions
+2
View File
@@ -13,6 +13,8 @@ export const config = {
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '',
openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '',
openRouterModel: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
telegramAuthMaxAgeSeconds: Number(
File diff suppressed because one or more lines are too long
+23 -5
View File
@@ -128,6 +128,14 @@ exports.Prisma.PlaceScalarFieldEnum = {
longitude: 'longitude',
googlePrimaryType: 'googlePrimaryType',
googleTypes: 'googleTypes',
googleBusinessStatus: 'googleBusinessStatus',
googleRating: 'googleRating',
googleUserRatingCount: 'googleUserRatingCount',
googleRegularOpeningHours: 'googleRegularOpeningHours',
googleCurrentOpeningHours: 'googleCurrentOpeningHours',
googlePhotos: 'googlePhotos',
googlePayload: 'googlePayload',
googlePayloadFetchedAt: 'googlePayloadFetchedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -145,6 +153,13 @@ exports.Prisma.UserScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserFavoritePlaceScalarFieldEnum = {
id: 'id',
userId: 'userId',
placeId: 'placeId',
createdAt: 'createdAt'
};
exports.Prisma.UserSessionScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
@@ -178,6 +193,8 @@ exports.Prisma.VoiceExperienceScalarFieldEnum = {
status: 'status',
transcript: 'transcript',
analysis: 'analysis',
recordingPayload: 'recordingPayload',
promptHintsShown: 'promptHintsShown',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -197,16 +214,16 @@ exports.Prisma.QueryMode = {
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.JsonNullValueFilter = {
DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
UPLOADED: 'UPLOADED',
TRANSCRIBING: 'TRANSCRIBING',
@@ -219,6 +236,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
exports.Prisma.ModelName = {
Place: 'Place',
User: 'User',
UserFavoritePlace: 'UserFavoritePlace',
UserSession: 'UserSession',
TelegramLoginRequest: 'TelegramLoginRequest',
VoiceExperience: 'VoiceExperience'
+2444 -180
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "prisma-client-af7d1a24a80c81a94e157e22f4f12e91cca6099374f7a2346452663b0168688a",
"name": "prisma-client-0b8929063537a8b938f0db85082be56f6d3b5317e97858abd029e31273b163bc",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",
+33 -10
View File
@@ -17,16 +17,25 @@ enum VoiceExperienceStatus {
}
model Place {
id String @id @default(cuid())
googlePlaceId String @unique
name String
latitude Float
longitude Float
googlePrimaryType String?
googleTypes String[] @default([])
experiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
googlePlaceId String @unique
name String
latitude Float
longitude Float
googlePrimaryType String?
googleTypes String[] @default([])
googleBusinessStatus String?
googleRating Float?
googleUserRatingCount Int?
googleRegularOpeningHours Json?
googleCurrentOpeningHours Json?
googlePhotos Json?
googlePayload Json?
googlePayloadFetchedAt DateTime?
experiences VoiceExperience[]
favoriteUsers UserFavoritePlace[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
@@ -41,10 +50,22 @@ model User {
sessions UserSession[]
loginRequests TelegramLoginRequest[]
voiceExperiences VoiceExperience[]
favoritePlaces UserFavoritePlace[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserFavoritePlace {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
placeId String
place Place @relation(fields: [placeId], references: [id])
createdAt DateTime @default(now())
@@unique([userId, placeId])
}
model UserSession {
id String @id @default(cuid())
tokenHash String @unique
@@ -82,6 +103,8 @@ model VoiceExperience {
status VoiceExperienceStatus @default(UPLOADED)
transcript String?
analysis Json?
recordingPayload Json?
promptHintsShown String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+154
View File
@@ -0,0 +1,154 @@
import { config } from '../config.js';
const googlePlaceDetailsBaseUrl = 'https://places.googleapis.com/v1/places';
export const googlePlaceDetailsFieldMask = [
'id',
'displayName',
'location',
'primaryType',
'types',
'businessStatus',
'rating',
'userRatingCount',
'regularOpeningHours',
'currentOpeningHours',
'photos',
].join(',');
export type GooglePlacePhoto = {
name?: string;
widthPx?: number;
heightPx?: number;
authorAttributions?: Array<{
displayName?: string;
uri?: string;
photoUri?: string;
}>;
};
export type GooglePlaceDetails = {
id?: string;
displayName?: {
text?: string;
};
primaryType?: string;
types?: string[];
businessStatus?: string;
rating?: number;
userRatingCount?: number;
regularOpeningHours?: unknown;
currentOpeningHours?: unknown;
photos?: GooglePlacePhoto[];
location?: {
latitude?: number;
longitude?: number;
};
};
export type GooglePlaceEnrichment = {
name: string;
latitude: number;
longitude: number;
googlePrimaryType: string | null;
googleTypes: string[];
googleBusinessStatus: string | null;
googleRating: number | null;
googleUserRatingCount: number | null;
googleRegularOpeningHours: unknown | null;
googleCurrentOpeningHours: unknown | null;
googlePhotos: GooglePlacePhoto[];
googlePayload: GooglePlaceDetails;
googlePayloadFetchedAt: Date;
};
function assertGooglePlacesKey() {
if (config.googlePlacesApiKey === '') {
throw new Error('GOOGLE_PLACES_API_KEY is required for Google Places.');
}
}
export async function fetchGooglePlaceDetails(
googlePlaceId: string,
): Promise<GooglePlaceDetails> {
assertGooglePlacesKey();
const response = await fetch(
`${googlePlaceDetailsBaseUrl}/${encodeURIComponent(googlePlaceId)}`,
{
headers: {
'X-Goog-Api-Key': config.googlePlacesApiKey,
'X-Goog-FieldMask': googlePlaceDetailsFieldMask,
},
},
);
if (!response.ok) {
const body = await response.text();
throw new Error(`Google Place Details failed: ${response.status} ${body}`);
}
return (await response.json()) as GooglePlaceDetails;
}
export function normalizeGooglePlaceDetails(
fallback: {
name: string;
latitude: number;
longitude: number;
},
payload: GooglePlaceDetails,
): GooglePlaceEnrichment {
return {
name: payload.displayName?.text?.trim() || fallback.name,
latitude: payload.location?.latitude ?? fallback.latitude,
longitude: payload.location?.longitude ?? fallback.longitude,
googlePrimaryType: payload.primaryType ?? null,
googleTypes: payload.types ?? [],
googleBusinessStatus: payload.businessStatus ?? null,
googleRating: payload.rating ?? null,
googleUserRatingCount: payload.userRatingCount ?? null,
googleRegularOpeningHours: payload.regularOpeningHours ?? null,
googleCurrentOpeningHours: payload.currentOpeningHours ?? null,
googlePhotos: (payload.photos ?? [])
.filter((photo) => Boolean(photo.name))
.slice(0, 3),
googlePayload: payload,
googlePayloadFetchedAt: new Date(),
};
}
export async function fetchGooglePlacePhotoMedia(input: {
photoName: string;
maxWidthPx: number;
}) {
assertGooglePlacesKey();
if (
!input.photoName.startsWith('places/') ||
!input.photoName.includes('/photos/')
) {
throw new Error('Google photo name is invalid.');
}
if (
!Number.isInteger(input.maxWidthPx) ||
input.maxWidthPx < 1 ||
input.maxWidthPx > 4800
) {
throw new Error('Google photo maxWidthPx must be from 1 to 4800.');
}
const response = await fetch(
`https://places.googleapis.com/v1/${input.photoName}/media?maxWidthPx=${input.maxWidthPx}`,
{ headers: { 'X-Goog-Api-Key': config.googlePlacesApiKey } },
);
if (!response.ok) {
const body = await response.text();
throw new Error(`Google Place Photo failed: ${response.status} ${body}`);
}
return {
contentType: response.headers.get('content-type') ?? 'image/jpeg',
bytes: Buffer.from(await response.arrayBuffer()),
};
}
+414 -6
View File
@@ -1,6 +1,11 @@
import { prisma } from '../prisma.js';
import { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js';
import {
fetchGooglePlaceDetails,
normalizeGooglePlaceDetails,
type GooglePlacePhoto,
} from '../google/places.js';
const googleNearbySearchUrl =
'https://places.googleapis.com/v1/places:searchNearby';
@@ -20,6 +25,10 @@ type NearbyPlacesInput = {
radiusMeters: number;
};
type SearchPlacesInput = {
query: string;
};
type GoogleNearbyPlace = {
id?: string;
displayName?: {
@@ -48,6 +57,7 @@ type PersistableGooglePlace = {
type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
include: {
favoriteUsers: true;
experiences: {
include: {
user: true;
@@ -56,6 +66,20 @@ type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
};
}>;
type AiSearchResponse = {
choices?: Array<{
finish_reason?: string;
message?: {
content?: string;
};
}>;
};
type AiSearchSelection = {
placeIds: string[];
message: string;
};
function serializeVoiceExperience(
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
) {
@@ -65,19 +89,31 @@ function serializeVoiceExperience(
};
}
function serializePlace(place: PlaceWithRecentExperiences) {
function serializePlace(place: PlaceWithRecentExperiences, userId?: string) {
return {
...place,
googleRegularOpeningHours: place.googleRegularOpeningHours,
googleCurrentOpeningHours: place.googleCurrentOpeningHours,
googlePayload: place.googlePayload,
googlePhotos: place.googlePhotos,
photoUrls: googlePhotoUrls(place.googlePhotos),
photoAttributions: googlePhotoAttributions(place.googlePhotos),
traits: placeTraits(place),
isFavorite:
userId === undefined
? false
: place.favoriteUsers.some((favorite) => favorite.userId === userId),
experiences: place.experiences.map(serializeVoiceExperience),
};
}
export async function listPlaces() {
export async function listPlaces(userId: string) {
const places = await prisma.place.findMany({
where: {
experiences: { some: {} },
},
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
@@ -87,10 +123,36 @@ export async function listPlaces() {
orderBy: { updatedAt: 'desc' },
});
return places.map(serializePlace);
await enrichPlacesMissingGooglePayload(places);
const refreshedPlaces = await loadPlacesByIds(places.map((place) => place.id));
return refreshedPlaces.map((place) => serializePlace(place, userId));
}
export async function listNearbyPlaces(input: NearbyPlacesInput) {
export async function listFavoritePlaces(userId: string) {
const favorites = await prisma.userFavoritePlace.findMany({
where: { userId },
include: {
place: {
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return favorites.map((favorite) => serializePlace(favorite.place, userId));
}
export async function listNearbyPlaces(
input: NearbyPlacesInput,
userId: string,
) {
if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) {
throw new Error('Nearby place search requires a valid coordinate.');
}
@@ -142,9 +204,32 @@ export async function listNearbyPlaces(input: NearbyPlacesInput) {
}
const payload = (await response.json()) as GoogleNearbyResponse;
return parseGoogleNearbyPlaces(input, payload).map((place) =>
serializeGoogleNearbyPlace(place),
const nearbyPlaces = parseGoogleNearbyPlaces(input, payload);
const storedPlaces = await prisma.place.findMany({
where: {
googlePlaceId: {
in: nearbyPlaces.map((place) => place.googlePlaceId),
},
},
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
});
const storedByGoogleId = new Map(
storedPlaces.map((place) => [place.googlePlaceId, place]),
);
return nearbyPlaces.map((place) => {
const stored = storedByGoogleId.get(place.googlePlaceId);
return stored
? serializePlace(stored, userId)
: serializeGoogleNearbyPlace(place);
});
}
export async function listVoiceExperiences() {
@@ -157,6 +242,90 @@ export async function listVoiceExperiences() {
return experiences.map(serializeVoiceExperience);
}
export async function searchPlaces(input: SearchPlacesInput, userId: string) {
const query = input.query.trim();
if (query === '') {
throw new Error('Place search query is required.');
}
const places = await prisma.place.findMany({
where: {
experiences: { some: {} },
},
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
take: 50,
});
if (places.length === 0) {
return { message: '', places: [] };
}
const selection = await selectPlacesWithAi({
query,
places: places.map((place) => ({
id: place.id,
name: place.name,
googlePrimaryType: place.googlePrimaryType,
googleTypes: place.googleTypes,
traits: placeTraits(place),
businessStatus: place.googleBusinessStatus,
rating: place.googleRating,
userRatingCount: place.googleUserRatingCount,
currentOpeningHours: place.googleCurrentOpeningHours,
isFavorite: place.favoriteUsers.some(
(favorite) => favorite.userId === userId,
),
reviewSummaries: place.experiences
.map((experience) => {
const analysis = jsonObject(experience.analysis);
const summary = analysis?.summary;
return typeof summary === 'string' ? summary : null;
})
.filter((summary) => summary !== null),
})),
});
const byId = new Map(places.map((place) => [place.id, place]));
const selectedPlaces = selection.placeIds
.map((id) => byId.get(id))
.filter((place) => place !== undefined);
return {
message: selection.message,
places: selectedPlaces.map((place) => serializePlace(place, userId)),
};
}
export async function addFavoritePlace(placeId: string, userId: string) {
const place = await prisma.place.findUnique({ where: { id: placeId } });
if (!place) {
throw new Error(`Place ${placeId} was not found.`);
}
await prisma.userFavoritePlace.upsert({
where: { userId_placeId: { userId, placeId } },
create: { userId, placeId },
update: {},
});
return loadSerializedPlace(placeId, userId);
}
export async function removeFavoritePlace(placeId: string, userId: string) {
await prisma.userFavoritePlace.deleteMany({
where: { userId, placeId },
});
return loadSerializedPlace(placeId, userId);
}
function distanceMeters(
fromLatitude: number,
fromLongitude: number,
@@ -229,6 +398,245 @@ function serializeGoogleNearbyPlace(place: PersistableGooglePlace) {
longitude: place.longitude,
googlePrimaryType: place.googlePrimaryType,
googleTypes: place.googleTypes,
googleBusinessStatus: null,
googleRating: null,
googleUserRatingCount: null,
googleRegularOpeningHours: null,
googleCurrentOpeningHours: null,
googlePayload: null,
googlePhotos: null,
photoUrls: [],
photoAttributions: [],
traits: [],
isFavorite: false,
experiences: [],
};
}
async function loadSerializedPlace(placeId: string, userId: string) {
const place = await prisma.place.findUnique({
where: { id: placeId },
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
});
if (!place) {
throw new Error(`Place ${placeId} was not found.`);
}
return serializePlace(place, userId);
}
async function loadPlacesByIds(placeIds: string[]) {
if (placeIds.length === 0) {
return [];
}
const places = await prisma.place.findMany({
where: { id: { in: placeIds } },
include: {
favoriteUsers: true,
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
});
const order = new Map(placeIds.map((id, index) => [id, index]));
return places.sort((left, right) => {
return (order.get(left.id) ?? 0) - (order.get(right.id) ?? 0);
});
}
async function enrichPlacesMissingGooglePayload(
places: Array<Pick<PlaceWithRecentExperiences, 'id' | 'googlePlaceId' | 'name' | 'latitude' | 'longitude' | 'googlePayloadFetchedAt'>>,
) {
for (const place of places) {
if (place.googlePayloadFetchedAt) {
continue;
}
await enrichPlaceFromGoogle({
id: place.id,
googlePlaceId: place.googlePlaceId,
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
});
}
}
export async function enrichPlaceFromGoogle(input: {
id: string;
googlePlaceId: string;
name: string;
latitude: number;
longitude: number;
}) {
const payload = await fetchGooglePlaceDetails(input.googlePlaceId);
const enrichment = normalizeGooglePlaceDetails(
{
name: input.name,
latitude: input.latitude,
longitude: input.longitude,
},
payload,
);
return prisma.place.update({
where: { id: input.id },
data: {
name: enrichment.name,
latitude: enrichment.latitude,
longitude: enrichment.longitude,
googlePrimaryType: enrichment.googlePrimaryType,
googleTypes: enrichment.googleTypes,
googleBusinessStatus: enrichment.googleBusinessStatus,
googleRating: enrichment.googleRating,
googleUserRatingCount: enrichment.googleUserRatingCount,
googleRegularOpeningHours:
enrichment.googleRegularOpeningHours as Prisma.InputJsonValue,
googleCurrentOpeningHours:
enrichment.googleCurrentOpeningHours as Prisma.InputJsonValue,
googlePhotos: enrichment.googlePhotos as Prisma.InputJsonValue,
googlePayload: enrichment.googlePayload as Prisma.InputJsonValue,
googlePayloadFetchedAt: enrichment.googlePayloadFetchedAt,
},
});
}
function googlePhotoUrls(value: Prisma.JsonValue | null) {
return googlePhotos(value).map((photo) => {
return `${config.publicApiUrl}/google/place-photo?name=${encodeURIComponent(photo.name ?? '')}&maxWidthPx=900`;
});
}
function googlePhotoAttributions(value: Prisma.JsonValue | null) {
return googlePhotos(value).flatMap((photo) => photo.authorAttributions ?? []);
}
function googlePhotos(value: Prisma.JsonValue | null): GooglePlacePhoto[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is GooglePlacePhoto => {
return jsonObject(item)?.name !== undefined;
});
}
function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) {
const tags = new Set<string>();
for (const experience of place.experiences) {
const analysis = jsonObject(experience.analysis);
const analysisTags = analysis?.tags;
if (!Array.isArray(analysisTags)) {
continue;
}
for (const tag of analysisTags) {
if (typeof tag === 'string') {
tags.add(tag);
}
}
}
return [...tags];
}
function jsonObject(value: unknown): Record<string, unknown> | null {
if (!value || Array.isArray(value) || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
async function selectPlacesWithAi(input: {
query: string;
places: Array<Record<string, unknown>>;
}): Promise<AiSearchSelection> {
if (!config.openRouterApiKey) {
throw new Error('OPENROUTER_API_KEY is required for AI place search.');
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${config.openRouterApiKey}`,
'content-type': 'application/json',
'http-referer': config.webAppUrl,
'x-title': 'MapFlow',
},
body: JSON.stringify({
model: config.openRouterModel,
provider: { require_parameters: true },
temperature: 0.1,
max_tokens: 1200,
response_format: {
type: 'json_schema',
json_schema: {
name: 'place_search',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
required: ['placeIds', 'message'],
properties: {
placeIds: {
type: 'array',
maxItems: 12,
items: { type: 'string' },
},
message: { type: 'string' },
},
},
},
},
messages: [
{
role: 'system',
content:
'You rank known places for a map UI. Use only provided place ids. Return one valid JSON object. Do not invent places.',
},
{
role: 'user',
content: JSON.stringify({
task:
'Select places that best match the user search. Prefer favorites and open places only when relevant to the query.',
query: input.query,
places: input.places,
}),
},
],
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`OpenRouter place search failed with ${response.status}: ${errorBody.slice(0, 500)}`,
);
}
const payload = (await response.json()) as AiSearchResponse;
const choice = payload.choices?.[0];
const content = choice?.message?.content?.trim();
if (!content) {
throw new Error(
`OpenRouter returned an empty place search for ${config.openRouterModel}; finish_reason=${choice?.finish_reason ?? 'missing'}.`,
);
}
const selection = JSON.parse(content) as AiSearchSelection;
if (!Array.isArray(selection.placeIds)) {
throw new Error('OpenRouter place search placeIds must be an array.');
}
if (typeof selection.message !== 'string') {
throw new Error('OpenRouter place search message must be a string.');
}
return selection;
}
+40 -4
View File
@@ -12,9 +12,13 @@ import {
createTelegramBotLogin,
} from '../auth/telegram-bot-login.js';
import {
addFavoritePlace,
listFavoritePlaces,
listNearbyPlaces,
listPlaces,
listVoiceExperiences,
removeFavoritePlace,
searchPlaces,
} from './places.js';
import { createVoiceExperience } from './voice-experiences.js';
@@ -36,8 +40,13 @@ export const resolvers = {
},
places: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext);
return listPlaces();
const user = await requireTelegramUser(graphqlContext);
return listPlaces(user.id);
},
favoritePlaces: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return listFavoritePlaces(user.id);
},
nearbyPlaces: async (
_: unknown,
@@ -45,8 +54,17 @@ export const resolvers = {
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext);
return listNearbyPlaces(args.input);
const user = await requireTelegramUser(graphqlContext);
return listNearbyPlaces(args.input, user.id);
},
searchPlaces: async (
_: unknown,
args: { input: Parameters<typeof searchPlaces>[0] },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return searchPlaces(args.input, user.id);
},
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
@@ -75,5 +93,23 @@ export const resolvers = {
const user = await requireTelegramUser(graphqlContext);
return createVoiceExperience(args.input, user.id);
},
addFavoritePlace: async (
_: unknown,
args: { placeId: string },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return addFavoritePlace(args.placeId, user.id);
},
removeFavoritePlace: async (
_: unknown,
args: { placeId: string },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return removeFavoritePlace(args.placeId, user.id);
},
},
};
+31
View File
@@ -18,6 +18,17 @@ export const typeDefs = /* GraphQL */ `
longitude: Float!
googlePrimaryType: String
googleTypes: [String!]!
googleBusinessStatus: String
googleRating: Float
googleUserRatingCount: Int
googleRegularOpeningHours: JSON
googleCurrentOpeningHours: JSON
googlePayload: JSON
googlePhotos: JSON
photoUrls: [String!]!
photoAttributions: JSON
traits: [String!]!
isFavorite: Boolean!
experiences: [VoiceExperience!]!
}
@@ -41,6 +52,8 @@ export const typeDefs = /* GraphQL */ `
audioObjectKey: String!
transcript: String
analysis: JSON
recordingPayload: JSON
promptHintsShown: [String!]!
createdAt: String!
}
@@ -49,10 +62,15 @@ export const typeDefs = /* GraphQL */ `
googleName: String!
latitude: Float!
longitude: Float!
googlePrimaryType: String
googleTypes: [String!] = []
durationSeconds: Int!
audioObjectKey: String!
audioContentBase64: String!
audioMimeType: String!
addToFavorites: Boolean = false
recordingPayload: JSON
promptHintsShown: [String!] = []
}
input NearbyPlacesInput {
@@ -61,6 +79,10 @@ export const typeDefs = /* GraphQL */ `
radiusMeters: Int!
}
input SearchPlacesInput {
query: String!
}
input AuthenticateTelegramInput {
initData: String!
}
@@ -90,11 +112,18 @@ export const typeDefs = /* GraphQL */ `
user: User!
}
type SearchPlacesPayload {
message: String!
places: [Place!]!
}
type Query {
health: String!
me: User!
places: [Place!]!
favoritePlaces: [Place!]!
nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
searchPlaces(input: SearchPlacesInput!): SearchPlacesPayload!
voiceExperiences: [VoiceExperience!]!
}
@@ -104,5 +133,7 @@ export const typeDefs = /* GraphQL */ `
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
addFavoritePlace(placeId: ID!): Place!
removeFavoritePlace(placeId: ID!): Place!
}
`;
+30 -1
View File
@@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto';
import { enqueueVoiceExperience } from '../hatchet/enqueue-voice-experience.js';
import { prisma } from '../prisma.js';
import { enrichPlaceFromGoogle } from './places.js';
import type { Prisma } from '../generated/prisma/client.js';
const forbiddenGeneratedPlaceIdPrefix = 'manual-';
@@ -10,10 +12,15 @@ export type CreateVoiceExperienceInput = {
googleName: string;
latitude: number;
longitude: number;
googlePrimaryType?: string | null;
googleTypes?: string[];
durationSeconds: number;
audioObjectKey: string;
audioContentBase64: string;
audioMimeType: string;
addToFavorites?: boolean | null;
recordingPayload?: unknown;
promptHintsShown?: string[];
};
function randomAudioAccessToken() {
@@ -49,23 +56,45 @@ export async function createVoiceExperience(
name: googleName,
latitude: input.latitude,
longitude: input.longitude,
googlePrimaryType: input.googlePrimaryType ?? null,
googleTypes: input.googleTypes ?? [],
},
update: {
name: googleName,
latitude: input.latitude,
longitude: input.longitude,
googlePrimaryType: input.googlePrimaryType ?? undefined,
googleTypes: input.googleTypes ?? undefined,
},
});
const enrichedPlace = await enrichPlaceFromGoogle({
id: place.id,
googlePlaceId,
name: googleName,
latitude: input.latitude,
longitude: input.longitude,
});
if (input.addToFavorites === true) {
await prisma.userFavoritePlace.upsert({
where: { userId_placeId: { userId, placeId: enrichedPlace.id } },
create: { userId, placeId: enrichedPlace.id },
update: {},
});
}
const experience = await prisma.voiceExperience.create({
data: {
placeId: place.id,
placeId: enrichedPlace.id,
userId,
durationSeconds: input.durationSeconds,
audioObjectKey: input.audioObjectKey,
audioContentBase64: input.audioContentBase64,
audioMimeType: input.audioMimeType,
audioAccessToken: randomAudioAccessToken(),
recordingPayload: input.recordingPayload as Prisma.InputJsonValue,
promptHintsShown: input.promptHintsShown ?? [],
status: 'UPLOADED',
},
include: { place: true, user: true },
+18
View File
@@ -8,6 +8,7 @@ import {
fetchTelegramPhoto,
handleTelegramBotWebhook,
} from './auth/telegram-bot-login.js';
import { fetchGooglePlacePhotoMedia } from './google/places.js';
const app = Fastify({
logger: true,
@@ -49,6 +50,23 @@ app.get('/telegram/photo/:fileId', async (request, reply) => {
return reply.send(photo.bytes);
});
app.get('/google/place-photo', async (request, reply) => {
const query = request.query as { name?: string; maxWidthPx?: string };
if (!query.name) {
return reply.code(400).send({ error: 'Google photo name is required.' });
}
const photo = await fetchGooglePlacePhotoMedia({
photoName: query.name,
maxWidthPx: Number(query.maxWidthPx ?? '900'),
});
reply.header('content-type', photo.contentType);
reply.header('cache-control', 'public, max-age=86400');
reply.header('access-control-allow-origin', '*');
reply.header('cross-origin-resource-policy', 'cross-origin');
return reply.send(photo.bytes);
});
app.get('/audio/voice-experiences/:experienceId', async (request, reply) => {
const params = request.params as { experienceId: string };
const query = request.query as { token?: string };