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
@@ -1,4 +1,6 @@
DATABASE_URL=postgresql://mapflow:mapflow@localhost:5432/mapflow
HATCHET_CLIENT_TOKEN=
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
HOST=0.0.0.0
PORT=4000
@@ -0,0 +1,33 @@
-- AlterTable
ALTER TABLE "Place" ADD COLUMN "googleBusinessStatus" TEXT,
ADD COLUMN "googleCurrentOpeningHours" JSONB,
ADD COLUMN "googlePayload" JSONB,
ADD COLUMN "googlePayloadFetchedAt" TIMESTAMP(3),
ADD COLUMN "googlePhotos" JSONB,
ADD COLUMN "googleRating" DOUBLE PRECISION,
ADD COLUMN "googleRegularOpeningHours" JSONB,
ADD COLUMN "googleUserRatingCount" INTEGER;
-- AlterTable
ALTER TABLE "VoiceExperience" ADD COLUMN "promptHintsShown" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "recordingPayload" JSONB;
-- CreateTable
CREATE TABLE "UserFavoritePlace" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"placeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserFavoritePlace_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserFavoritePlace_userId_placeId_key" ON "UserFavoritePlace"("userId", "placeId");
-- AddForeignKey
ALTER TABLE "UserFavoritePlace" ADD CONSTRAINT "UserFavoritePlace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserFavoritePlace" ADD CONSTRAINT "UserFavoritePlace_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+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
}
+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 };