Tours: save a tour as a new, independent tour (P2C-07)

This commit is contained in:
Maurice
2026-10-15 22:18:41 +02:00
parent 356df90095
commit 485e360c64
38 changed files with 432 additions and 62 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import { tourCreateRequestSchema } from '@trek/shared';
import { tourCopyRequestSchema, tourCreateRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {}
export class TourCopyDto extends createZodDto(tourCopyRequestSchema) {}
+15 -2
View File
@@ -4,10 +4,10 @@ import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { TourCreateDto } from './dto/tour-create.dto';
import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto';
import { ToursService } from './tours.service';
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common';
import { tourDetailResponseSchema } from '@trek/shared';
import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared';
/**
* /api/trips/:tripId/tours: the Tours facet, read and route edits.
@@ -53,6 +53,19 @@ export class ToursController {
return await this.tours.updateTour(tripId, placeId, body, socketId);
}
/** P2C-07: the tour saved again as a new, independent tour of the trip, without its days. */
@RequirePermission('place_edit')
@Post(':placeId/copy')
@ResponseContract(tourCreateResponseSchema)
async copy(
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Body() body: TourCopyDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.tours.copyTour(tripId, placeId, body.name, socketId);
}
/** Takes an import planned over in place back to its recorded track; the planning is dropped. */
@RequirePermission('place_edit')
@Post(':placeId/restore-recorded')
+61
View File
@@ -336,6 +336,67 @@ export class ToursService {
return result;
}
/**
* P2C-07 "save as new tour": an independent tour made from one of the trip,
* with its own id, its own copy of the line and the control points, the
* same measures and source, and none of the days. It points at the tour it
* came from (`derived_from_place_id`), so the detail can name it; nothing
* keeps the two in step afterwards.
*/
async copyTour(tripId: string, placeId: string, name: string, socketId?: string): Promise<TourCreateResponse> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
const copyId = await this.uow.transactional(async () => {
const [source, provenance, place] = await Promise.all([
this.toursRepo.findInTrip(tid, pid),
this.toursRepo.findProvenance(tid, pid),
this.placesRepo.findInTrip(pid, tid),
]);
if (!source || !provenance || !place) throw new NotFoundException('Tour not found');
const start = parseRouteGeometry(place.route_geometry)[0];
const id = await this.placesRepo.insertTourPlace({
trip_id: tid,
name,
lat: start?.[0] ?? place.lat ?? 0,
lng: start?.[1] ?? place.lng ?? 0,
route_geometry: place.route_geometry,
description: place.description ?? null,
website: place.website ?? null,
});
await this.toursRepo.insertTour({
place_id: id,
tour_type: source.tour_type,
distance: source.distance,
elevation_gain: source.elevation_gain,
elevation_loss: source.elevation_loss,
duration: source.duration,
planned_duration_minutes: source.planned_duration_minutes,
break_additional_minutes: source.break_additional_minutes,
match_confidence: source.match_confidence,
max_hiking_difficulty: source.max_hiking_difficulty,
wanderer_ref: source.wanderer_ref,
difficulty: source.difficulty,
derived_from_place_id: pid,
origin: source.origin as TourOrigin,
origin_file_name: provenance.origin_file_name,
});
await this.waypointsRepo.insertForPlace(id, await this.waypointsRepo.listForPlace(pid));
return id;
});
const result = await this.getTour(tripId, String(copyId));
const created = await this.placesRepo.findWithTagsAndRatings(copyId);
this.broadcastToursChanged(tripId, [copyId], socketId);
try {
if (created) this.places.broadcast(tripId, 'place:created', { place: created }, socketId);
} catch {
this.logger.warn(`Committed Tour copy ${copyId}: place notification failed`);
}
return result;
}
/** Replace a saved tour's derived route and routing controls as one write. */
async updateTour(
tripId: string,