mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
Use Google Maps feature IDs for place map links
This commit is contained in:
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
|
|||||||
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
||||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
interface DayPlanSidebarProps {
|
interface DayPlanSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -1603,14 +1604,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
}}
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={e => {
|
||||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
||||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
ctxMenu.open(e, [
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||||
{ divider: true },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||||
])}
|
{ divider: true },
|
||||||
|
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||||
|
])
|
||||||
|
}}
|
||||||
onMouseEnter={e => {
|
onMouseEnter={e => {
|
||||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
@@ -2296,4 +2300,4 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default DayPlanSidebar
|
export default DayPlanSidebar
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface PlaceFormData {
|
|||||||
// Populated from a maps-search pick (not part of the initial blank form).
|
// Populated from a maps-search pick (not part of the initial blank form).
|
||||||
phone?: string
|
phone?: string
|
||||||
google_place_id?: string
|
google_place_id?: string
|
||||||
|
google_ftid?: string
|
||||||
osm_id?: string
|
osm_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
address: resolved.address || prev.address,
|
address: resolved.address || prev.address,
|
||||||
lat: String(resolved.lat),
|
lat: String(resolved.lat),
|
||||||
lng: String(resolved.lng),
|
lng: String(resolved.lng),
|
||||||
|
google_ftid: resolved.google_ftid || prev.google_ftid,
|
||||||
}))
|
}))
|
||||||
setMapsResults([])
|
setMapsResults([])
|
||||||
setMapsSearch('')
|
setMapsSearch('')
|
||||||
@@ -241,6 +242,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: result.lat || prev.lat,
|
lat: result.lat || prev.lat,
|
||||||
lng: result.lng || prev.lng,
|
lng: result.lng || prev.lng,
|
||||||
google_place_id: result.google_place_id || prev.google_place_id,
|
google_place_id: result.google_place_id || prev.google_place_id,
|
||||||
|
google_ftid: result.google_ftid || prev.google_ftid,
|
||||||
osm_id: result.osm_id || prev.osm_id,
|
osm_id: result.osm_id || prev.osm_id,
|
||||||
website: result.website || prev.website,
|
website: result.website || prev.website,
|
||||||
phone: result.phone || prev.phone,
|
phone: result.phone || prev.phone,
|
||||||
|
|||||||
@@ -618,6 +618,22 @@ describe('PlaceInspector', () => {
|
|||||||
expect(mapsBtn).toBeTruthy();
|
expect(mapsBtn).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
render(<PlaceInspector {...defaultProps} place={buildPlace({
|
||||||
|
name: "St. Jacobs Farmers' Market",
|
||||||
|
lat: 43.5118527,
|
||||||
|
lng: -80.5542617,
|
||||||
|
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
|
||||||
|
})} />);
|
||||||
|
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
|
||||||
|
await user.click(mapsBtn);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
|
||||||
|
openSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
// ── No files section when no upload handler and no files ──────────────────
|
// ── No files section when no upload handler and no files ──────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
|
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
|
||||||
@@ -686,4 +702,3 @@ describe('PlaceInspector', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
import { formatDistance, formatElevation } from '../../utils/units'
|
import { formatDistance, formatElevation } from '../../utils/units'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -164,6 +165,7 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
const openNow = googleDetails?.open_now ?? null
|
const openNow = googleDetails?.open_now ?? null
|
||||||
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place, googleDetails?.google_maps_url)
|
||||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||||
|
|
||||||
@@ -291,14 +293,10 @@ export default function PlaceInspector({
|
|||||||
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{googleDetails?.google_maps_url && (
|
{googleMapsUrl && (
|
||||||
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||||
)}
|
)}
|
||||||
{!googleDetails?.google_maps_url && place.lat && place.lng && (
|
|
||||||
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
|
||||||
label={<span className="hidden sm:inline">Google Maps</span>} />
|
|
||||||
)}
|
|
||||||
{(place.website || googleDetails?.website) && (
|
{(place.website || googleDetails?.website) && (
|
||||||
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AssignmentPlace, Place } from '../../types'
|
||||||
|
|
||||||
|
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||||
|
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
|
||||||
|
|
||||||
|
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
|
||||||
|
if (!place) return null
|
||||||
|
const ftid = place.google_ftid?.trim()
|
||||||
|
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
|
||||||
|
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
|
||||||
|
}
|
||||||
|
const placeId = place.google_place_id?.trim()
|
||||||
|
if (placeId) {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
|
||||||
|
}
|
||||||
|
if (detailsUrl) return detailsUrl
|
||||||
|
if (place.lat == null || place.lng == null) return null
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
export interface PlacesSidebarProps {
|
export interface PlacesSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -234,11 +235,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
|
|
||||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||||
const selDayId = selectedDayIdRef.current
|
const selDayId = selectedDayIdRef.current
|
||||||
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
||||||
ctxMenu.open(e, [
|
ctxMenu.open(e, [
|
||||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
||||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
|
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -3054,6 +3054,14 @@ function runMigrations(db: Database.Database): void {
|
|||||||
if (!err.message?.includes('duplicate column name')) throw err;
|
if (!err.message?.includes('duplicate column name')) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Store Google Maps feature IDs separately from real Google Places API IDs.
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('duplicate column name')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ function createTables(db: Database.Database): void {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
google_place_id TEXT,
|
google_place_id TEXT,
|
||||||
|
google_ftid TEXT,
|
||||||
website TEXT,
|
website TEXT,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
transport_mode TEXT DEFAULT 'walking',
|
transport_mode TEXT DEFAULT 'walking',
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
|
|||||||
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
|
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
|
||||||
|
|
||||||
**Adding a place to the itinerary (correct order):**
|
**Adding a place to the itinerary (correct order):**
|
||||||
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result.
|
1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
|
||||||
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
|
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
|
||||||
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
|
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
|
||||||
|
|
||||||
@@ -348,4 +348,4 @@ export function closeMcpSessions(): void {
|
|||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimitMap.clear();
|
rateLimitMap.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||||
|
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
@@ -147,7 +148,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||||
@@ -155,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||||
return { place, accommodation };
|
return { place, accommodation };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_place',
|
'create_place',
|
||||||
{
|
{
|
||||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
|
description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -33,6 +33,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||||
|
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
@@ -42,11 +43,11 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
|
||||||
safeBroadcast(tripId, 'place:created', { place });
|
safeBroadcast(tripId, 'place:created', { place });
|
||||||
return ok({ place });
|
return ok({ place });
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||||
|
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
@@ -76,14 +78,14 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||||
return { place, assignment };
|
return { place, assignment };
|
||||||
});
|
});
|
||||||
@@ -121,14 +123,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||||
|
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
},
|
},
|
||||||
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
|
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
|
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
|
||||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||||
safeBroadcast(tripId, 'place:updated', { place });
|
safeBroadcast(tripId, 'place:updated', { place });
|
||||||
return ok({ place });
|
return ok({ place });
|
||||||
@@ -196,7 +199,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (R) server.registerTool(
|
if (R) server.registerTool(
|
||||||
'search_place',
|
'search_place',
|
||||||
{
|
{
|
||||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
|
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
|||||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||||
p.duration_minutes, p.notes as place_notes,
|
p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
@@ -59,6 +59,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
|||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
transport_mode: a.transport_mode,
|
transport_mode: a.transport_mode,
|
||||||
google_place_id: a.google_place_id,
|
google_place_id: a.google_place_id,
|
||||||
|
google_ftid: a.google_ftid,
|
||||||
website: a.website,
|
website: a.website,
|
||||||
phone: a.phone,
|
phone: a.phone,
|
||||||
category: a.category_id ? {
|
category: a.category_id ? {
|
||||||
@@ -79,7 +80,7 @@ export function listDayAssignments(dayId: string | number) {
|
|||||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||||
p.duration_minutes, p.notes as place_notes,
|
p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function getAssignmentsForDay(dayId: number | string) {
|
|||||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||||
p.duration_minutes, p.notes as place_notes,
|
p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
@@ -54,6 +54,7 @@ export function getAssignmentsForDay(dayId: number | string) {
|
|||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
transport_mode: a.transport_mode,
|
transport_mode: a.transport_mode,
|
||||||
google_place_id: a.google_place_id,
|
google_place_id: a.google_place_id,
|
||||||
|
google_ftid: a.google_ftid,
|
||||||
website: a.website,
|
website: a.website,
|
||||||
phone: a.phone,
|
phone: a.phone,
|
||||||
category: a.category_id ? {
|
category: a.category_id ? {
|
||||||
@@ -88,7 +89,7 @@ export function listDays(tripId: string | number) {
|
|||||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||||
p.duration_minutes, p.notes as place_notes,
|
p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface GooglePlaceResult {
|
|||||||
websiteUri?: string;
|
websiteUri?: string;
|
||||||
nationalPhoneNumber?: string;
|
nationalPhoneNumber?: string;
|
||||||
types?: string[];
|
types?: string[];
|
||||||
|
googleMapsUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GoogleAutocompleteSuggestion {
|
interface GoogleAutocompleteSuggestion {
|
||||||
@@ -60,7 +61,6 @@ interface GoogleAutocompleteSuggestion {
|
|||||||
interface GooglePlaceDetails extends GooglePlaceResult {
|
interface GooglePlaceDetails extends GooglePlaceResult {
|
||||||
userRatingCount?: number;
|
userRatingCount?: number;
|
||||||
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
||||||
googleMapsUri?: string;
|
|
||||||
editorialSummary?: { text: string };
|
editorialSummary?: { text: string };
|
||||||
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
||||||
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
||||||
@@ -88,6 +88,18 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
|||||||
return API_LANG_OVERRIDES[code] ?? code;
|
return API_LANG_OVERRIDES[code] ?? code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
|
||||||
|
|
||||||
|
export function googleFtidFromMapsUrl(url?: string | null): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const ftid = new URL(url).searchParams.get('ftid')?.trim();
|
||||||
|
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||||
import * as placePhotoCache from './placePhotoCache';
|
import * as placePhotoCache from './placePhotoCache';
|
||||||
|
|
||||||
@@ -145,6 +157,7 @@ export async function searchNominatim(query: string, lang?: string) {
|
|||||||
const data = await response.json() as NominatimResult[];
|
const data = await response.json() as NominatimResult[];
|
||||||
return data.map(item => ({
|
return data.map(item => ({
|
||||||
google_place_id: null,
|
google_place_id: null,
|
||||||
|
google_ftid: null,
|
||||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
osm_id: `${item.osm_type}:${item.osm_id}`,
|
||||||
name: item.name || item.display_name?.split(',')[0] || '',
|
name: item.name || item.display_name?.split(',')[0] || '',
|
||||||
address: item.display_name || '',
|
address: item.display_name || '',
|
||||||
@@ -573,7 +586,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Goog-Api-Key': apiKey,
|
'X-Goog-Api-Key': apiKey,
|
||||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(searchBody),
|
body: JSON.stringify(searchBody),
|
||||||
});
|
});
|
||||||
@@ -588,6 +601,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
|
|||||||
|
|
||||||
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
||||||
google_place_id: p.id,
|
google_place_id: p.id,
|
||||||
|
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
|
||||||
name: p.displayName?.text || '',
|
name: p.displayName?.text || '',
|
||||||
address: p.formattedAddress || '',
|
address: p.formattedAddress || '',
|
||||||
lat: p.location?.latitude || null,
|
lat: p.location?.latitude || null,
|
||||||
@@ -740,6 +754,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
|
|
||||||
const place = {
|
const place = {
|
||||||
google_place_id: data.id,
|
google_place_id: data.id,
|
||||||
|
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
|
||||||
name: data.displayName?.text || '',
|
name: data.displayName?.text || '',
|
||||||
address: data.formattedAddress || '',
|
address: data.formattedAddress || '',
|
||||||
lat: data.location?.latitude || null,
|
lat: data.location?.latitude || null,
|
||||||
@@ -799,6 +814,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
|
|||||||
|
|
||||||
const place = {
|
const place = {
|
||||||
google_place_id: data.id,
|
google_place_id: data.id,
|
||||||
|
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
|
||||||
name: data.displayName?.text || '',
|
name: data.displayName?.text || '',
|
||||||
address: data.formattedAddress || '',
|
address: data.formattedAddress || '',
|
||||||
lat: data.location?.latitude || null,
|
lat: data.location?.latitude || null,
|
||||||
@@ -983,7 +999,7 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
|
|||||||
|
|
||||||
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
|
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
|
||||||
let resolvedUrl = url;
|
let resolvedUrl = url;
|
||||||
|
|
||||||
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
||||||
@@ -1064,5 +1080,5 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
|
|||||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||||
const address = nominatim.display_name || null;
|
const address = nominatim.display_name || null;
|
||||||
|
|
||||||
return { lat, lng, name, address };
|
return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
|
|||||||
* open/closed). When the importer opts in and a Google Maps key is configured,
|
* open/closed). When the importer opts in and a Google Maps key is configured,
|
||||||
* we re-resolve each place by name — biased to and validated against the
|
* we re-resolve each place by name — biased to and validated against the
|
||||||
* imported coordinates — to a real Google place, then fill in the empty fields
|
* imported coordinates — to a real Google place, then fill in the empty fields
|
||||||
* and persist the resolved `google_place_id` (which is what powers on-demand
|
* and persist the resolved `google_place_id` plus `google_ftid` (which power
|
||||||
* opening hours / the proper Maps link going forward).
|
* on-demand opening hours and proper Maps links going forward).
|
||||||
*
|
*
|
||||||
* This runs detached from the import request (fire-and-forget) so a long list
|
* This runs detached from the import request (fire-and-forget) so a long list
|
||||||
* never blocks the response, and pushes each enriched row over the websocket so
|
* never blocks the response, and pushes each enriched row over the websocket so
|
||||||
@@ -26,6 +26,7 @@ export interface EnrichablePlace {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
google_place_id?: string | null;
|
google_place_id?: string | null;
|
||||||
|
google_ftid?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
@@ -105,18 +106,20 @@ async function enrichOne(tripId: string, userId: number, place: EnrichablePlace,
|
|||||||
|
|
||||||
const gpid = str(match.google_place_id);
|
const gpid = str(match.google_place_id);
|
||||||
if (!gpid) return;
|
if (!gpid) return;
|
||||||
|
const gftid = str(match.google_ftid);
|
||||||
|
|
||||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE places
|
`UPDATE places
|
||||||
SET google_place_id = COALESCE(google_place_id, ?),
|
SET google_place_id = COALESCE(google_place_id, ?),
|
||||||
address = COALESCE(address, ?),
|
google_ftid = COALESCE(google_ftid, ?),
|
||||||
website = COALESCE(website, ?),
|
address = COALESCE(address, ?),
|
||||||
phone = COALESCE(phone, ?),
|
website = COALESCE(website, ?),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
phone = COALESCE(phone, ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND trip_id = ?`,
|
WHERE id = ? AND trip_id = ?`,
|
||||||
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
||||||
|
|
||||||
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
|
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
|
||||||
// that case — a missing photo must never abort the rest of the enrichment.
|
// that case — a missing photo must never abort the rest of the enrichment.
|
||||||
|
|||||||
@@ -123,27 +123,27 @@ export function createPlace(
|
|||||||
category_id?: number; price?: number; currency?: string;
|
category_id?: number; price?: number; currency?: string;
|
||||||
place_time?: string; end_time?: string;
|
place_time?: string; end_time?: string;
|
||||||
duration_minutes?: number; notes?: string; image_url?: string;
|
duration_minutes?: number; notes?: string; image_url?: string;
|
||||||
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
|
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
|
||||||
transport_mode?: string; tags?: number[];
|
transport_mode?: string; tags?: number[];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
name, description, lat, lng, address, category_id, price, currency,
|
name, description, lat, lng, address, category_id, price, currency,
|
||||||
place_time, end_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
|
||||||
transport_mode, tags = [],
|
transport_mode, tags = [],
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||||
place_time, end_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
|
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId, name, description || null, lat || null, lng || null, address || null,
|
tripId, name, description || null, lat || null, lng || null, address || null,
|
||||||
category_id || null, price || null, currency || null,
|
category_id || null, price || null, currency || null,
|
||||||
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
|
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
|
||||||
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
|
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeId = result.lastInsertRowid;
|
const placeId = result.lastInsertRowid;
|
||||||
@@ -180,7 +180,7 @@ export function updatePlace(
|
|||||||
category_id?: number; price?: number; currency?: string;
|
category_id?: number; price?: number; currency?: string;
|
||||||
place_time?: string; end_time?: string;
|
place_time?: string; end_time?: string;
|
||||||
duration_minutes?: number; notes?: string; image_url?: string;
|
duration_minutes?: number; notes?: string; image_url?: string;
|
||||||
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
|
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
|
||||||
transport_mode?: string; tags?: number[];
|
transport_mode?: string; tags?: number[];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -190,7 +190,7 @@ export function updatePlace(
|
|||||||
const {
|
const {
|
||||||
name, description, lat, lng, address, category_id, price, currency,
|
name, description, lat, lng, address, category_id, price, currency,
|
||||||
place_time, end_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
|
||||||
transport_mode, tags,
|
transport_mode, tags,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
@@ -210,6 +210,7 @@ export function updatePlace(
|
|||||||
notes = ?,
|
notes = ?,
|
||||||
image_url = ?,
|
image_url = ?,
|
||||||
google_place_id = ?,
|
google_place_id = ?,
|
||||||
|
google_ftid = ?,
|
||||||
osm_id = ?,
|
osm_id = ?,
|
||||||
website = ?,
|
website = ?,
|
||||||
phone = ?,
|
phone = ?,
|
||||||
@@ -231,6 +232,7 @@ export function updatePlace(
|
|||||||
notes !== undefined ? notes : existingPlace.notes,
|
notes !== undefined ? notes : existingPlace.notes,
|
||||||
image_url !== undefined ? image_url : existingPlace.image_url,
|
image_url !== undefined ? image_url : existingPlace.image_url,
|
||||||
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
|
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
|
||||||
|
google_ftid !== undefined ? google_ftid : existingPlace.google_ftid,
|
||||||
osm_id !== undefined ? osm_id : existingPlace.osm_id,
|
osm_id !== undefined ? osm_id : existingPlace.osm_id,
|
||||||
website !== undefined ? website : existingPlace.website,
|
website !== undefined ? website : existingPlace.website,
|
||||||
phone !== undefined ? phone : existingPlace.phone,
|
phone !== undefined ? phone : existingPlace.phone,
|
||||||
@@ -625,6 +627,65 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
|
|||||||
// Import Google Maps list
|
// Import Google Maps list
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function googleMapsHexId(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||||
|
const raw = String(value).trim();
|
||||||
|
if (/^0x[0-9a-f]+$/i.test(raw)) return raw.toLowerCase();
|
||||||
|
if (!/^-?\d+$/.test(raw)) return null;
|
||||||
|
try {
|
||||||
|
const parsed = BigInt(raw);
|
||||||
|
const unsigned = parsed < 0n ? (1n << 64n) + parsed : parsed;
|
||||||
|
return `0x${unsigned.toString(16)}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleMapsFeatureIdFromItem(item: unknown): string | null {
|
||||||
|
if (!Array.isArray(item)) return null;
|
||||||
|
const candidates = [
|
||||||
|
Array.isArray(item[1]) ? item[1][6] : null,
|
||||||
|
Array.isArray(item[7]) ? item[7][1] : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ids of candidates) {
|
||||||
|
if (!Array.isArray(ids) || ids.length < 2) continue;
|
||||||
|
const first = googleMapsHexId(ids[0]);
|
||||||
|
const second = googleMapsHexId(ids[1]);
|
||||||
|
if (first && second) return `${first}:${second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDuplicatePlace(
|
||||||
|
tripId: string,
|
||||||
|
place: { name: string | null | undefined; lat: number | null; lng: number | null },
|
||||||
|
): { id: number; google_ftid: string | null } | null {
|
||||||
|
const normalizedName = place.name?.trim().toLowerCase();
|
||||||
|
if (normalizedName) {
|
||||||
|
const duplicate = db.prepare(`
|
||||||
|
SELECT id, google_ftid FROM places
|
||||||
|
WHERE trip_id = ? AND lower(trim(name)) = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(tripId, normalizedName) as { id: number; google_ftid: string | null } | undefined;
|
||||||
|
if (duplicate) return duplicate;
|
||||||
|
}
|
||||||
|
if (place.lat != null && place.lng != null) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, google_ftid FROM places
|
||||||
|
WHERE trip_id = ?
|
||||||
|
AND lat IS NOT NULL AND lng IS NOT NULL
|
||||||
|
AND abs(lat - ?) <= ?
|
||||||
|
AND abs(lng - ?) <= ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) as { id: number; google_ftid: string | null } | undefined || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
|
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
|
||||||
let listId: string | null = null;
|
let listId: string | null = null;
|
||||||
let resolvedUrl = url;
|
let resolvedUrl = url;
|
||||||
@@ -689,7 +750,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse place data from items
|
// Parse place data from items
|
||||||
const places: { name: string; lat: number; lng: number; notes: string | null }[] = [];
|
const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const coords = item?.[1]?.[5];
|
const coords = item?.[1]?.[5];
|
||||||
const lat = coords?.[2];
|
const lat = coords?.[2];
|
||||||
@@ -698,7 +759,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
|
|||||||
const note = item?.[3] || null;
|
const note = item?.[3] || null;
|
||||||
|
|
||||||
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
|
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
|
||||||
places.push({ name, lat, lng, notes: note || null });
|
places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,18 +769,23 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
|
|||||||
|
|
||||||
const dedup = buildDedupSet(tripId);
|
const dedup = buildDedupSet(tripId);
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
|
INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, 'walking')
|
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||||
`);
|
`);
|
||||||
|
const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
|
||||||
const created: any[] = [];
|
const created: any[] = [];
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const insertAll = db.transaction(() => {
|
const insertAll = db.transaction(() => {
|
||||||
for (const p of places) {
|
for (const p of places) {
|
||||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||||
|
const duplicate = findDuplicatePlace(tripId, p);
|
||||||
|
if (duplicate && !duplicate.google_ftid && p.googleFtid) {
|
||||||
|
updateGoogleFtidStmt.run(p.googleFtid, duplicate.id);
|
||||||
|
}
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
|
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid);
|
||||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||||
created.push(place);
|
created.push(place);
|
||||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
|
|||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
transport_mode: a.transport_mode,
|
transport_mode: a.transport_mode,
|
||||||
google_place_id: a.google_place_id,
|
google_place_id: a.google_place_id,
|
||||||
|
google_ftid: a.google_ftid,
|
||||||
website: a.website,
|
website: a.website,
|
||||||
phone: a.phone,
|
phone: a.phone,
|
||||||
category: a.category_id ? {
|
category: a.category_id ? {
|
||||||
|
|||||||
@@ -632,14 +632,14 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
|
|||||||
const insertPlace = db.prepare(`
|
const insertPlace = db.prepare(`
|
||||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||||
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
|
duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
for (const p of oldPlaces) {
|
for (const p of oldPlaces) {
|
||||||
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
||||||
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
||||||
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
||||||
p.website, p.phone, p.transport_mode, p.osm_id);
|
p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id);
|
||||||
placeMap.set(p.id, r.lastInsertRowid);
|
placeMap.set(p.id, r.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface Place {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
google_place_id?: string | null;
|
google_place_id?: string | null;
|
||||||
|
google_ftid?: string | null;
|
||||||
osm_id?: string | null;
|
osm_id?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
@@ -323,6 +324,7 @@ export interface AssignmentRow extends DayAssignment {
|
|||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
transport_mode: string;
|
transport_mode: string;
|
||||||
google_place_id: string | null;
|
google_place_id: string | null;
|
||||||
|
google_ftid: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
category_name: string | null;
|
category_name: string | null;
|
||||||
|
|||||||
@@ -751,13 +751,20 @@ describe('searchPlaces (fetch stubbed)', () => {
|
|||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
places: [{ id: 'gid1', displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 } }],
|
places: [{
|
||||||
|
id: 'gid1',
|
||||||
|
displayName: { text: 'Eiffel Tower' },
|
||||||
|
formattedAddress: 'Paris',
|
||||||
|
location: { latitude: 48.8, longitude: 2.3 },
|
||||||
|
googleMapsUri: 'https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x882bf179e806d471:0x8591dde29c821a93',
|
||||||
|
}],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||||||
const result = await searchPlaces(1, 'Eiffel Tower');
|
const result = await searchPlaces(1, 'Eiffel Tower');
|
||||||
expect(result.source).toBe('google');
|
expect(result.source).toBe('google');
|
||||||
expect((result.places[0] as any).google_place_id).toBe('gid1');
|
expect((result.places[0] as any).google_place_id).toBe('gid1');
|
||||||
|
expect((result.places[0] as any).google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
|
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
|
||||||
@@ -813,6 +820,7 @@ describe('searchPlaces (fetch stubbed)', () => {
|
|||||||
const result = await searchPlaces(1, 'sparse');
|
const result = await searchPlaces(1, 'sparse');
|
||||||
const place = result.places[0] as any;
|
const place = result.places[0] as any;
|
||||||
expect(place.google_place_id).toBe('gid-sparse');
|
expect(place.google_place_id).toBe('gid-sparse');
|
||||||
|
expect(place.google_ftid).toBeNull();
|
||||||
expect(place.name).toBe('');
|
expect(place.name).toBe('');
|
||||||
expect(place.address).toBe('');
|
expect(place.address).toBe('');
|
||||||
expect(place.lat).toBeNull();
|
expect(place.lat).toBeNull();
|
||||||
@@ -1082,7 +1090,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'],
|
weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'],
|
||||||
openNow: true,
|
openNow: true,
|
||||||
},
|
},
|
||||||
googleMapsUri: 'https://maps.google.com/?cid=123',
|
googleMapsUri: 'https://maps.google.com/?cid=123&ftid=0x882bf179e806d471:0x8591dde29c821a93',
|
||||||
editorialSummary: { text: 'Iconic iron tower.' },
|
editorialSummary: { text: 'Iconic iron tower.' },
|
||||||
reviews: [
|
reviews: [
|
||||||
{
|
{
|
||||||
@@ -1099,6 +1107,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
const result = await getPlaceDetails(1, 'ChIJ123');
|
const result = await getPlaceDetails(1, 'ChIJ123');
|
||||||
const place = result.place as any;
|
const place = result.place as any;
|
||||||
expect(place.google_place_id).toBe('ChIJ123');
|
expect(place.google_place_id).toBe('ChIJ123');
|
||||||
|
expect(place.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||||
expect(place.name).toBe('Eiffel Tower');
|
expect(place.name).toBe('Eiffel Tower');
|
||||||
expect(place.rating).toBe(4.7);
|
expect(place.rating).toBe(4.7);
|
||||||
expect(place.rating_count).toBe(200000);
|
expect(place.rating_count).toBe(200000);
|
||||||
|
|||||||
@@ -449,6 +449,57 @@ describe('importGoogleList', () => {
|
|||||||
expect(result.places[1].name).toBe('London');
|
expect(result.places[1].name).toBe('London');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const listPayload = [
|
||||||
|
[null, null, null, null, 'My Test List', null, null, null, [
|
||||||
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||||
|
const result = await importGoogleList(String(trip.id), url) as any;
|
||||||
|
|
||||||
|
expect(result.places).toHaveLength(1);
|
||||||
|
expect(result.places[0].google_place_id).toBeNull();
|
||||||
|
expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const existing = createPlace(testDb, trip.id, {
|
||||||
|
name: "St. Jacobs Farmers' Market",
|
||||||
|
lat: 43.5118527,
|
||||||
|
lng: -80.5542617,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const listPayload = [
|
||||||
|
[null, null, null, null, 'My Test List', null, null, null, [
|
||||||
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||||
|
const result = await importGoogleList(String(trip.id), url) as any;
|
||||||
|
const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any;
|
||||||
|
|
||||||
|
expect(result.places).toHaveLength(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(row.google_place_id).toBeNull();
|
||||||
|
expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||||
|
});
|
||||||
|
|
||||||
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
|
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
|
|||||||
image_url: 'https://example.com/img.jpg',
|
image_url: 'https://example.com/img.jpg',
|
||||||
transport_mode: 'walk',
|
transport_mode: 'walk',
|
||||||
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
|
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
|
||||||
|
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
|
||||||
website: 'https://eiffel-tower.com',
|
website: 'https://eiffel-tower.com',
|
||||||
phone: '+33 1 2345 6789',
|
phone: '+33 1 2345 6789',
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -66,6 +67,7 @@ describe('formatAssignmentWithPlace', () => {
|
|||||||
expect(place.image_url).toBe('https://example.com/img.jpg');
|
expect(place.image_url).toBe('https://example.com/img.jpg');
|
||||||
expect(place.transport_mode).toBe('walk');
|
expect(place.transport_mode).toBe('walk');
|
||||||
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
|
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
|
||||||
|
expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0');
|
||||||
expect(place.website).toBe('https://eiffel-tower.com');
|
expect(place.website).toBe('https://eiffel-tower.com');
|
||||||
expect(place.phone).toBe('+33 1 2345 6789');
|
expect(place.phone).toBe('+33 1 2345 6789');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,5 +84,6 @@ export const mapsResolveUrlResultSchema = z.object({
|
|||||||
lng: z.number(),
|
lng: z.number(),
|
||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
address: z.string().nullable(),
|
address: z.string().nullable(),
|
||||||
|
google_ftid: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
|
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const placeSchema = z.object({
|
|||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
image_url: z.string().nullable().optional(),
|
image_url: z.string().nullable().optional(),
|
||||||
google_place_id: z.string().nullable().optional(),
|
google_place_id: z.string().nullable().optional(),
|
||||||
|
google_ftid: z.string().nullable().optional(),
|
||||||
osm_id: z.string().nullable().optional(),
|
osm_id: z.string().nullable().optional(),
|
||||||
route_geometry: z.string().nullable().optional(),
|
route_geometry: z.string().nullable().optional(),
|
||||||
website: z.string().nullable().optional(),
|
website: z.string().nullable().optional(),
|
||||||
@@ -93,6 +94,7 @@ export const assignmentPlaceSchema = z.object({
|
|||||||
image_url: z.string().nullable().optional(),
|
image_url: z.string().nullable().optional(),
|
||||||
transport_mode: z.string().nullable().optional(),
|
transport_mode: z.string().nullable().optional(),
|
||||||
google_place_id: z.string().nullable().optional(),
|
google_place_id: z.string().nullable().optional(),
|
||||||
|
google_ftid: z.string().nullable().optional(),
|
||||||
website: z.string().nullable().optional(),
|
website: z.string().nullable().optional(),
|
||||||
phone: z.string().nullable().optional(),
|
phone: z.string().nullable().optional(),
|
||||||
category: placeCategorySchema.optional(),
|
category: placeCategorySchema.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user