feat(places): phone, e-mail and opening hours by hand when the search has none (#2472, #2356)

This commit is contained in:
Maurice
2026-10-01 13:42:44 +02:00
parent 8c37597bd7
commit d61d8ca9d7
45 changed files with 726 additions and 22 deletions
+10
View File
@@ -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) {
+23 -8
View File
@@ -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 });
+12 -2
View File
@@ -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).
+4
View File
@@ -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;