mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user