Add place enrichment favorites and AI search
Build and deploy Backend / build (push) Successful in 1m1s
Build and deploy Backend / build (push) Successful in 1m1s
This commit is contained in:
@@ -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
@@ -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'
|
||||
|
||||
Vendored
+2444
-180
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-af7d1a24a80c81a94e157e22f4f12e91cca6099374f7a2346452663b0168688a",
|
||||
"name": "prisma-client-0b8929063537a8b938f0db85082be56f6d3b5317e97858abd029e31273b163bc",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user