feat(tours): take a tour over from another trip, server and contract (LIB-02)

GET /api/tours/library lists the tours of every trip the user owns or is a
member of, with the trip each is in and how many tours each trip holds, to
choose one to take over into the trip being planned. It is narrowed by a
part of the name (matched ignoring case, a "%" or "_" just a character), a
type or one trip, and the asking trip can be left out; up to 100 tours, and
the answer says when there were more. GET /api/tours/library/:placeId
previews one: its facts and its line thinned to 400 points, its ends kept.
Neither is trip-scoped, so every answer is bounded by the user's own trips,
read anew each call: a tour of a trip out of reach is "not found", nothing
hints that it exists. Measures stay null where the tour has none.

POST /api/trips/:tripId/tours/take-over copies one into this trip, which the
guard checks with 'place_edit'; the source trip is checked against the
user's trips as they are at that moment, and the source is read again inside
the write. The copy is a tour of its own: its line, measures, settings and
control points, without days, without its stops' links to places of the
other trip and without the obstacle decisions taken there; it keeps the
import record of its line. A copy that fails leaves nothing behind.

Where it came from is kept as text (tours.taken_over_from_trip_title and
taken_over_from_tour_name, migration 045100), not as a pointer, and read
back in the provenance as takenOverFrom; a copy of such a copy names it too.
The in-trip copy (P2C-07) and the take-over share one copy routine.

MCP: list_tour_library and take_over_tour, over the same service and
checks. The repository tests follow the hiking preferences column of MF-08.
This commit is contained in:
Maurice
2026-10-19 14:53:08 +02:00
parent 9cd227fd81
commit f6fd2722d0
21 changed files with 1074 additions and 48 deletions
+1
View File
@@ -56,6 +56,7 @@ export * from './roadtrip/roadtrip.schema';
export * from './place/track-colors';
export * from './tours/tours.schema';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-library.schema';
export * from './tours/tours.metrics';
export * from './tours/tour-gpx-import.schema';
export * from './tours/paddle-plan.schema';
@@ -0,0 +1,75 @@
import {
tourLibraryDetailResponseSchema,
tourLibraryQuerySchema,
tourLibraryResponseSchema,
tourTakeOverRequestSchema,
} from './tour-library.schema';
import { describe, expect, it } from 'vitest';
const item = {
place_id: 7,
name: 'Kramerplateauweg',
description: null,
tour_type: 'hike',
distance: 4.8,
elevation_gain: 533,
elevation_loss: 530,
duration: 156,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 1,
has_waypoints: true,
origin: 'trek',
trip: { id: 2, title: 'Garmisch' },
};
describe('tour library contracts (LIB-02)', () => {
it('reads the query from strings and refuses what it does not know', () => {
expect(tourLibraryQuerySchema.parse({ q: ' ridge ', type: 'hike', trip_id: '3', exclude_trip_id: '1' })).toEqual({
q: 'ridge',
type: 'hike',
trip_id: 3,
exclude_trip_id: 1,
});
expect(tourLibraryQuerySchema.safeParse({ type: 'car' }).success).toBe(false);
expect(tourLibraryQuerySchema.safeParse({ trip_id: '0' }).success).toBe(false);
expect(tourLibraryQuerySchema.safeParse({ sort: 'name' }).success).toBe(false);
});
it('lists tours with the trip they are in, measures left unknown where unknown', () => {
const answer = tourLibraryResponseSchema.parse({
tours: [item, { ...item, place_id: 8, distance: null, elevation_gain: null }],
trips: [{ id: 2, title: 'Garmisch', tour_count: 2 }],
truncated: false,
});
expect(answer.tours[1]?.distance).toBeNull();
expect(
tourLibraryResponseSchema.safeParse({ tours: [{ ...item, trip: undefined }], trips: [], truncated: false })
.success,
).toBe(false);
});
it('previews a line as plain points', () => {
expect(
tourLibraryDetailResponseSchema.parse({ tour: item, line: [[47.4, 11.1]], waypoint_count: 4 }).line,
).toHaveLength(1);
expect(tourLibraryDetailResponseSchema.safeParse({ tour: item, line: [[47.4]], waypoint_count: 4 }).success).toBe(
false,
);
});
it('takes over by trip and tour, the name optional and trimmed', () => {
expect(tourTakeOverRequestSchema.parse({ source_trip_id: 2, source_place_id: 7, name: ' Copy ' }).name).toBe(
'Copy',
);
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7 }).success).toBe(true);
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, name: '' }).success).toBe(
false,
);
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: '2', source_place_id: 7 }).success).toBe(false);
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, day_id: 1 }).success).toBe(
false,
);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { tourListItemSchema, tourTypeKeySchema } from './tours.schema';
import { z } from 'zod';
/**
* LIB-02: taking a tour over from another trip the user can open. The list
* and the preview read across those trips without changing anything; the
* take-over writes one independent copy into the trip it is asked in.
*/
/** The most tours one list answer carries; the answer says when there were more. */
export const TOUR_LIBRARY_LIMIT = 100;
/** The most points a preview's line carries, thinned evenly along it. */
export const TOUR_LIBRARY_PREVIEW_POINTS = 400;
/** What the list is narrowed by: a part of the name, a type, one trip; the trip asked from is left out. */
export const tourLibraryQuerySchema = z
.object({
q: z.string().trim().max(100).optional(),
type: tourTypeKeySchema.optional(),
trip_id: z.coerce.number().int().positive().optional(),
exclude_trip_id: z.coerce.number().int().positive().optional(),
})
.strict();
export type TourLibraryQuery = z.infer<typeof tourLibraryQuerySchema>;
const tourLibraryTripSchema = z.object({ id: z.number().int().positive(), title: z.string() });
/** A tour of another trip as the list shows it: its measures as they are, and the trip it is in. */
export const tourLibraryItemSchema = tourListItemSchema
.pick({
place_id: true,
name: true,
description: true,
tour_type: true,
distance: true,
elevation_gain: true,
elevation_loss: true,
duration: true,
planned_duration_minutes: true,
break_additional_minutes: true,
max_hiking_difficulty: true,
has_waypoints: true,
origin: true,
})
.extend({ trip: tourLibraryTripSchema });
export type TourLibraryItem = z.infer<typeof tourLibraryItemSchema>;
export const tourLibraryResponseSchema = z.object({
tours: z.array(tourLibraryItemSchema),
/** Every trip with a tour the user could take over, for the trip filter, the asking trip left out. */
trips: z.array(tourLibraryTripSchema.extend({ tour_count: z.number().int().nonnegative() })),
/** More tours matched than the answer carries. */
truncated: z.boolean(),
});
export type TourLibraryResponse = z.infer<typeof tourLibraryResponseSchema>;
/** One tour's preview: the list's facts, its line thinned for drawing, and how many control points it has. */
export const tourLibraryDetailResponseSchema = z.object({
tour: tourLibraryItemSchema,
line: z.array(z.tuple([z.number(), z.number()])),
waypoint_count: z.number().int().nonnegative(),
});
export type TourLibraryDetailResponse = z.infer<typeof tourLibraryDetailResponseSchema>;
/** Which tour of which trip to take over, and the name the copy gets (the tour's own when left out). */
export const tourTakeOverRequestSchema = z
.object({
source_trip_id: z.number().int().positive(),
source_place_id: z.number().int().positive(),
name: z.string().trim().min(1).max(255).optional(),
})
.strict();
export type TourTakeOverRequest = z.infer<typeof tourTakeOverRequestSchema>;
@@ -58,6 +58,11 @@ export const tourProvenanceSchema = z.object({
* import, a snapshot whose import record is missing.
*/
editedLocally: z.boolean().nullable(),
/**
* LIB-02: the trip and tour this tour was taken over from, as they were
* called then; null for every other tour, absent in a detail cached before.
*/
takenOverFrom: z.object({ tripTitle: z.string(), tourName: z.string() }).nullable().optional(),
});
export type TourProvenance = z.infer<typeof tourProvenanceSchema>;