mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
feat(tours): add non-motorized day tour planning
This commit is contained in:
@@ -12,6 +12,7 @@ export const ADDON_IDS = {
|
||||
LLM_PARSING: 'llm_parsing',
|
||||
COLLECTIONS: 'collections',
|
||||
ROADTRIP: 'roadtrip',
|
||||
TOURS: 'tours',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
+178
-2
@@ -86,6 +86,115 @@ function createPlaceRegionsFollowPlaceTrigger(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
|
||||
function schemaObjectSql(db: Database.Database, name: string): string | undefined {
|
||||
return (db.prepare('SELECT sql FROM sqlite_master WHERE name = ?').get(name) as { sql: string } | undefined)?.sql;
|
||||
}
|
||||
|
||||
function hasSchemaColumn(db: Database.Database, table: string, column: string): boolean {
|
||||
return !!db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column);
|
||||
}
|
||||
|
||||
function normalizedSchemaSql(sql: string): string {
|
||||
return sql.replace(/IF NOT EXISTS/gi, '').replace(/\s+/g, '').replace(/;$/, '');
|
||||
}
|
||||
|
||||
function matchesSchemaObject(db: Database.Database, name: string, sql: string | undefined): boolean {
|
||||
const actual = schemaObjectSql(db, name);
|
||||
return sql === undefined ? actual === undefined : actual !== undefined
|
||||
&& normalizedSchemaSql(actual) === normalizedSchemaSql(sql);
|
||||
}
|
||||
|
||||
function matchesToursSchema(db: Database.Database, stage: number): boolean {
|
||||
const expected: Record<string, string | undefined> = {
|
||||
tour_types: `CREATE TABLE tour_types (
|
||||
key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL,
|
||||
routing_profile TEXT, is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
tours: stage >= 2 ? `CREATE TABLE tours (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL,
|
||||
elevation_loss REAL, duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL,
|
||||
tour_group_id INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
${stage === 4 ? ', max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)' : ''}
|
||||
)` : undefined,
|
||||
tour_waypoints: stage >= 3 ? `CREATE TABLE tour_waypoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90),
|
||||
lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
|
||||
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')),
|
||||
sequence INTEGER NOT NULL CHECK(sequence >= 0), UNIQUE(place_id, sequence)
|
||||
)` : undefined,
|
||||
idx_tours_tour_type: stage >= 2 ? 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)' : undefined,
|
||||
idx_tour_waypoints_place: stage >= 3
|
||||
? 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)' : undefined,
|
||||
};
|
||||
return Object.entries(expected).every(([name, sql]) => matchesSchemaObject(db, name, sql));
|
||||
}
|
||||
|
||||
function toursLineageRewind(db: Database.Database, version: number): number | undefined {
|
||||
const legacy = version >= 216 && version <= 218;
|
||||
const frozen = version >= 243 && version <= 246;
|
||||
if (!legacy && !frozen) return undefined;
|
||||
const tourTables = ['tour_types', 'tours', 'tour_waypoints'].map(name => !!schemaObjectSql(db, name));
|
||||
const upstreamMarkers = {
|
||||
immich: hasSchemaColumn(db, 'users', 'immich_allow_insecure_tls'),
|
||||
atlasTrigger: !!schemaObjectSql(db, 'trg_place_regions_follow_place'),
|
||||
push: !!schemaObjectSql(db, 'push_subscriptions'),
|
||||
routeExcluded: hasSchemaColumn(db, 'day_assignments', 'route_excluded'),
|
||||
packedQuantity: hasSchemaColumn(db, 'packing_items', 'packed_quantity'),
|
||||
bucketRegion: hasSchemaColumn(db, 'bucket_list', 'region_code'),
|
||||
holidayFraction: hasSchemaColumn(db, 'vacay_company_holidays', 'fraction'),
|
||||
entryDraft: hasSchemaColumn(db, 'journey_entries', 'is_draft'),
|
||||
travelShare: hasSchemaColumn(db, 'share_tokens', 'share_travel_only'),
|
||||
hiddenImages: hasSchemaColumn(db, 'share_tokens', 'share_hide_images'),
|
||||
settlementNote: hasSchemaColumn(db, 'budget_settlements', 'note'),
|
||||
googleUsage: !!schemaObjectSql(db, 'google_api_usage'),
|
||||
templateWeight: hasSchemaColumn(db, 'packing_template_items', 'weight_grams'),
|
||||
templateQuantity: hasSchemaColumn(db, 'packing_template_items', 'quantity'),
|
||||
templateBag: hasSchemaColumn(db, 'packing_template_items', 'bag_name'),
|
||||
journeyStatus: hasSchemaColumn(db, 'journeys', 'status_override'),
|
||||
placeEmail: hasSchemaColumn(db, 'places', 'email'),
|
||||
placeHours: hasSchemaColumn(db, 'places', 'opening_hours'),
|
||||
photoLocation: hasSchemaColumn(db, 'journeys', 'photo_location'),
|
||||
};
|
||||
const legacyMarkers = {
|
||||
countries: !!schemaObjectSql(db, 'school_holiday_countries'),
|
||||
regions: !!schemaObjectSql(db, 'school_holiday_regions'),
|
||||
periods: !!schemaObjectSql(db, 'school_holiday_periods'),
|
||||
endDay: hasSchemaColumn(db, 'day_assignments', 'end_day'),
|
||||
boundaries: !!schemaObjectSql(db, 'roadtrip_day_boundaries'),
|
||||
};
|
||||
const unsafe = (): never => {
|
||||
throw new Error(`[migrations] Unsafe Tours migration lineage at schema version ${version}: `
|
||||
+ JSON.stringify({ tourTables, upstreamMarkers, legacyMarkers })
|
||||
+ '. Cursor and data were not changed; inspect this database before retrying.');
|
||||
};
|
||||
if (!tourTables.some(Boolean)) {
|
||||
const laterUpstreamMarkers = Object.entries(upstreamMarkers)
|
||||
.filter(([name]) => !['immich', 'atlasTrigger', 'push'].includes(name));
|
||||
if (frozen && laterUpstreamMarkers.some(([, present]) => present)) unsafe();
|
||||
if (frozen && (!upstreamMarkers.immich
|
||||
|| (version === 243 && (upstreamMarkers.atlasTrigger || upstreamMarkers.push))
|
||||
|| (version === 244 && !upstreamMarkers.atlasTrigger && !upstreamMarkers.push)
|
||||
|| (version === 245 && !upstreamMarkers.push)
|
||||
|| (version === 246 && (!upstreamMarkers.atlasTrigger || !upstreamMarkers.push)))) unsafe();
|
||||
if (legacy && (Object.values(upstreamMarkers).some(Boolean)
|
||||
|| !legacyMarkers.countries || !legacyMarkers.regions || !legacyMarkers.periods
|
||||
|| (version === 216 && (legacyMarkers.endDay || legacyMarkers.boundaries))
|
||||
|| (version === 217 && legacyMarkers.boundaries)
|
||||
|| (version >= 217 && !legacyMarkers.endDay)
|
||||
|| (version >= 218 && !legacyMarkers.boundaries))) unsafe();
|
||||
return undefined;
|
||||
}
|
||||
if (!matchesToursSchema(db, version - (legacy ? 215 : 242))
|
||||
|| Object.values(upstreamMarkers).some(Boolean)
|
||||
|| (legacy && Object.values(legacyMarkers).some(Boolean))
|
||||
|| (frozen && !Object.values(legacyMarkers).every(Boolean))) unsafe();
|
||||
return legacy ? 215 : 242;
|
||||
}
|
||||
|
||||
function runMigrations(db: Database.Database): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
|
||||
const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined;
|
||||
@@ -5450,10 +5559,66 @@ 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 === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0');
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tour_types (
|
||||
key TEXT PRIMARY KEY,
|
||||
label_key TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
routing_profile TEXT,
|
||||
is_sport INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tours (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
tour_type TEXT NOT NULL REFERENCES tour_types(key),
|
||||
distance REAL,
|
||||
elevation_gain REAL,
|
||||
elevation_loss REAL,
|
||||
duration REAL,
|
||||
difficulty TEXT,
|
||||
wanderer_ref TEXT,
|
||||
match_confidence REAL,
|
||||
tour_group_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)');
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tour_waypoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90),
|
||||
lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
|
||||
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')),
|
||||
sequence INTEGER NOT NULL CHECK(sequence >= 0),
|
||||
UNIQUE(place_id, sequence)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
|
||||
},
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('tours')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'max_hiking_difficulty')) {
|
||||
db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)');
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
for (let i = currentVersion; i < migrations.length; i++) {
|
||||
const rewindVersion = toursLineageRewind(db, currentVersion);
|
||||
if (rewindVersion !== undefined && migrations.slice(rewindVersion).some(step => typeof step !== 'function')) {
|
||||
throw new Error('[migrations] Unsafe Tours migration lineage: bridge replay contains a raw migration');
|
||||
}
|
||||
const applyMigrations = (startVersion: number): void => {
|
||||
for (let i = startVersion; i < migrations.length; i++) {
|
||||
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
|
||||
try {
|
||||
const migration = migrations[i];
|
||||
@@ -5474,9 +5639,20 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
|
||||
if (rewindVersion !== undefined) throw err;
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (rewindVersion !== undefined) {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(rewindVersion);
|
||||
applyMigrations(rewindVersion);
|
||||
})();
|
||||
} else if (currentVersion < migrations.length) {
|
||||
applyMigrations(currentVersion);
|
||||
}
|
||||
if (currentVersion < migrations.length) {
|
||||
console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from a shared Naver Maps list', type: 'integration', icon: 'Link2', enabled: 1, sort_order: 13 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 },
|
||||
{ id: 'tours', name: 'Tours', description: 'Hikes and other single-day tours that follow a route', type: 'trip', icon: 'Mountain', enabled: 0, sort_order: 8 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
||||
{ id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 },
|
||||
@@ -180,10 +181,24 @@ function seedAddons(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// MVP seeds/enables only `hike` — the other controlled tour_types (bike, run, ski,
|
||||
// kayak, walk, food) ship in a later release and are intentionally NOT seeded yet.
|
||||
function seedTourTypes(db: Database.Database): void {
|
||||
try {
|
||||
const insertType = db.prepare(
|
||||
'INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
insertType.run('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0);
|
||||
} catch (err: unknown) {
|
||||
console.error('Error seeding tour types:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
function runSeeds(db: Database.Database): void {
|
||||
seedAdminAccount(db);
|
||||
seedCategories(db);
|
||||
seedAddons(db);
|
||||
seedTourTypes(db);
|
||||
}
|
||||
|
||||
export { runSeeds, seedAdminAccount };
|
||||
|
||||
@@ -272,6 +272,10 @@ export function applyGlobalMiddleware(
|
||||
// document and never reaches the Service Worker that would cache it
|
||||
// (#2180). routing.openstreetmap.de below is a different host.
|
||||
"https://tile.openstreetmap.de", "https://tiles.stadiamaps.com",
|
||||
// OpenTopoMap is the key-free raster layer offered only by the Tours
|
||||
// planner. Leaflet uses its a/b/c shards; the apex is named too so a
|
||||
// future unsharded template does not repeat the OSM CSP gap above.
|
||||
"https://tile.opentopomap.org", "https://*.tile.opentopomap.org",
|
||||
// The imagery host, for the same reason and one more. Leaflet fetches a tile
|
||||
// as an <img>, which img-src's blanket `https:` waves through, so the satellite
|
||||
// view worked on Leaflet with this host missing. A GL map reads the raster
|
||||
|
||||
@@ -38,6 +38,7 @@ import { DayNotesModule } from './day-notes/day-notes.module';
|
||||
import { AccommodationsModule } from './accommodations/accommodations.module';
|
||||
import { AssignmentsModule } from './assignments/assignments.module';
|
||||
import { PlacesModule } from './places/places.module';
|
||||
import { ToursModule } from './tours/tours.module';
|
||||
import { TripsModule } from './trips/trips.module';
|
||||
import { TodoModule } from './todo/todo.module';
|
||||
import { CollabModule } from './collab/collab.module';
|
||||
@@ -84,7 +85,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule],
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, GoogleQuotaModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, ToursModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule],
|
||||
providers: [
|
||||
// Default-deny: a route is authenticated unless it carries @Public() or
|
||||
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
|
||||
|
||||
@@ -100,9 +100,11 @@ export class AssignmentsService {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`, assignmentId);
|
||||
@@ -136,9 +138,11 @@ export class AssignmentsService {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
|
||||
@@ -29,6 +29,8 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
|
||||
accommodation_id: a.accommodation_id ?? null,
|
||||
participants: participants || [],
|
||||
created_at: a.created_at,
|
||||
tour_place_id: a.tour_place_id ?? null,
|
||||
tour_route_geometry: a.tour_route_geometry ?? null,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
|
||||
@@ -109,9 +109,11 @@ export class DaysService {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
@@ -136,6 +138,8 @@ export class DaysService {
|
||||
// a booking wrote, and this copy is one of the paths that feed it.
|
||||
accommodation_id: a.accommodation_id ?? null,
|
||||
created_at: a.created_at,
|
||||
tour_place_id: a.tour_place_id ?? null,
|
||||
tour_route_geometry: a.tour_route_geometry ?? null,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
@@ -195,9 +199,11 @@ export class DaysService {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${dayPlaceholders})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
|
||||
@@ -317,7 +317,7 @@ export class PlacesController {
|
||||
this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (ids.length === 0) {
|
||||
return { deleted: [], count: 0 };
|
||||
return { deleted: [], count: 0, tourPlaceIds: [] };
|
||||
}
|
||||
// Scope the ids to the trip before the hook: onPlaceDeleted keys on the
|
||||
// place id alone, so an id from another trip would detach that trip's
|
||||
@@ -328,7 +328,7 @@ export class PlacesController {
|
||||
for (const id of scoped) this.places.onDeleted(id);
|
||||
// Read the linked expenses before the delete — afterwards the link is gone (#1298).
|
||||
const expenseIds = this.places.linkedExpenseIds(tripId, scoped);
|
||||
const { deleted, cancelled } = await this.places.removeMany(tripId, ids);
|
||||
const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.removeMany(tripId, ids);
|
||||
for (const id of deleted) {
|
||||
this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
@@ -346,7 +346,7 @@ export class PlacesController {
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { deleted, count: deleted.length };
|
||||
return { deleted, count: deleted.length, tourPlaceIds: deletedTourPlaceIds };
|
||||
}
|
||||
|
||||
@Post('bulk-update')
|
||||
@@ -530,7 +530,7 @@ export class PlacesController {
|
||||
}
|
||||
this.places.onDeleted(Number(id));
|
||||
const expenseIds = this.places.linkedExpenseIds(tripId, [id]);
|
||||
const { deleted, cancelled } = await this.places.remove(tripId, id);
|
||||
const { deleted, deletedTourPlaceIds = [], cancelled } = await this.places.remove(tripId, id);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
@@ -545,6 +545,6 @@ export class PlacesController {
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { success: true };
|
||||
return { success: true, tourPlaceIds: deletedTourPlaceIds };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'delete_place',
|
||||
description: 'Delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. Warn the user before calling this on a hotel with a booking: it cannot be undone.',
|
||||
description: 'Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. Warn the user before calling: this cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
@@ -458,7 +458,7 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_delete_places',
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Warn the user before calling this: it cannot be undone.',
|
||||
description: 'Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. Warn the user before calling: this cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { resolveCountryCodeSync } from '../atlas/atlas-geo';
|
||||
import { XMLValidator } from 'fast-xml-parser';
|
||||
import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared';
|
||||
@@ -74,6 +75,14 @@ type Trip = TripAccess;
|
||||
|
||||
type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null };
|
||||
|
||||
export interface PreparedGpxPlace {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
description: string | null;
|
||||
routeGeometry?: string;
|
||||
}
|
||||
|
||||
/** Fields accepted when creating a place. */
|
||||
export interface PlaceCreateInput {
|
||||
name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
@@ -185,9 +194,10 @@ export class PlacesService {
|
||||
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
|
||||
) {
|
||||
let query = `
|
||||
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
pr.country_code as country_code, pr.region_name as region_name
|
||||
FROM places p
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN place_regions pr ON pr.place_id = p.id
|
||||
WHERE p.trip_id = ?
|
||||
@@ -507,37 +517,42 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
async remove(tripId: string, placeId: string): Promise<{ deleted: boolean; cancelled: CancelledStays }> {
|
||||
async remove(tripId: string, placeId: string): Promise<{ deleted: boolean; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> {
|
||||
const place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>(
|
||||
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId,
|
||||
);
|
||||
const cancelled = noCancelledStays();
|
||||
if (!place) return { deleted: false, cancelled };
|
||||
if (!place) return { deleted: false, deletedTourPlaceIds: [], cancelled };
|
||||
let wasTour = false;
|
||||
// The linked expense goes with the place, the same way a booking takes its
|
||||
// expense with it (#1298). One transaction, so a place can never survive
|
||||
// half-detached from its money.
|
||||
this.dbs.transaction(() => {
|
||||
wasTour = !!this.dbs.get('SELECT 1 FROM tours WHERE place_id = ?', placeId);
|
||||
this.cancelStaysAt(tripId, placeId, cancelled);
|
||||
this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId);
|
||||
this.dbs.run('DELETE FROM places WHERE id = ?', placeId);
|
||||
});
|
||||
await reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url);
|
||||
await reclaimPlaceImage(this.storage, place.image_url);
|
||||
return { deleted: true, cancelled };
|
||||
return { deleted: true, deletedTourPlaceIds: wasTour ? [Number(placeId)] : [], cancelled };
|
||||
}
|
||||
|
||||
async removeMany(tripId: string, ids: number[]): Promise<{ deleted: number[]; cancelled: CancelledStays }> {
|
||||
async removeMany(tripId: string, ids: number[]): Promise<{ deleted: number[]; deletedTourPlaceIds: number[]; cancelled: CancelledStays }> {
|
||||
const cancelled = noCancelledStays();
|
||||
if (ids.length === 0) return { deleted: [], cancelled };
|
||||
if (ids.length === 0) return { deleted: [], deletedTourPlaceIds: [], cancelled };
|
||||
const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
|
||||
const selectTourStmt = this.dbs.prepare('SELECT 1 FROM tours WHERE place_id = ?');
|
||||
const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?');
|
||||
const deleted: number[] = [];
|
||||
const deletedTourPlaceIds: number[] = [];
|
||||
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
|
||||
this.dbs.transaction(() => {
|
||||
for (const id of ids) {
|
||||
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!row) continue;
|
||||
if (selectTourStmt.get(id)) deletedTourPlaceIds.push(id);
|
||||
this.cancelStaysAt(tripId, id, cancelled);
|
||||
deleteExpenseStmt.run(tripId, id);
|
||||
deleteStmt.run(id);
|
||||
@@ -550,7 +565,7 @@ export class PlacesService {
|
||||
await reclaimPhotoCache(this.photoCache, row.google_place_id, row.image_url);
|
||||
await reclaimPlaceImage(this.storage, row.image_url);
|
||||
}
|
||||
return { deleted, cancelled };
|
||||
return { deleted, deletedTourPlaceIds, cancelled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -759,11 +774,17 @@ export class PlacesService {
|
||||
}
|
||||
|
||||
private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null {
|
||||
const rows = this.prepareGpxRows(fileBuffer, opts);
|
||||
if (!rows.length) return null;
|
||||
return this.dbs.transaction(() => this.persistGpxRows(tripId, rows));
|
||||
}
|
||||
|
||||
prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): PreparedGpxPlace[] {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
|
||||
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) return null;
|
||||
if (!gpx) return [];
|
||||
|
||||
const str = (v: unknown) => (v != null ? String(v).trim() : null);
|
||||
const num = (v: unknown) => { const n = Number.parseFloat(String(v)); return Number.isNaN(n) ? null : n; };
|
||||
@@ -782,8 +803,7 @@ export class PlacesService {
|
||||
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
||||
};
|
||||
|
||||
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
const waypoints: PreparedGpxPlace[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
if (importWaypoints) {
|
||||
@@ -828,8 +848,18 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
return waypoints;
|
||||
}
|
||||
|
||||
importPreparedGpx(tripId: string, rows: PreparedGpxPlace[]): GpxImportResult {
|
||||
if (!this.dbs.connection.inTransaction) throw new Error('Prepared GPX import requires an active transaction');
|
||||
const result = this.persistGpxRows(tripId, rows);
|
||||
const tracks = result.places.filter(place => place.route_geometry && !place.route_color);
|
||||
this.applyImportedTrackColors(this.dbs.connection, tripId, tracks);
|
||||
return result;
|
||||
}
|
||||
|
||||
private persistGpxRows(tripId: string, waypoints: PreparedGpxPlace[]): GpxImportResult {
|
||||
const dedup = this.buildDedupSet(tripId);
|
||||
const insertStmt = this.dbs.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
||||
@@ -837,18 +867,16 @@ export class PlacesService {
|
||||
`);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
this.dbs.transaction(() => {
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
||||
const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
});
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
||||
const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
}
|
||||
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
@@ -1003,20 +1031,25 @@ export class PlacesService {
|
||||
// Read and write in one transaction so two concurrent imports cannot both
|
||||
// read the same set of free colours.
|
||||
this.dbs.transaction((conn) => {
|
||||
const taken = new Set(
|
||||
(conn
|
||||
.prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL')
|
||||
.all(tripId) as { c: string }[]).map((r) => r.c),
|
||||
);
|
||||
const free = TRACK_COLORS.filter((c) => !taken.has(c));
|
||||
const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?');
|
||||
tracks.forEach((track, i) => {
|
||||
// Free ones first, then wrap through the whole palette — never reuse a
|
||||
// free colour twice within the same import.
|
||||
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
|
||||
stmt.run(color, track.id);
|
||||
track.route_color = color;
|
||||
});
|
||||
this.applyImportedTrackColors(conn, tripId, tracks);
|
||||
});
|
||||
}
|
||||
|
||||
private applyImportedTrackColors(conn: Database.Database, tripId: string, tracks: ImportedPlace[]): void {
|
||||
if (tracks.length === 0) return;
|
||||
const taken = new Set(
|
||||
(conn
|
||||
.prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL')
|
||||
.all(tripId) as { c: string }[]).map((r) => r.c),
|
||||
);
|
||||
const free = TRACK_COLORS.filter((c) => !taken.has(c));
|
||||
const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?');
|
||||
tracks.forEach((track, i) => {
|
||||
// Free ones first, then wrap through the whole palette — never reuse a
|
||||
// free colour twice within the same import.
|
||||
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
|
||||
stmt.run(color, track.id);
|
||||
track.route_color = color;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { tourCreateRequestSchema } from '@trek/shared';
|
||||
|
||||
export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body, Controller, Get, Headers, HttpException, Param, Post, Put, UploadedFile, UseGuards, UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { ToursService } from './tours.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { TourCreateDto } from './dto/tour-create.dto';
|
||||
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/tours: the Tours facet, read and tours-mode GPX import.
|
||||
* TripAccessGuard resolves
|
||||
* :tripId (404 for no access); writes require 'place_edit'. Assignments stay
|
||||
* on the existing day_edit endpoints.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class ToursController {
|
||||
constructor(private readonly tours: ToursService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('tripId') tripId: string) {
|
||||
return { tours: this.tours.listTours(tripId) };
|
||||
}
|
||||
|
||||
@Get(':placeId')
|
||||
detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
|
||||
return this.tours.getTour(tripId, placeId);
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Post()
|
||||
create(
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: TourCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.tours.createTour(tripId, body, socketId);
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Put(':placeId')
|
||||
update(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('placeId') placeId: string,
|
||||
@Body() body: TourCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.tours.updateTour(tripId, placeId, body, socketId);
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Post('import/gpx')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
importGpx(
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const result = this.tours.importGpxAsTour(tripId, file.buffer, file.originalname, socketId);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'No track or route found in GPX file' }, 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pure geometry-metrics helpers for the Tours domain, separate from GPX parsing.
|
||||
* PlacesService prepares and persists imported places; these helpers derive
|
||||
* metrics from the resulting [[lat,lng,ele?]] geometry.
|
||||
*/
|
||||
|
||||
/** One decoded `route_geometry` point: `[lat, lng]` or `[lat, lng, ele]`. */
|
||||
export type GeometryPoint = [number, number] | [number, number, number];
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** Great-circle distance between two points, in km. */
|
||||
function haversineKm(a: GeometryPoint, b: GeometryPoint): number {
|
||||
const [lat1, lng1] = a;
|
||||
const [lat2, lng2] = b;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export interface TourMetrics {
|
||||
/** Sum of segment haversine distances, in km. */
|
||||
distanceKm: number;
|
||||
/** Sum of positive elevation deltas, in metres. Null when the track carries no elevation. */
|
||||
elevationGainM: number | null;
|
||||
/** Sum of negative elevation deltas (as a positive number), in metres. Null when no elevation. */
|
||||
elevationLossM: number | null;
|
||||
/** Whether every point in the geometry carried a 3rd (elevation) value. */
|
||||
hasElevation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives distance/elevation from a parsed `route_geometry` array. Never throws —
|
||||
* A track with fewer than 2 points, or with missing/mixed
|
||||
* elevation, still yields a usable (if partial) result rather than blocking import.
|
||||
*/
|
||||
export function computeTourMetrics(points: GeometryPoint[]): TourMetrics {
|
||||
if (points.length < 2) {
|
||||
return { distanceKm: 0, elevationGainM: null, elevationLossM: null, hasElevation: false };
|
||||
}
|
||||
const hasElevation = points.every(p => p.length === 3 && Number.isFinite(p[2]));
|
||||
|
||||
let distanceKm = 0;
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
distanceKm += haversineKm(points[i - 1], points[i]);
|
||||
if (hasElevation) {
|
||||
const delta = (points[i][2] as number) - (points[i - 1][2] as number);
|
||||
if (delta > 0) gain += delta;
|
||||
else loss += -delta;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
distanceKm,
|
||||
elevationGainM: hasElevation ? gain : null,
|
||||
elevationLossM: hasElevation ? loss : null,
|
||||
hasElevation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `places.route_geometry` JSON string into typed points. Returns an empty
|
||||
* array for null/malformed input rather than throwing — a place created by the
|
||||
* GPX importer without geometry (shouldn't happen for routes/tracks, but never
|
||||
* trust stored JSON blindly) simply yields a zero-metric tour.
|
||||
*/
|
||||
export function parseRouteGeometry(routeGeometry: string | null | undefined): GeometryPoint[] {
|
||||
if (!routeGeometry) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(routeGeometry);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(p): p is GeometryPoint =>
|
||||
Array.isArray(p) && (p.length === 2 || p.length === 3) && p.every((n: unknown) => typeof n === 'number'),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Confidence threshold: below this, the tour imports with a caution flag. */
|
||||
export const LOW_CONFIDENCE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ToursController } from './tours.controller';
|
||||
import { ToursService } from './tours.service';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
/**
|
||||
* Tours domain: an isolated bounded context that imports PlacesModule to reuse
|
||||
* GPX preparation and persistence through PlacesService.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlacesModule, PermissionsModule, AuthModule, AddonsModule],
|
||||
controllers: [ToursController],
|
||||
providers: [ToursService],
|
||||
})
|
||||
export class ToursModule {}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { TourCreateRequest, TourCreateResponse, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { computeTourMetrics, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, type GeometryPoint } from './tours.helpers';
|
||||
|
||||
export interface ImportGpxAsTourResult {
|
||||
tours: TourListItem[];
|
||||
caution: boolean;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
type TourJoinRow = {
|
||||
place_id: number; name: string; tour_type: string; distance: number | null;
|
||||
elevation_gain: number | null; elevation_loss: number | null; duration: number | null;
|
||||
difficulty: string | null; wanderer_ref: string | null; match_confidence: number | null;
|
||||
tour_group_id: number | null; max_hiking_difficulty: number | null; planned: number; has_waypoints: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tours domain: the `tours` facet table (place_id PK/FK) carries tour-specific
|
||||
* metadata while existing places and day assignments are reused.
|
||||
* GPX parsing and persistence are reused through PlacesService.prepareGpxRows
|
||||
* and importPreparedGpx. Places and facet rows are persisted in one transaction,
|
||||
* with tour metrics derived from each place's route_geometry.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ToursService {
|
||||
private readonly logger = new Logger(ToursService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
private toItem(r: TourJoinRow): TourListItem {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
tour_type: r.tour_type as TourListItem['tour_type'],
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
difficulty: r.difficulty,
|
||||
wanderer_ref: r.wanderer_ref,
|
||||
match_confidence: r.match_confidence,
|
||||
tour_group_id: r.tour_group_id,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty ?? 2,
|
||||
planned: Boolean(r.planned),
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
};
|
||||
}
|
||||
|
||||
/** All tours (the facet + owning place) for a trip, newest first. */
|
||||
listTours(tripId: string): TourListItem[] {
|
||||
const rows = this.dbs.all<TourJoinRow>(`
|
||||
SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain,
|
||||
t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence,
|
||||
t.tour_group_id, t.max_hiking_difficulty,
|
||||
EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned,
|
||||
EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) AS has_waypoints
|
||||
FROM tours t
|
||||
JOIN places p ON p.id = t.place_id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY t.created_at DESC
|
||||
`, tripId);
|
||||
return rows.map(r => this.toItem(r));
|
||||
}
|
||||
|
||||
/** One saved tour plus the persisted routing controls needed by the editor. */
|
||||
getTour(tripId: string, placeId: string): TourDetailResponse {
|
||||
const row = this.dbs.get<TourJoinRow>(`
|
||||
SELECT p.id AS place_id, p.name, t.tour_type, t.distance, t.elevation_gain,
|
||||
t.elevation_loss, t.duration, t.difficulty, t.wanderer_ref, t.match_confidence,
|
||||
t.tour_group_id, t.max_hiking_difficulty,
|
||||
EXISTS(SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) AS planned,
|
||||
EXISTS(SELECT 1 FROM tour_waypoints tw WHERE tw.place_id = p.id) AS has_waypoints
|
||||
FROM tours t
|
||||
JOIN places p ON p.id = t.place_id
|
||||
WHERE p.trip_id = ? AND p.id = ?
|
||||
`, tripId, placeId);
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
|
||||
let waypoints = this.dbs.all<TourWaypoint>(`
|
||||
SELECT lat, lng, role, sequence
|
||||
FROM tour_waypoints
|
||||
WHERE place_id = ?
|
||||
ORDER BY sequence
|
||||
`, placeId);
|
||||
// GPX tours created before tour_waypoints existed still belong in this
|
||||
// all-tours rail. A read-only endpoint must not backfill the database, so
|
||||
// expose their saved geometry endpoints as controls; the first edit/save
|
||||
// replaces them with normal persisted tour_waypoints transactionally.
|
||||
if (waypoints.length < 2) {
|
||||
const place = this.dbs.get<{ route_geometry: string | null }>(
|
||||
'SELECT route_geometry FROM places WHERE id = ? AND trip_id = ?', placeId, tripId,
|
||||
);
|
||||
const geometry = parseRouteGeometry(place?.route_geometry);
|
||||
if (geometry.length >= 2) {
|
||||
const start = geometry[0];
|
||||
const end = geometry[geometry.length - 1];
|
||||
waypoints = [
|
||||
{ lat: start[0], lng: start[1], role: 'start', sequence: 0 },
|
||||
{ lat: end[0], lng: end[1], role: 'end', sequence: 1 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return { tour: this.toItem(row), waypoints };
|
||||
}
|
||||
|
||||
/** Create the owning Place, Tours facet, and ordered control points as one write. */
|
||||
createTour(tripId: string, input: TourCreateRequest, socketId?: string): TourCreateResponse {
|
||||
const geometry = input.route_geometry as GeometryPoint[];
|
||||
const metrics = computeTourMetrics(geometry);
|
||||
const start = input.route_geometry[0];
|
||||
let placeId = 0;
|
||||
|
||||
this.dbs.transaction(() => {
|
||||
const place = this.dbs.run(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, 'walking', ?)
|
||||
`, tripId, input.name, start[0], start[1], JSON.stringify(input.route_geometry));
|
||||
placeId = Number(place.lastInsertRowid);
|
||||
|
||||
this.dbs.run(`
|
||||
INSERT INTO tours (
|
||||
place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence,
|
||||
max_hiking_difficulty
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||
`, placeId, input.tour_type, metrics.distanceKm, metrics.elevationGainM,
|
||||
metrics.elevationLossM, input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
|
||||
input.max_hiking_difficulty);
|
||||
|
||||
const insertWaypoint = this.dbs.prepare(`
|
||||
INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const waypoint of input.waypoints) {
|
||||
insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
|
||||
}
|
||||
});
|
||||
|
||||
const tour = this.listTours(tripId).find(item => item.place_id === placeId);
|
||||
const place = this.dbs.getPlaceWithTags(placeId);
|
||||
if (!tour || !place) throw new Error('Created tour could not be loaded');
|
||||
const waypoints = this.dbs.all<TourWaypoint>(`
|
||||
SELECT lat, lng, role, sequence
|
||||
FROM tour_waypoints
|
||||
WHERE place_id = ?
|
||||
ORDER BY sequence
|
||||
`, placeId);
|
||||
|
||||
// Only announce after every row committed. The originating client reloads
|
||||
// explicitly because socket-id exclusion intentionally suppresses its echo.
|
||||
this.broadcastToursChanged(tripId, [placeId], socketId);
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
return { tour, waypoints };
|
||||
}
|
||||
|
||||
/** Replace a saved tour's derived route and routing controls as one write. */
|
||||
updateTour(tripId: string, placeId: string, input: TourCreateRequest, socketId?: string): TourDetailResponse {
|
||||
// The trip predicate is the cross-trip boundary. Do this before any write so
|
||||
// an id from another accessible trip cannot be moved into the current one.
|
||||
this.getTour(tripId, placeId);
|
||||
const geometry = input.route_geometry as GeometryPoint[];
|
||||
const metrics = computeTourMetrics(geometry);
|
||||
const start = input.route_geometry[0];
|
||||
|
||||
this.dbs.transaction(() => {
|
||||
this.dbs.run(`
|
||||
UPDATE places
|
||||
SET name = ?, lat = ?, lng = ?, transport_mode = 'walking', route_geometry = ?
|
||||
WHERE id = ? AND trip_id = ?
|
||||
`, input.name, start[0], start[1], JSON.stringify(input.route_geometry), placeId, tripId);
|
||||
this.dbs.run(`
|
||||
UPDATE tours
|
||||
SET tour_type = ?, distance = ?, elevation_gain = ?, elevation_loss = ?,
|
||||
duration = ?, match_confidence = 1, max_hiking_difficulty = ?
|
||||
WHERE place_id = ?
|
||||
`, input.tour_type, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM,
|
||||
input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60), input.max_hiking_difficulty, placeId);
|
||||
this.dbs.run('DELETE FROM tour_waypoints WHERE place_id = ?', placeId);
|
||||
|
||||
const insertWaypoint = this.dbs.prepare(`
|
||||
INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const waypoint of input.waypoints) {
|
||||
insertWaypoint.run(placeId, waypoint.lat, waypoint.lng, waypoint.role, waypoint.sequence);
|
||||
}
|
||||
});
|
||||
|
||||
const result = this.getTour(tripId, placeId);
|
||||
const place = this.dbs.getPlaceWithTags(Number(placeId));
|
||||
if (!place) throw new Error('Updated tour could not be loaded');
|
||||
this.broadcastToursChanged(tripId, [Number(placeId)], socketId);
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private broadcastToursChanged(tripId: string, placeIds: number[], socketId?: string): void {
|
||||
try {
|
||||
this.places.broadcast(tripId, 'tours:changed', { placeIds }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tours change for trip ${tripId}: realtime invalidation failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours-mode GPX import prepares rows through PlacesService.prepareGpxRows
|
||||
* with waypoints excluded. PlacesService.importPreparedGpx persists the places
|
||||
* in the same transaction as their `tours` facet rows, whose metrics are
|
||||
* derived from route_geometry.
|
||||
*
|
||||
* Missing or implausible metrics do not block import:
|
||||
* a track without elevation still imports, just flagged with a low
|
||||
* match_confidence so the client can surface a "with caution" toast.
|
||||
*/
|
||||
importGpxAsTour(tripId: string, fileBuffer: Buffer, defaultName?: string, socketId?: string): ImportGpxAsTourResult | null {
|
||||
const rows = this.places.prepareGpxRows(fileBuffer, {
|
||||
importWaypoints: false, importRoutes: true, importTracks: true, defaultName,
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const insertTour = this.dbs.prepare(`
|
||||
INSERT INTO tours (place_id, tour_type, distance, elevation_gain, elevation_loss, duration, match_confidence, max_hiking_difficulty)
|
||||
VALUES (?, 'hike', ?, ?, ?, NULL, ?, 2)
|
||||
`);
|
||||
|
||||
const tours: TourListItem[] = [];
|
||||
const result = this.dbs.transaction(() => {
|
||||
const result = this.places.importPreparedGpx(tripId, rows);
|
||||
for (const place of result.places) {
|
||||
const points = parseRouteGeometry(place.route_geometry);
|
||||
const metrics = computeTourMetrics(points);
|
||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||
insertTour.run(place.id, metrics.distanceKm, metrics.elevationGainM, metrics.elevationLossM, matchConfidence);
|
||||
tours.push(this.toItem({
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'hike',
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: matchConfidence,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
||||
this.broadcastToursChanged(tripId, result.places.map(place => place.id), socketId);
|
||||
for (const place of result.places) {
|
||||
try {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
|
||||
}
|
||||
}
|
||||
return { tours, caution: tours.some(t => t.caution), skipped: result.skipped };
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,8 @@ export interface AssignmentRow extends DayAssignment {
|
||||
stop_type: string | null;
|
||||
/** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */
|
||||
fill_percent: number | null;
|
||||
tour_place_id?: number | null;
|
||||
tour_route_geometry?: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
|
||||
Reference in New Issue
Block a user