mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
chore: move maps backend to proper DI
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { findByIata, searchAirports } from '../../services/airportService';
|
||||
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
@@ -9,62 +8,13 @@ import {
|
||||
} from './_shared';
|
||||
import { canRead } from '../scopes';
|
||||
|
||||
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
export function registerMapsWeatherTools(server: McpServer, _userId: number, scopes: string[] | null): void {
|
||||
const canGeo = canRead(scopes, 'geo');
|
||||
const canWeather = canRead(scopes, 'weather');
|
||||
|
||||
// --- MAPS EXTRAS ---
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'get_place_details',
|
||||
{
|
||||
description: 'Fetch detailed information about a place by its Google Place ID.',
|
||||
inputSchema: {
|
||||
placeId: z.string().describe('Google Place ID'),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ placeId, lang }) => {
|
||||
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
|
||||
if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true };
|
||||
return ok({ details });
|
||||
}
|
||||
);
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'reverse_geocode',
|
||||
{
|
||||
description: 'Get a human-readable address for given coordinates.',
|
||||
inputSchema: {
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ lat, lng, lang }) => {
|
||||
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true };
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'resolve_maps_url',
|
||||
{
|
||||
description: 'Resolve a Google Maps share URL to coordinates and place name.',
|
||||
inputSchema: {
|
||||
url: z.string().describe('Google Maps share URL'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await resolveGoogleMapsUrl(url);
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
// The geo tools (get_place_details, reverse_geocode, resolve_maps_url) moved to
|
||||
// the DI-discovered src/nest/maps/maps.mcp.ts (@McpController, attached via the
|
||||
// nest-mcp registry).
|
||||
|
||||
// --- WEATHER ---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../nest/assignments/assignments.bridge';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import { searchPlaces } from '../../nest/maps/maps.bridge';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
|
||||
@@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
|
||||
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
|
||||
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
|
||||
collab, vacay, reservations, day, permissions, audit, budget, trip — see the
|
||||
migration recipe below.
|
||||
collab, vacay, reservations, day, permissions, audit, budget, trip, maps — see
|
||||
the migration recipe below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -223,8 +223,21 @@ serves the legacy prompts registrar and budget.mcp.ts's owner/member seam
|
||||
were deleted with their last consumers and the unused days/budget bridge
|
||||
exports pruned; the controller adopted `trips.dto.ts` — all seven allow-list
|
||||
entries removed).
|
||||
mapsService followed (the 1429-line geo core — Google Places, Nominatim,
|
||||
Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the
|
||||
wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to
|
||||
`maps.helpers.ts` as plain exports (files.constants/client-ip precedent —
|
||||
transitService's User-Agent imports from there, not a bridge), the module-scoped
|
||||
POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped
|
||||
on purpose (permissions-cache precedent: the bridge instance and the DI
|
||||
singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts`
|
||||
registrar for the decorator-driven `maps.mcp.ts` (the registrar file survives —
|
||||
its weather + airport tools await their own migrations); BookingImportService
|
||||
injects `MapsService` for its Nominatim geocoding, and a 3-export
|
||||
`maps.bridge.ts` serves the legacy placeEnrichment helper and the places
|
||||
registrar until the place domain migrates).
|
||||
Repeat these steps per
|
||||
service (next up: the **mapsService → transitService → placeService** chain —
|
||||
service (next up: **transitService → placeService → transitItineraryService** —
|
||||
or the notifications fan-in — per the dependency-honest order in
|
||||
`migration-graph.md`). This is a
|
||||
**pure relocation** — byte-identical
|
||||
|
||||
@@ -9,9 +9,10 @@ import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule],
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createPlace } from '../../services/placeService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { User } from '../../types';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
@@ -28,6 +28,7 @@ export class BookingImportService {
|
||||
private readonly budget: BudgetService,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly maps: MapsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -160,7 +161,7 @@ export class BookingImportService {
|
||||
].filter((q): q is string => !!q);
|
||||
|
||||
for (const q of queries) {
|
||||
const results = await searchNominatim(q);
|
||||
const results = await this.maps.searchNominatim(q);
|
||||
const hit = results[0];
|
||||
if (hit?.lat != null && hit?.lng != null) {
|
||||
lat = hit.lat;
|
||||
@@ -192,7 +193,7 @@ export class BookingImportService {
|
||||
for (const ep of reservationData.endpoints) {
|
||||
if ((ep.lat == null || ep.lng == null) && ep.name) {
|
||||
try {
|
||||
const hit = (await searchNominatim(ep.name))[0];
|
||||
const hit = (await this.maps.searchNominatim(ep.name))[0];
|
||||
if (hit?.lat != null && hit?.lng != null) {
|
||||
ep.lat = hit.lat;
|
||||
ep.lng = hit.lng;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { MapsService } from './maps.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the maps domain — for code running OUTSIDE the Nest
|
||||
* container (the legacy placeService's placeEnrichment helper and the legacy
|
||||
* places MCP registrar in src/mcp/tools/places.ts; the geo MCP tools moved to
|
||||
* the DI-discovered maps.mcp.ts, and transitService's User-Agent comes from the
|
||||
* pure maps.helpers.ts). Exports only the legacy services/mapsService names
|
||||
* still consumed outside the container, 1:1, so repointing a consumer is an
|
||||
* import-path-only diff. Inside the container, inject MapsService instead.
|
||||
* Delete this file when the place domain migrates.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const maps = new MapsService(new DatabaseService(db));
|
||||
|
||||
export function getMapsKey(userId: number) {
|
||||
return maps.getMapsKey(userId);
|
||||
}
|
||||
|
||||
export function searchPlaces(
|
||||
userId: number,
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
) {
|
||||
return maps.searchPlaces(userId, query, lang, locationBias);
|
||||
}
|
||||
|
||||
export function getPlacePhoto(userId: number, placeId: string, lat: number, lng: number, name?: string) {
|
||||
return maps.getPlacePhoto(userId, placeId, lat, lng, name);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
|
||||
/**
|
||||
* Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env.
|
||||
*
|
||||
* Moved verbatim from the legacy services/mapsService.ts when the maps domain
|
||||
* went DI-native. Kept as plain functions (files.constants.ts / client-ip.ts
|
||||
* precedent) because they are consumed both by MapsService and by plain-module
|
||||
* code outside the container (transitService's User-Agent, the unit suites'
|
||||
* parser cases).
|
||||
*/
|
||||
|
||||
// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that
|
||||
// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited
|
||||
// and throttled harder (see #1309). When the instance URL is configured we append it;
|
||||
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
|
||||
export function buildUserAgent(instanceUrl: string | undefined): string {
|
||||
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
|
||||
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
|
||||
return base;
|
||||
}
|
||||
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
|
||||
export const UA = buildUserAgent(getAppUrl());
|
||||
|
||||
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||||
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||||
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||||
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
||||
// opening hours come back in the wrong language. Codes not listed here pass
|
||||
// through unchanged (they are already valid), as do locale forms the client
|
||||
// sometimes sends (e.g. 'pt-BR').
|
||||
const API_LANG_OVERRIDES: Record<string, string> = {
|
||||
br: 'pt-BR',
|
||||
gr: 'el',
|
||||
'el-GR': 'el',
|
||||
};
|
||||
export function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
||||
const code = (lang || '').trim();
|
||||
if (!code) return fallback;
|
||||
return API_LANG_OVERRIDES[code] ?? code;
|
||||
}
|
||||
|
||||
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
|
||||
|
||||
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
|
||||
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
|
||||
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
|
||||
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
|
||||
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass POI categories ──────────────────────────────────────────────────
|
||||
|
||||
export interface OverpassPoi {
|
||||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||||
address: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
source: 'openstreetmap';
|
||||
}
|
||||
|
||||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||||
// client-side).
|
||||
export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: [
|
||||
'tourism=attraction',
|
||||
'tourism=viewpoint',
|
||||
'historic=monument',
|
||||
'historic=castle',
|
||||
'historic=memorial',
|
||||
'historic=ruins',
|
||||
],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||||
const DEFAULT_OVERPASS_MIRRORS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
|
||||
// Operators behind locked-down egress — or running their own Overpass — can point TREK
|
||||
// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it
|
||||
// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a
|
||||
// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped.
|
||||
export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] {
|
||||
const custom = (raw ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
|
||||
}
|
||||
|
||||
// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case
|
||||
// wait before every mirror is given up on and a 502 is returned. Public mirrors answer
|
||||
// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a
|
||||
// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or
|
||||
// non-numeric value falls back to the default — a 0/negative cap would abort every
|
||||
// request immediately and 502 the search.
|
||||
export function resolveOverpassTimeoutMs(raw?: string): number {
|
||||
if (raw === undefined) return readEnv().integrations.overpassTimeoutMs;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : 12000;
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
|
||||
export interface GoogleTimePoint {
|
||||
day?: number;
|
||||
hour?: number;
|
||||
minute?: number;
|
||||
}
|
||||
|
||||
export interface GoogleOpeningHours {
|
||||
weekdayDescriptions?: string[];
|
||||
openNow?: boolean;
|
||||
periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[];
|
||||
specialDays?: { date?: { year?: number; month?: number; day?: number } }[];
|
||||
}
|
||||
|
||||
// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday.
|
||||
// The weekday descriptions next to them are display text in the requested language and
|
||||
// cannot be parsed reliably; these can. A period without `close` is Google's way of
|
||||
// saying the place never closes.
|
||||
export interface OpeningTimePoint {
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
}
|
||||
export interface OpeningPeriod {
|
||||
open: OpeningTimePoint;
|
||||
close: OpeningTimePoint | null;
|
||||
}
|
||||
|
||||
// Places (New) speaks proto3 JSON, which leaves out fields that hold the default
|
||||
// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes.
|
||||
function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null {
|
||||
if (!point || typeof point !== 'object') return null;
|
||||
const field = (value: unknown): number | null => {
|
||||
if (value === undefined || value === null) return 0;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
|
||||
};
|
||||
const day = field(point.day);
|
||||
const hour = field(point.hour);
|
||||
const minute = field(point.minute);
|
||||
if (day === null || hour === null || minute === null) return null;
|
||||
if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return { day, hour, minute };
|
||||
}
|
||||
|
||||
export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return null;
|
||||
const result: OpeningPeriod[] = [];
|
||||
for (const period of periods) {
|
||||
const open = toTimePoint(period?.open);
|
||||
if (!open) continue;
|
||||
// A missing close means "never closes"; a close that is there but unusable makes
|
||||
// the whole period a guess, so it is dropped rather than read as round-the-clock.
|
||||
const close = period?.close == null ? null : toTimePoint(period.close);
|
||||
if (period?.close != null && !close) continue;
|
||||
result.push({ open, close });
|
||||
}
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
// Holidays and other exceptional days that the weekly periods do not describe. Google
|
||||
// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can
|
||||
// compare them against the place's own local date.
|
||||
export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null {
|
||||
if (!Array.isArray(specialDays) || specialDays.length === 0) return null;
|
||||
const dates: string[] = [];
|
||||
for (const entry of specialDays) {
|
||||
const date = entry?.date;
|
||||
if (!date) continue;
|
||||
const { year, month, day } = date;
|
||||
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) continue;
|
||||
const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (!dates.includes(iso)) dates.push(iso);
|
||||
}
|
||||
return dates.length > 0 ? dates : null;
|
||||
}
|
||||
|
||||
// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses.
|
||||
const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g;
|
||||
|
||||
/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */
|
||||
function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] {
|
||||
const day = (dayIdx + 1) % 7;
|
||||
const periods: OpeningPeriod[] = [];
|
||||
for (const match of timePart.matchAll(OSM_TIME_RANGE)) {
|
||||
const openHour = parseInt(match[1], 10);
|
||||
const openMinute = parseInt(match[2], 10);
|
||||
let closeHour = parseInt(match[3], 10);
|
||||
const closeMinute = parseInt(match[4], 10);
|
||||
if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue;
|
||||
// OSM writes the end of a day as 24:00, a clock reading Google's numbering has no
|
||||
// hour 24 — that is midnight of the following day, same as any range that wraps.
|
||||
let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute;
|
||||
if (closeHour === 24) {
|
||||
if (closeMinute !== 0) continue;
|
||||
closeHour = 0;
|
||||
nextDay = true;
|
||||
}
|
||||
periods.push({
|
||||
open: { day, hour: openHour, minute: openMinute },
|
||||
close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute },
|
||||
});
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
export function parseOpeningHours(ohString: string): {
|
||||
weekdayDescriptions: string[];
|
||||
openNow: boolean | null;
|
||||
periods: OpeningPeriod[];
|
||||
} {
|
||||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const result: string[] = LONG.map((d) => `${d}: ?`);
|
||||
const periods: OpeningPeriod[] = [];
|
||||
|
||||
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
||||
for (const segment of ohString.split(';')) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(
|
||||
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [, daysPart, timePart] = match;
|
||||
const dayIndices = new Set<number>();
|
||||
for (const range of daysPart.split(',')) {
|
||||
const parts = range
|
||||
.trim()
|
||||
.split('-')
|
||||
.map((d) => DAYS.indexOf(d.trim()));
|
||||
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
||||
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
|
||||
dayIndices.add(parts[1]);
|
||||
} else if (parts[0] >= 0) {
|
||||
dayIndices.add(parts[0]);
|
||||
}
|
||||
}
|
||||
for (const idx of dayIndices) {
|
||||
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
|
||||
periods.push(...osmPeriods(idx, timePart));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute openNow
|
||||
let openNow: boolean | null = null;
|
||||
try {
|
||||
const now = new Date();
|
||||
const jsDay = now.getDay();
|
||||
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
|
||||
const todayLine = result[dayIdx];
|
||||
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
||||
if (timeRanges.length > 0) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
openNow = timeRanges.some((m) => {
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||||
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
return { weekdayDescriptions: result, openNow, periods };
|
||||
}
|
||||
|
||||
// ── Build standardized OSM details ───────────────────────────────────────────
|
||||
|
||||
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
|
||||
let opening_hours: string[] | null = null;
|
||||
let open_now: boolean | null = null;
|
||||
let opening_periods: OpeningPeriod[] | null = null;
|
||||
if (tags.opening_hours) {
|
||||
const parsed = parseOpeningHours(tags.opening_hours);
|
||||
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
|
||||
if (hasData) {
|
||||
opening_hours = parsed.weekdayDescriptions;
|
||||
open_now = parsed.openNow;
|
||||
opening_periods = parsed.periods.length > 0 ? parsed.periods : null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
website: tags['contact:website'] || tags.website || null,
|
||||
phone: tags['contact:phone'] || tags.phone || null,
|
||||
opening_hours,
|
||||
open_now,
|
||||
opening_periods,
|
||||
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
|
||||
summary: tags.description || null,
|
||||
source: 'openstreetmap' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Place-id classification ──────────────────────────────────────────────────
|
||||
|
||||
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
|
||||
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
|
||||
// collection views send), OSM ids the client sends when a place has no
|
||||
// google_place_id, and the raw photo URLs legacy rows keep in image_url. Google
|
||||
// answers those with a billable 400 INVALID_ARGUMENT, so every lookup sorts them
|
||||
// out before the call and uses the OSM/Wikimedia path instead.
|
||||
const NON_GOOGLE_PLACE_ID = /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/i;
|
||||
// The subset that still has a provider behind it — Overpass for details,
|
||||
// Wikimedia for photos.
|
||||
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
|
||||
|
||||
export function isGooglePlaceId(placeId: string): boolean {
|
||||
return !NON_GOOGLE_PLACE_ID.test(placeId);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { MapsService } from './maps.service';
|
||||
|
||||
/**
|
||||
* Geo MCP tools, moved 1:1 from the legacy registrar in
|
||||
* src/mcp/tools/mapsWeather.ts when the maps domain went DI-native (the weather
|
||||
* and airport tools stay there until their services migrate). Same names,
|
||||
* descriptions, input schemas and result shapes; the legacy registration-time
|
||||
* `canRead(scopes, 'geo')` gate became the declarative `access` marker ('geo'
|
||||
* is a read-only scope group — there is no geo:write).
|
||||
*/
|
||||
@McpController()
|
||||
export class MapsMcp {
|
||||
constructor(private readonly maps: MapsService) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_place_details',
|
||||
description: 'Fetch detailed information about a place by its Google Place ID.',
|
||||
inputSchema: {
|
||||
placeId: z.string().describe('Google Place ID'),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
})
|
||||
async getPlaceDetails({ placeId, lang }: { placeId: string; lang?: string }, ctx: McpContext) {
|
||||
const details = await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en');
|
||||
if (!details) return errorResult('Place not found or maps service not configured.');
|
||||
return ok({ details });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'reverse_geocode',
|
||||
description: 'Get a human-readable address for given coordinates.',
|
||||
inputSchema: {
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
})
|
||||
async reverseGeocode({ lat, lng, lang }: { lat: number; lng: number; lang?: string }, _ctx: McpContext) {
|
||||
const result = await this.maps.reverseGeocode(String(lat), String(lng), lang ?? 'en');
|
||||
if (!result) return errorResult('Reverse geocode failed or maps service not configured.');
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'resolve_maps_url',
|
||||
description: 'Resolve a Google Maps share URL to coordinates and place name.',
|
||||
inputSchema: {
|
||||
url: z.string().describe('Google Maps share URL'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
})
|
||||
async resolveMapsUrl({ url }: { url: string }, _ctx: McpContext) {
|
||||
const result = await this.maps.resolveGoogleMapsUrl(url);
|
||||
if (!result) return errorResult('Could not resolve URL or maps service not configured.');
|
||||
return ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MapsController } from './maps.controller';
|
||||
import { MapsService } from './maps.service';
|
||||
import { MapsMcp } from './maps.mcp';
|
||||
|
||||
/** Maps / geo domain (L3 leaf module). Registered in AppModule. */
|
||||
/**
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
|
||||
* MapsService for the in-container consumers (BookingImportModule's Nominatim
|
||||
* geocoding); out-of-container code goes through maps.bridge.ts.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService],
|
||||
providers: [MapsService, MapsMcp],
|
||||
exports: [MapsService],
|
||||
})
|
||||
export class MapsModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from '../nest/maps/maps.bridge';
|
||||
|
||||
/**
|
||||
* Background enrichment for list-imported places (#886).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readEnv } from '../app-config';
|
||||
import { buildUserAgent } from './mapsService';
|
||||
import { buildUserAgent } from '../nest/maps/maps.helpers';
|
||||
import { getAppUrl } from '../app-config';
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user