mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
This commit is contained in:
@@ -5430,6 +5430,16 @@ function runMigrations(db: Database.Database): void {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[];
|
||||
if (!cols.some(c => c.name === 'status_override')) db.exec('ALTER TABLE journeys ADD COLUMN status_override TEXT');
|
||||
},
|
||||
/**
|
||||
* A place's e-mail and its own opening hours, typed in by hand (#2472): the
|
||||
* search fills in what it knows, and these are for everything it does not.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as { name: string }[];
|
||||
const has = (c: string) => cols.some(col => col.name === c);
|
||||
if (!has('email')) db.exec('ALTER TABLE places ADD COLUMN email TEXT');
|
||||
if (!has('opening_hours')) db.exec('ALTER TABLE places ADD COLUMN opening_hours TEXT');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
mapsSearchRequestSchema,
|
||||
placeImageUrlSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeEmailField,
|
||||
placeOpeningHoursSchema,
|
||||
placeWebsiteSchema,
|
||||
roadtripStopTypeSchema,
|
||||
type PlaceOpeningHours,
|
||||
roadtripGpxImportSchema,
|
||||
type RoadtripGpxImport,
|
||||
type RoadtripStopType,
|
||||
@@ -48,6 +51,9 @@ function parseId(value: string | string[]): number | null {
|
||||
* DaysService), but the service itself never did, so its own module stays off
|
||||
* the loop and PlacesModule can import it.
|
||||
*/
|
||||
/** Hours as the column keeps them: JSON text, or null to clear (#2472). */
|
||||
const hoursText = (hours: PlaceOpeningHours | null | undefined): string | null => (hours ? JSON.stringify(hours) : null);
|
||||
|
||||
@McpController()
|
||||
export class PlacesMcp {
|
||||
constructor(
|
||||
@@ -78,6 +84,8 @@ export class PlacesMcp {
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
|
||||
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
@@ -87,10 +95,11 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createPlace(
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }: {
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours, image_url, price, currency, stop_type }: {
|
||||
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string;
|
||||
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
|
||||
notes?: string; website?: string; phone?: string; email?: string | null; opening_hours?: PlaceOpeningHours | null;
|
||||
image_url?: string; price?: number; currency?: string;
|
||||
stop_type?: RoadtripStopType;
|
||||
},
|
||||
ctx: McpContext,
|
||||
@@ -98,7 +107,7 @@ export class PlacesMcp {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -122,6 +131,8 @@ export class PlacesMcp {
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
|
||||
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
@@ -132,10 +143,11 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createAndAssignPlace(
|
||||
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, place_notes, website, phone, image_url, assignment_notes, price, currency, stop_type }: {
|
||||
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, place_notes, website, phone, email, opening_hours, image_url, assignment_notes, price, currency, stop_type }: {
|
||||
tripId: number; dayId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string;
|
||||
place_notes?: string; website?: string; phone?: string; image_url?: string; assignment_notes?: string;
|
||||
place_notes?: string; website?: string; phone?: string; email?: string | null; opening_hours?: PlaceOpeningHours | null;
|
||||
image_url?: string; assignment_notes?: string;
|
||||
price?: number; currency?: string; stop_type?: RoadtripStopType;
|
||||
},
|
||||
ctx: McpContext,
|
||||
@@ -146,7 +158,7 @@ export class PlacesMcp {
|
||||
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
|
||||
const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
@@ -179,6 +191,8 @@ export class PlacesMcp {
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
|
||||
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
|
||||
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to remove the current picture'),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
@@ -192,10 +206,11 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async updatePlace(
|
||||
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }: {
|
||||
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }: {
|
||||
tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number;
|
||||
address?: string; category_id?: number; price?: number; currency?: string; place_time?: string | null;
|
||||
end_time?: string | null; duration_minutes?: number | null; notes?: string; website?: string; phone?: string;
|
||||
email?: string | null; opening_hours?: PlaceOpeningHours | null;
|
||||
image_url?: string | null;
|
||||
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string;
|
||||
google_place_id?: string; google_ftid?: string; amap_poi_id?: string; stop_type?: RoadtripStopType | null;
|
||||
@@ -206,7 +221,7 @@ export class PlacesMcp {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours: opening_hours === undefined ? undefined : hoursText(opening_hours), image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface PlaceCreateInput {
|
||||
place_time?: string; end_time?: string;
|
||||
duration_minutes?: number; notes?: string; image_url?: string;
|
||||
google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string;
|
||||
email?: string | null; opening_hours?: string | null;
|
||||
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
|
||||
@@ -95,6 +96,7 @@ export interface PlaceUpdateInput {
|
||||
place_time?: string; end_time?: string;
|
||||
duration_minutes?: number; notes?: string; image_url?: string;
|
||||
google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string;
|
||||
email?: string | null; opening_hours?: string | null;
|
||||
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
|
||||
@@ -276,6 +278,7 @@ export class PlacesService {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone,
|
||||
email, opening_hours,
|
||||
transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [],
|
||||
} = body;
|
||||
|
||||
@@ -283,8 +286,8 @@ export class PlacesService {
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone, transport_mode,
|
||||
route_geometry, route_color, stop_type, fill_percent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
route_geometry, route_color, stop_type, fill_percent, email, opening_hours)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
|
||||
// 0 is a legitimate value for all four (Null Island, a free entry, a
|
||||
@@ -303,6 +306,7 @@ export class PlacesService {
|
||||
// `?? null` rather than `|| null`, the same reason lat/lng have it: the column is a
|
||||
// percentage and the falsy check would be a silent floor.
|
||||
fill_percent ?? null,
|
||||
email?.trim() || null, opening_hours || null,
|
||||
);
|
||||
|
||||
const placeId = result.lastInsertRowid;
|
||||
@@ -367,6 +371,7 @@ export class PlacesService {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone,
|
||||
email, opening_hours,
|
||||
transport_mode, route_color, stop_type, fill_percent, tags,
|
||||
} = body;
|
||||
|
||||
@@ -391,6 +396,8 @@ export class PlacesService {
|
||||
amap_poi_id = ?,
|
||||
website = ?,
|
||||
phone = ?,
|
||||
email = ?,
|
||||
opening_hours = ?,
|
||||
transport_mode = COALESCE(?, transport_mode),
|
||||
route_color = ?,
|
||||
stop_type = ?,
|
||||
@@ -421,6 +428,9 @@ export class PlacesService {
|
||||
amap_poi_id !== undefined ? amap_poi_id : existingPlace.amap_poi_id,
|
||||
website !== undefined ? website : existingPlace.website,
|
||||
phone !== undefined ? phone : existingPlace.phone,
|
||||
// Empty clears, like null: the form sends what its field holds (#2472).
|
||||
email !== undefined ? (email?.trim() || null) : existingPlace.email,
|
||||
opening_hours !== undefined ? (opening_hours || null) : existingPlace.opening_hours,
|
||||
transport_mode || null,
|
||||
// Deliberately not COALESCE: an explicit null is how the picker resets a
|
||||
// track back to its category colour (#776).
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface Place {
|
||||
route_color?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
/** Typed in by hand (#2472). */
|
||||
email?: string | null;
|
||||
/** Hand-kept opening hours as JSON text, seven days Monday first (#2472). */
|
||||
opening_hours?: string | null;
|
||||
transport_mode?: string;
|
||||
/** What kind of stop this is on a drive (#1797); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user