mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(tours): a paddle tour's obstacle checklist and check bench on the server
PAD-06 and PAD-09. The tours list carries the check bench of every paddle tour and the detail its whole checklist, worked out of the obstacle data along the tour's line and the decisions stored for it. The obstacle data is read along the line run by run and kept in memory under the line, the boat and the data version, so a list of a trip reads it once; a hike costs nothing. While the data cannot be read the checklist says so and keeps every decision visible. PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews stores what the paddlers decided, each against the basis the user saw, and answers the detail. It needs place_edit, refuses a tour that is no paddle tour with a 400, waits with a 503 while the data cannot be read, and skips an obstacle no longer on the checklist instead of failing. A copied tour and a copied trip take their decisions along. The MCP read tools describe the new fields; there is no write tool, since a decision is a statement of the paddlers. The obstacle data keeps the dates of checks on site, covers a line by sampling it, and tells its version by its rows and regions.
This commit is contained in:
@@ -2,12 +2,14 @@ import type { JsonColumn } from '../utils/json-column';
|
||||
import {
|
||||
FERRATAS_RESULT_CAP,
|
||||
ferrataSchema,
|
||||
obstacleSnapshotSchema,
|
||||
paddleGeometrySchema,
|
||||
paddleLockSchema,
|
||||
paddleRegionBoundarySchema,
|
||||
tourFixedLegSchema,
|
||||
trackMarksSchema,
|
||||
type Ferrata,
|
||||
type ObstacleSnapshot,
|
||||
type PaddleGeometry,
|
||||
type PaddleLock,
|
||||
type PaddleRegionBoundary,
|
||||
@@ -191,3 +193,10 @@ export const PADDLE_REGION_BOUNDARY: JsonColumn<PaddleRegionBoundary | null> = {
|
||||
schema: paddleRegionBoundarySchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** What a paddle tour's obstacle decision keeps of its obstacle (PAD-06). A broken row has none, and the decision is left out. */
|
||||
export const TOUR_OBSTACLE_SNAPSHOT: JsonColumn<ObstacleSnapshot | null> = {
|
||||
column: 'tour_obstacle_reviews.obstacle_snapshot',
|
||||
schema: obstacleSnapshotSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
@@ -66,6 +66,18 @@ export class PaddleFeaturesRepository extends TrekRepository<PaddleFeatures> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** How many objects TREK holds and the newest row's id: together they change whenever a read lands. */
|
||||
async stats(): Promise<{ count: number; max_id: number | null }> {
|
||||
const row = await this.db()
|
||||
.selectFrom('paddle_features')
|
||||
.select((eb) => [eb.fn.countAll<number>().as('count'), eb.fn.max('id').as('max_id')])
|
||||
.executeTakeFirst();
|
||||
return {
|
||||
count: Number(row?.count ?? 0),
|
||||
max_id: row?.max_id === null || row?.max_id === undefined ? null : Number(row.max_id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drops a region's objects and writes the new read in their place; the caller holds the transaction. */
|
||||
async replaceRegion(regionId: string, rows: readonly PaddleFeatureRow[]): Promise<void> {
|
||||
await this.db().deleteFrom('paddle_features').where('region_id', '=', regionId).execute();
|
||||
|
||||
@@ -1,4 +1,98 @@
|
||||
import type { TourObstacleReviews } from '../entities/TourObstacleReviews.entity';
|
||||
import type { TourObstacleReviews, TTourObstacleReviewsDecision } from '../entities/TourObstacleReviews.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TourObstacleReviewsRepository extends TrekRepository<TourObstacleReviews> {}
|
||||
/** `decision` narrowed to the four values the column's CHECK constraint allows. */
|
||||
type ReviewsDB = {
|
||||
tour_obstacle_reviews: Omit<DB['tour_obstacle_reviews'], 'decision'> & { decision: TTourObstacleReviewsDecision };
|
||||
places: DB['places'];
|
||||
};
|
||||
|
||||
/** One stored decision, its snapshot still JSON text and its time the stored text. */
|
||||
export interface TourObstacleReviewRow {
|
||||
place_id: number;
|
||||
obstacle_key: string;
|
||||
decision: TTourObstacleReviewsDecision;
|
||||
note: string | null;
|
||||
route_fingerprint: string;
|
||||
source_fingerprint: string;
|
||||
obstacle_snapshot: string;
|
||||
decided_at: string;
|
||||
decided_by: number | null;
|
||||
}
|
||||
|
||||
export type TourObstacleReviewWrite = Omit<TourObstacleReviewRow, 'place_id'>;
|
||||
|
||||
const COLUMNS = [
|
||||
'r.place_id',
|
||||
'r.obstacle_key',
|
||||
'r.decision',
|
||||
'r.note',
|
||||
'r.route_fingerprint',
|
||||
'r.source_fingerprint',
|
||||
'r.obstacle_snapshot',
|
||||
'r.decided_at',
|
||||
'r.decided_by',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `tour_obstacle_reviews`: what the paddlers decided about the obstacles on a
|
||||
* tour (PAD-06), each bound to the route and the data it was decided on. A
|
||||
* decision goes with its tour; nothing here removes one the data no longer
|
||||
* holds.
|
||||
*/
|
||||
export class TourObstacleReviewsRepository extends TrekRepository<TourObstacleReviews> {
|
||||
private db() {
|
||||
return this.kysely<ReviewsDB>();
|
||||
}
|
||||
|
||||
async listForPlace(place_id: number): Promise<TourObstacleReviewRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_obstacle_reviews as r')
|
||||
.select(COLUMNS)
|
||||
.where('r.place_id', '=', place_id)
|
||||
.orderBy('r.obstacle_key', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Every decision on the tours of one trip, for its tours list. */
|
||||
async listForTrip(trip_id: number): Promise<TourObstacleReviewRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_obstacle_reviews as r')
|
||||
.innerJoin('places as p', 'p.id', 'r.place_id')
|
||||
.select(COLUMNS)
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('r.place_id', 'asc')
|
||||
.orderBy('r.obstacle_key', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Writes decisions, each replacing the one stored for its obstacle; the caller holds the transaction. */
|
||||
async writeMany(place_id: number, rows: readonly TourObstacleReviewWrite[]): Promise<void> {
|
||||
await this.db()
|
||||
.insertInto('tour_obstacle_reviews')
|
||||
.values(rows.map((row) => ({ ...row, place_id })))
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['place_id', 'obstacle_key']).doUpdateSet((eb) => ({
|
||||
decision: eb.ref('excluded.decision'),
|
||||
note: eb.ref('excluded.note'),
|
||||
route_fingerprint: eb.ref('excluded.route_fingerprint'),
|
||||
source_fingerprint: eb.ref('excluded.source_fingerprint'),
|
||||
obstacle_snapshot: eb.ref('excluded.obstacle_snapshot'),
|
||||
decided_at: eb.ref('excluded.decided_at'),
|
||||
decided_by: eb.ref('excluded.decided_by'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Removes the decisions about the given obstacles; returns how many went. */
|
||||
async deleteKeys(place_id: number, keys: readonly string[]): Promise<number> {
|
||||
const result = await this.db()
|
||||
.deleteFrom('tour_obstacle_reviews')
|
||||
.where('place_id', '=', place_id)
|
||||
.where('obstacle_key', 'in', [...keys])
|
||||
.executeTakeFirst();
|
||||
return Number(result.numDeletedRows ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,10 @@ export interface TourPaddlePlanUpdate {
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days'>;
|
||||
type ToursKyselyDB = Pick<
|
||||
DB,
|
||||
'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days' | 'tour_obstacle_reviews'
|
||||
>;
|
||||
|
||||
/** One day a tour of the trip sits on, in the order of the trip. */
|
||||
export interface TourDayRow {
|
||||
@@ -260,6 +263,52 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
* trip copy still says where its imports came from and when. Nothing happens
|
||||
* for a tour without one.
|
||||
*/
|
||||
/** The line of every paddle tour of a trip, for its check bench (PAD-09). */
|
||||
async listPaddleLinesForTrip(trip_id: number): Promise<Array<{ place_id: number; route_geometry: string | null }>> {
|
||||
return await this.db()
|
||||
.selectFrom('tours as t')
|
||||
.innerJoin('places as p', 'p.id', 't.place_id')
|
||||
.select(['t.place_id', 'p.route_geometry'])
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.where('t.tour_type', '=', 'kayak')
|
||||
.orderBy('t.place_id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** A copied tour takes the decisions about its obstacles along, bound to what they were decided on. */
|
||||
async copyObstacleReviews(source_place_id: number, place_id: number): Promise<void> {
|
||||
await this.db()
|
||||
.insertInto('tour_obstacle_reviews')
|
||||
.columns([
|
||||
'place_id',
|
||||
'obstacle_key',
|
||||
'decision',
|
||||
'note',
|
||||
'route_fingerprint',
|
||||
'source_fingerprint',
|
||||
'obstacle_snapshot',
|
||||
'decided_at',
|
||||
'decided_by',
|
||||
])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('tour_obstacle_reviews')
|
||||
.select((inner) => [
|
||||
inner.val(place_id).as('place_id'),
|
||||
'obstacle_key',
|
||||
'decision',
|
||||
'note',
|
||||
'route_fingerprint',
|
||||
'source_fingerprint',
|
||||
'obstacle_snapshot',
|
||||
'decided_at',
|
||||
'decided_by',
|
||||
])
|
||||
.where('place_id', '=', source_place_id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async copySource(source_place_id: number, place_id: number): Promise<void> {
|
||||
await this.db()
|
||||
.insertInto('tour_sources')
|
||||
|
||||
@@ -85,3 +85,53 @@ export function boxOfBoundary(boundary: RegionBoundary): Box {
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The line is checked about this often, in metres, and at no more than LINE_MAX_SAMPLES points. */
|
||||
const LINE_SAMPLE_M = 250;
|
||||
const LINE_MAX_SAMPLES = 400;
|
||||
|
||||
function metres(a: readonly [number, number], b: readonly [number, number]): number {
|
||||
const cos = Math.cos((((a[0] + b[0]) / 2) * Math.PI) / 180);
|
||||
return Math.hypot((b[0] - a[0]) * 111_195, (b[1] - a[1]) * 111_195 * cos);
|
||||
}
|
||||
|
||||
/** Points along a line about every LINE_SAMPLE_M, its ends included, at most LINE_MAX_SAMPLES. */
|
||||
function samplesAlong(points: readonly (readonly [number, number])[]): Array<[number, number]> {
|
||||
const samples: Array<[number, number]> = [];
|
||||
points.forEach((point, i) => {
|
||||
const prev = points[i - 1];
|
||||
if (!prev) {
|
||||
samples.push([point[0], point[1]]);
|
||||
return;
|
||||
}
|
||||
const steps = Math.max(1, Math.ceil(metres(prev, point) / LINE_SAMPLE_M));
|
||||
for (let k = 1; k <= steps; k++) {
|
||||
samples.push([prev[0] + ((point[0] - prev[0]) * k) / steps, prev[1] + ((point[1] - prev[1]) * k) / steps]);
|
||||
}
|
||||
});
|
||||
if (samples.length <= LINE_MAX_SAMPLES) return samples;
|
||||
const stride = samples.length / LINE_MAX_SAMPLES;
|
||||
return Array.from({ length: LINE_MAX_SAMPLES }, (_, i) => samples[Math.floor(i * stride)] ?? samples[0] ?? [0, 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far TREK's data covers a paddle tour's line (PAD-09): covered when
|
||||
* every sample along it lies inside a region, partial when some do, none
|
||||
* otherwise. A region without a boundary counts by its box, and by nothing
|
||||
* when that is unknown too.
|
||||
*/
|
||||
export function coverageOfLine(
|
||||
points: readonly (readonly [number, number])[],
|
||||
regions: ReadonlyArray<{ boundary: RegionBoundary | null; box: Box | null }>,
|
||||
): PaddleCoverageState {
|
||||
if (regions.length === 0 || points.length === 0) return 'none';
|
||||
const inside = samplesAlong(points).map(([lat, lng]) =>
|
||||
regions.some(({ boundary, box }) =>
|
||||
boundary
|
||||
? boundaryContains(boundary, lat, lng)
|
||||
: !!box && lat >= box.south && lat <= box.north && lng >= box.west && lng <= box.east,
|
||||
),
|
||||
);
|
||||
if (inside.every(Boolean)) return 'covered';
|
||||
return inside.some(Boolean) ? 'partial' : 'none';
|
||||
}
|
||||
|
||||
@@ -2,23 +2,84 @@ import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../db/entities/PaddleRegions.entity';
|
||||
import { PADDLE_FEATURE_GEOMETRY, PADDLE_FEATURE_TAGS, PADDLE_REGION_BOUNDARY } from '../../db/json-columns';
|
||||
import type { Box, PaddleFeatureRow, PaddleFeaturesRepository } from '../../db/repositories/PaddleFeatures.repository';
|
||||
import type { PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository';
|
||||
import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository';
|
||||
import { utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { coverageOf, parseBox } from './paddle-geo';
|
||||
import { coverageOf, coverageOfLine, parseBox } from './paddle-geo';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
NEAR_M,
|
||||
PADDLE_OBSTACLES_CAP,
|
||||
paddleObstacleKindSchema,
|
||||
type PaddleCoverageRegion,
|
||||
type PaddleCoverageState,
|
||||
type PaddleFeature,
|
||||
type PaddleObstaclesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** The widest a run of a line is read at once, in degrees a side; a long tour is read run by run. */
|
||||
const RUN_SPAN_DEG = 0.1;
|
||||
|
||||
/** What the data holds along a paddle tour's line, as the checklist reads it. */
|
||||
export interface PaddleObstaclesAlong {
|
||||
features: PaddleFeature[];
|
||||
regions: PaddleCoverageRegion[];
|
||||
coverage: PaddleCoverageState;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** The boxes a line is read in: runs no wider than RUN_SPAN_DEG, each grown by NEAR_M so nothing near the line is missed. */
|
||||
function runBoxesOf(points: readonly (readonly [number, number])[]): Box[] {
|
||||
const boxes: Box[] = [];
|
||||
let box: Box | null = null;
|
||||
let last: readonly [number, number] | null = null;
|
||||
for (const [lat, lng] of points) {
|
||||
if (
|
||||
box &&
|
||||
Math.max(box.north, lat) - Math.min(box.south, lat) <= RUN_SPAN_DEG &&
|
||||
Math.max(box.east, lng) - Math.min(box.west, lng) <= RUN_SPAN_DEG
|
||||
) {
|
||||
box = {
|
||||
south: Math.min(box.south, lat),
|
||||
west: Math.min(box.west, lng),
|
||||
north: Math.max(box.north, lat),
|
||||
east: Math.max(box.east, lng),
|
||||
};
|
||||
} else {
|
||||
if (box) boxes.push(box);
|
||||
// A new run starts at the last point of the one before, so the stretch between them is read too.
|
||||
const [fromLat, fromLng] = last ?? [lat, lng];
|
||||
box = {
|
||||
south: Math.min(fromLat, lat),
|
||||
west: Math.min(fromLng, lng),
|
||||
north: Math.max(fromLat, lat),
|
||||
east: Math.max(fromLng, lng),
|
||||
};
|
||||
}
|
||||
last = [lat, lng];
|
||||
}
|
||||
if (box) boxes.push(box);
|
||||
return boxes.map((run) => {
|
||||
const dLat = NEAR_M / 111_195;
|
||||
const dLng = dLat / Math.max(0.1, Math.cos((((run.south + run.north) / 2) * Math.PI) / 180));
|
||||
return { south: run.south - dLat, west: run.west - dLng, north: run.north + dLat, east: run.east + dLng };
|
||||
});
|
||||
}
|
||||
|
||||
export const PADDLE_BOX_INVALID_MESSAGE = 'bbox must be south,west,north,east and at most one degree a side';
|
||||
|
||||
function coverageRegionOf(region: PaddleRegionRow): PaddleCoverageRegion {
|
||||
return {
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
source_timestamp: region.source_timestamp,
|
||||
imported_at: utcSuffix(region.imported_at),
|
||||
};
|
||||
}
|
||||
|
||||
function featureOf(row: PaddleFeatureRow): PaddleFeature | null {
|
||||
const context = `${row.region_id}/${row.osm_ref}`;
|
||||
const kind = paddleObstacleKindSchema.safeParse(row.kind);
|
||||
@@ -59,6 +120,63 @@ export class PaddleObstaclesService {
|
||||
return await this.read(box);
|
||||
}
|
||||
|
||||
/**
|
||||
* The objects along a paddle tour's line (`[lat, lng]` points) and how far
|
||||
* the data covers the line itself, for the checklist (PAD-06, PAD-09). The
|
||||
* line is read in runs; an object two runs or two regions hold is answered
|
||||
* once, and a run cut at the cap marks the whole answer as cut.
|
||||
*/
|
||||
async readAlong(points: readonly (readonly [number, number])[]): Promise<PaddleObstaclesAlong> {
|
||||
const boxes = runBoxesOf(points);
|
||||
const runs = await Promise.all(
|
||||
boxes.map(async (box) => {
|
||||
const [rows, regions] = await Promise.all([
|
||||
this.features.findInBox(box, PADDLE_OBSTACLES_CAP + 1),
|
||||
this.regions.withDataInBox(box),
|
||||
]);
|
||||
return { rows, regions };
|
||||
}),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const features: PaddleFeature[] = [];
|
||||
const regions = new Map<string, PaddleRegionRow>();
|
||||
for (const run of runs) {
|
||||
for (const row of run.rows.slice(0, PADDLE_OBSTACLES_CAP)) {
|
||||
if (seen.has(row.osm_ref)) continue;
|
||||
seen.add(row.osm_ref);
|
||||
const feature = featureOf(row);
|
||||
if (feature) features.push(feature);
|
||||
}
|
||||
for (const region of run.regions) regions.set(region.id, region);
|
||||
}
|
||||
const held = [...regions.values()];
|
||||
return {
|
||||
features,
|
||||
regions: held.map(coverageRegionOf),
|
||||
coverage: coverageOfLine(
|
||||
points,
|
||||
held.map((region) => ({
|
||||
boundary: decodeJson(PADDLE_REGION_BOUNDARY, region.boundary, region.id),
|
||||
box:
|
||||
region.min_lat === null || region.min_lng === null || region.max_lat === null || region.max_lng === null
|
||||
? null
|
||||
: { south: region.min_lat, west: region.min_lng, north: region.max_lat, east: region.max_lng },
|
||||
})),
|
||||
),
|
||||
truncated: runs.some((run) => run.rows.length > PADDLE_OBSTACLES_CAP),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A text that changes whenever the data a checklist reads could have:
|
||||
* an object added or removed, a region read again or removed. The checklist
|
||||
* caches its reading under it.
|
||||
*/
|
||||
async dataVersion(): Promise<string> {
|
||||
const [stats, regions] = await Promise.all([this.features.stats(), this.regions.list()]);
|
||||
return `${stats.count}:${stats.max_id ?? '-'}:${regions.map((region) => `${region.id}@${region.imported_at ?? '-'}`).join(';')}`;
|
||||
}
|
||||
|
||||
/** The same answer for a box already known to be valid, for the server's own route checks. */
|
||||
async read(box: Box): Promise<PaddleObstaclesResponse> {
|
||||
const [rows, regions] = await Promise.all([
|
||||
@@ -78,12 +196,7 @@ export class PaddleObstaclesService {
|
||||
box,
|
||||
regions.map((region) => decodeJson(PADDLE_REGION_BOUNDARY, region.boundary, region.id)),
|
||||
),
|
||||
regions: regions.map((region) => ({
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
source_timestamp: region.source_timestamp,
|
||||
imported_at: utcSuffix(region.imported_at),
|
||||
})),
|
||||
regions: regions.map(coverageRegionOf),
|
||||
},
|
||||
truncated: unique.length > PADDLE_OBSTACLES_CAP,
|
||||
};
|
||||
|
||||
@@ -68,8 +68,11 @@ const KEPT = new Set([
|
||||
'fee',
|
||||
'website',
|
||||
'motorboat',
|
||||
// When someone last looked on site; the checklist shows it, a newer one never flips a decision.
|
||||
'check_date',
|
||||
'survey:date',
|
||||
]);
|
||||
const KEPT_PREFIXES = ['canoe:', 'boat:', 'whitewater:', 'portage:', 'lock:', 'access:'];
|
||||
const KEPT_PREFIXES = ['canoe:', 'boat:', 'whitewater:', 'portage:', 'lock:', 'access:', 'check_date:'];
|
||||
|
||||
/** The tags an object is judged and shown by; everything else of OSM's is left behind. */
|
||||
export function keptTags(tags: Readonly<Record<string, string>>): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourObstacleReviewsRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourObstacleReviewsDto extends createZodDto(tourObstacleReviewsRequestSchema) {}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TOUR_OBSTACLE_SNAPSHOT, TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type {
|
||||
TourObstacleReviewRow,
|
||||
TourObstacleReviewsRepository,
|
||||
TourObstacleReviewWrite,
|
||||
} from '../../db/repositories/TourObstacleReviews.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { dbNow } from '../../db/types';
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { PaddleCheckEvaluation } from './tour-paddle-check.cache';
|
||||
import { PaddleDataUnavailableError, storedReviewOf, TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
obstacleEntriesOf,
|
||||
obstacleSnapshotOf,
|
||||
type PaddleBoatType,
|
||||
type TourDetailResponse,
|
||||
type TourObstacleReviewsRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
export const NOT_A_PADDLE_TOUR_MESSAGE = 'Only a paddle tour has an obstacle checklist';
|
||||
export const PADDLE_DATA_UNAVAILABLE_MESSAGE = 'Obstacle data unavailable';
|
||||
|
||||
/**
|
||||
* PAD-06: saving what the paddlers decided about the obstacles of a tour.
|
||||
* Each decision is stored against the basis the user saw (the route near the
|
||||
* obstacle and its data, as the shown entry carried them), so one made on a
|
||||
* stale view or offline reads as one to check again rather than holding for
|
||||
* a line or data it was never made on. A save never fails over the data: an
|
||||
* obstacle no longer among the candidates is skipped, and data that cannot be
|
||||
* read is a 503 the client's queue retries. Several changes, and the members
|
||||
* of one lock, land in one transaction.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourObstacleReviewsService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly tours: ToursService,
|
||||
private readonly paddleChecks: TourPaddleCheckService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(TourObstacleReviews) private readonly reviewsRepo: TourObstacleReviewsRepository,
|
||||
) {}
|
||||
|
||||
async save(
|
||||
tripId: string,
|
||||
placeId: string,
|
||||
input: TourObstacleReviewsRequest,
|
||||
userId: number,
|
||||
socketId?: string,
|
||||
): Promise<TourDetailResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
const row = await this.toursRepo.findInTrip(tid, pid);
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
if (row.tour_type !== 'kayak') {
|
||||
throw new DomainError(400, NOT_A_PADDLE_TOUR_MESSAGE, {
|
||||
code: 'not_a_paddle_tour',
|
||||
details: { code: 'not_a_paddle_tour' },
|
||||
});
|
||||
}
|
||||
const evaluation = await this.evaluate(tid, pid, row);
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
const existing = await this.reviewsRepo.listForPlace(pid);
|
||||
const { writes, deletes } = this.changesOf(input, existing, evaluation, userId);
|
||||
if (writes.length > 0) await this.reviewsRepo.writeMany(pid, writes);
|
||||
if (deletes.length > 0) await this.reviewsRepo.deleteKeys(pid, deletes);
|
||||
});
|
||||
|
||||
const result = await this.tours.getTour(tripId, placeId);
|
||||
this.tours.broadcastToursChanged(tripId, [pid], socketId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The tour's line read against the obstacle data; a tour without a line has nothing to decide about. */
|
||||
private async evaluate(
|
||||
tid: number,
|
||||
pid: number,
|
||||
row: { has_waypoints: number; track_marks: string | null; boat_type: string | null },
|
||||
): Promise<PaddleCheckEvaluation | null> {
|
||||
const [waypoints, place] = await Promise.all([
|
||||
this.waypointsRepo.listForPlace(pid),
|
||||
this.placesRepo.findInTrip(pid, tid),
|
||||
]);
|
||||
const line = this.paddleChecks.lineOf(
|
||||
{ has_waypoints: Boolean(row.has_waypoints), track_marks: decodeJson(TOUR_TRACK_MARKS, row.track_marks, 'tour') },
|
||||
waypoints,
|
||||
place?.route_geometry ?? null,
|
||||
);
|
||||
if (!line) return null;
|
||||
try {
|
||||
return await this.paddleChecks.evaluate(line, row.boat_type as PaddleBoatType | null);
|
||||
} catch (err) {
|
||||
if (!(err instanceof PaddleDataUnavailableError)) throw err;
|
||||
logWarn(`[Paddle] obstacle decisions of tour ${pid} wait: ${err.message}`);
|
||||
throw new DomainError(503, PADDLE_DATA_UNAVAILABLE_MESSAGE, {
|
||||
code: 'paddle_data_unavailable',
|
||||
details: { code: 'paddle_data_unavailable' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** What the ops change of the stored decisions: the rows to write and the keys to remove. */
|
||||
private changesOf(
|
||||
input: TourObstacleReviewsRequest,
|
||||
existing: readonly TourObstacleReviewRow[],
|
||||
evaluation: PaddleCheckEvaluation | null,
|
||||
userId: number,
|
||||
): { writes: TourObstacleReviewWrite[]; deletes: string[] } {
|
||||
const stored = new Map(existing.map((row) => [row.obstacle_key, row]));
|
||||
const candidates = new Map((evaluation?.candidates ?? []).map((candidate) => [candidate.key, candidate]));
|
||||
const entries = evaluation
|
||||
? obstacleEntriesOf({ ...evaluation, reviews: existing.flatMap((row) => storedReviewOf(row) ?? []) })
|
||||
: [];
|
||||
const writes: TourObstacleReviewWrite[] = [];
|
||||
const deletes: string[] = [];
|
||||
for (const op of input.ops) {
|
||||
const row = stored.get(op.key);
|
||||
if (op.op === 'decide') {
|
||||
const candidate = candidates.get(op.key);
|
||||
if (!candidate || !op.decision || !op.basis) {
|
||||
logWarn(`[Paddle] decision about ${op.key} skipped: not on this tour's checklist now`);
|
||||
continue;
|
||||
}
|
||||
const note = op.note || null;
|
||||
const same =
|
||||
row?.decision === op.decision &&
|
||||
row.note === note &&
|
||||
row.route_fingerprint === op.basis.route &&
|
||||
row.source_fingerprint === op.basis.source;
|
||||
if (same) continue;
|
||||
writes.push({
|
||||
obstacle_key: op.key,
|
||||
decision: op.decision,
|
||||
note,
|
||||
route_fingerprint: op.basis.route,
|
||||
source_fingerprint: op.basis.source,
|
||||
obstacle_snapshot: encodeJson(TOUR_OBSTACLE_SNAPSHOT, obstacleSnapshotOf(candidate)),
|
||||
decided_at: dbNow(),
|
||||
decided_by: userId,
|
||||
});
|
||||
} else if (op.op === 'note') {
|
||||
// A note changes neither the decision nor when it was made.
|
||||
if (row) writes.push({ ...withoutPlace(row), note: op.note || null });
|
||||
} else if (op.op === 'clear') {
|
||||
if (row) deletes.push(op.key);
|
||||
} else if (row && entries.some((entry) => entry.key === op.key && entry.status === 'gone')) {
|
||||
// Kept on purpose although the data no longer holds the object: it reads as kept, not as to check.
|
||||
writes.push({ ...withoutPlace(row), source_fingerprint: 'gone', decided_at: dbNow(), decided_by: userId });
|
||||
}
|
||||
}
|
||||
return { writes, deletes };
|
||||
}
|
||||
}
|
||||
|
||||
function withoutPlace(row: TourObstacleReviewRow): TourObstacleReviewWrite {
|
||||
const { place_id: _place, ...write } = row;
|
||||
return write;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ObstacleCandidate, PaddleCheck, PaddleCheckLine, TourOwnEntry } from '@trek/shared';
|
||||
|
||||
/** What reading the obstacle data along one line made of it, before the stored decisions are laid over it. */
|
||||
export interface PaddleCheckEvaluation {
|
||||
line: PaddleCheckLine;
|
||||
candidates: ObstacleCandidate[];
|
||||
own: TourOwnEntry[];
|
||||
/** The checklist key of every object the read held, to tell a vanished object from one the route left. */
|
||||
dataKeys: ReadonlySet<string>;
|
||||
/** The data covers the whole line and nothing was cut, so a missing object is really missing. */
|
||||
complete: boolean;
|
||||
data: Pick<PaddleCheck, 'data' | 'data_as_of' | 'read_at'>;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 256;
|
||||
|
||||
/**
|
||||
* The evaluations of the last lines read, keyed by the line, the boat and
|
||||
* the version of the obstacle data, so the tours list of a trip reads its
|
||||
* paddle tours' obstacles once and not on every call. Held in memory: one
|
||||
* process serves the app, and a new data version simply misses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaddleCheckCache {
|
||||
private readonly entries = new Map<string, PaddleCheckEvaluation>();
|
||||
|
||||
get(key: string): PaddleCheckEvaluation | undefined {
|
||||
const hit = this.entries.get(key);
|
||||
if (hit) {
|
||||
// Read again, kept longer: the map's order is the order of use.
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
set(key: string, value: PaddleCheckEvaluation): void {
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, value);
|
||||
if (this.entries.size > MAX_ENTRIES) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest !== undefined) this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { TourObstacleReviews, TourObstacleReviewsDecision } from '../../db/entities/TourObstacleReviews.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TOUR_OBSTACLE_SNAPSHOT } from '../../db/json-columns';
|
||||
import type {
|
||||
TourObstacleReviewRow,
|
||||
TourObstacleReviewsRepository,
|
||||
} from '../../db/repositories/TourObstacleReviews.repository';
|
||||
import type { TourWaypointRow, TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { PaddleObstaclesService } from '../paddle-obstacles/paddle-obstacles.service';
|
||||
import { PaddleCheckCache, type PaddleCheckEvaluation } from './tour-paddle-check.cache';
|
||||
import { parseRouteGeometry, shortHash } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
obstacleCandidatesOf,
|
||||
obstacleEntriesOf,
|
||||
ownEntriesOf,
|
||||
PADDLE_CHECK_MAX_ENTRIES,
|
||||
PADDLE_CHECK_VERSION,
|
||||
paddleCheckLineOfPoints,
|
||||
paddleCheckLineOfTrack,
|
||||
paddleCheckSummaryOf,
|
||||
paddleObstacleKeyOf,
|
||||
type PaddleBoatType,
|
||||
type PaddleCheck,
|
||||
type PaddleCheckLine,
|
||||
type StoredObstacleReview,
|
||||
type TourListItem,
|
||||
type TourObstacleCheck,
|
||||
type TourObstacleEntry,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** The obstacle data could not be read: a checklist cannot be judged now, and a decision waits to be retried. */
|
||||
export class PaddleDataUnavailableError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(`Obstacle data unavailable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
||||
this.name = 'PaddleDataUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const DECISIONS = new Set<string>(Object.values(TourObstacleReviewsDecision));
|
||||
|
||||
/** One stored row as the checklist reads it; a row whose decision or snapshot does not read is left out, and said so. */
|
||||
export function storedReviewOf(row: TourObstacleReviewRow): StoredObstacleReview | null {
|
||||
const snapshot = decodeJson(TOUR_OBSTACLE_SNAPSHOT, row.obstacle_snapshot, `tour ${row.place_id}`);
|
||||
if (!snapshot || !DECISIONS.has(row.decision)) {
|
||||
logWarn(`[Paddle] obstacle decision ${row.place_id}/${row.obstacle_key} does not read; left out`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
obstacle_key: row.obstacle_key,
|
||||
decision: row.decision,
|
||||
note: row.note,
|
||||
route_fingerprint: row.route_fingerprint,
|
||||
source_fingerprint: row.source_fingerprint,
|
||||
snapshot,
|
||||
decided_at: utcSuffix(row.decided_at) ?? row.decided_at,
|
||||
decided_by: row.decided_by,
|
||||
};
|
||||
}
|
||||
|
||||
function oldest(values: ReadonlyArray<string | null>): string | null {
|
||||
return values.filter((value): value is string => !!value).sort()[0] ?? null;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = { data: 'unavailable', data_as_of: null, read_at: null } as const;
|
||||
|
||||
/**
|
||||
* PAD-06 and PAD-09 on the server: a paddle tour's checklist and its check
|
||||
* bench, worked out of the obstacle data along its line and the decisions
|
||||
* stored for it. The tours list carries the bench of every paddle tour, the
|
||||
* detail the whole checklist. A hike costs nothing here; a paddle tour's
|
||||
* reading is cached under the line, the boat and the data's version.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourPaddleCheckService {
|
||||
constructor(
|
||||
private readonly obstacles: PaddleObstaclesService,
|
||||
private readonly cache: PaddleCheckCache,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(TourObstacleReviews) private readonly reviewsRepo: TourObstacleReviewsRepository,
|
||||
) {}
|
||||
|
||||
/** A planned tour's line from its control points, a recorded track's from its line and the marks laid onto it. */
|
||||
lineOf(
|
||||
item: Pick<TourListItem, 'has_waypoints' | 'track_marks'>,
|
||||
waypoints: readonly TourWaypointRow[],
|
||||
routeGeometry: string | null,
|
||||
): PaddleCheckLine | null {
|
||||
return item.has_waypoints
|
||||
? paddleCheckLineOfPoints(waypoints)
|
||||
: paddleCheckLineOfTrack(parseRouteGeometry(routeGeometry), item.track_marks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The obstacle data along a line made into candidates and the paddler's own
|
||||
* entries. Throws PaddleDataUnavailableError when the data cannot be read.
|
||||
* A reading is kept only when the data's version is the same after the read
|
||||
* as before it, so a region import landing meanwhile is never cached.
|
||||
*/
|
||||
async evaluate(line: PaddleCheckLine, boat: PaddleBoatType | null): Promise<PaddleCheckEvaluation> {
|
||||
try {
|
||||
const version = await this.obstacles.dataVersion();
|
||||
const key = `${PADDLE_CHECK_VERSION}|${shortHash(JSON.stringify([line.points, line.portages, line.locks]))}|${boat ?? ''}|${version}`;
|
||||
const hit = this.cache.get(key);
|
||||
if (hit) return hit;
|
||||
const along = await this.obstacles.readAlong(line.points);
|
||||
const candidates = obstacleCandidatesOf({
|
||||
line,
|
||||
features: along.features,
|
||||
regions: along.regions,
|
||||
boat,
|
||||
hash: shortHash,
|
||||
});
|
||||
const own = ownEntriesOf({ line, candidates, features: along.features, boat });
|
||||
const limited = along.truncated || candidates.length > PADDLE_CHECK_MAX_ENTRIES;
|
||||
const evaluation: PaddleCheckEvaluation = {
|
||||
line,
|
||||
candidates,
|
||||
own,
|
||||
dataKeys: new Set(along.features.map(paddleObstacleKeyOf)),
|
||||
complete: along.coverage === 'covered' && !limited,
|
||||
data: {
|
||||
data: along.coverage === 'none' ? 'none' : limited ? 'limited' : along.coverage,
|
||||
data_as_of: oldest(along.regions.map((region) => region.source_timestamp)),
|
||||
read_at: oldest(along.regions.map((region) => region.imported_at)),
|
||||
},
|
||||
};
|
||||
if ((await this.obstacles.dataVersion()) === version) this.cache.set(key, evaluation);
|
||||
return evaluation;
|
||||
} catch (err) {
|
||||
throw new PaddleDataUnavailableError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** The checklist of one line with its decisions laid over it, and the bench it sums up to. */
|
||||
async checkOf(
|
||||
line: PaddleCheckLine,
|
||||
boat: PaddleBoatType | null,
|
||||
reviews: readonly StoredObstacleReview[],
|
||||
): Promise<{ entries: TourObstacleEntry[]; own: TourObstacleCheck['own']; check: PaddleCheck }> {
|
||||
let entries: TourObstacleEntry[];
|
||||
let own: TourObstacleCheck['own'];
|
||||
let data: Pick<PaddleCheck, 'data' | 'data_as_of' | 'read_at'>;
|
||||
try {
|
||||
const evaluation = await this.evaluate(line, boat);
|
||||
entries = obstacleEntriesOf({ ...evaluation, reviews });
|
||||
own = evaluation.own;
|
||||
data = evaluation.data;
|
||||
} catch (err) {
|
||||
logWarn(`[Paddle] checklist without data: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Without the data every decision stays visible, as one TREK cannot compare now.
|
||||
entries = obstacleEntriesOf({ candidates: [], reviews, dataKeys: new Set(), complete: false, line });
|
||||
own = [];
|
||||
data = UNAVAILABLE;
|
||||
}
|
||||
entries = entries.slice(0, PADDLE_CHECK_MAX_ENTRIES);
|
||||
const summary = paddleCheckSummaryOf(entries, own, data);
|
||||
return { entries, own, check: { ...summary, revision: shortHash(JSON.stringify({ entries, own, data })) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours list with the bench of every paddle tour; hikes go through
|
||||
* untouched, and a trip without paddle tours asks nothing. A tour whose
|
||||
* check fails reads as unavailable; the list itself never fails over it.
|
||||
*/
|
||||
async forTrip(tripId: number, items: TourListItem[]): Promise<TourListItem[]> {
|
||||
if (!items.some((item) => item.tour_type === 'kayak')) return items;
|
||||
const [lines, waypoints, rows] = await Promise.all([
|
||||
this.toursRepo.listPaddleLinesForTrip(tripId),
|
||||
this.waypointsRepo.listForTrip(tripId),
|
||||
this.reviewsRepo.listForTrip(tripId),
|
||||
]);
|
||||
const geometry = new Map(lines.map((line) => [line.place_id, line.route_geometry]));
|
||||
return await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (item.tour_type !== 'kayak') return item;
|
||||
const line = this.lineOf(
|
||||
item,
|
||||
waypoints.filter((point) => point.place_id === item.place_id),
|
||||
geometry.get(item.place_id) ?? null,
|
||||
);
|
||||
if (!line) return { ...item, paddle_check: null };
|
||||
const reviews = rows
|
||||
.filter((row) => row.place_id === item.place_id)
|
||||
.flatMap((row) => storedReviewOf(row) ?? []);
|
||||
const { check } = await this.checkOf(line, item.boat_type ?? null, reviews);
|
||||
return { ...item, paddle_check: check };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** One paddle tour with its bench and its whole checklist; any other tour as it is. */
|
||||
async forTour(
|
||||
item: TourListItem,
|
||||
waypoints: readonly TourWaypointRow[],
|
||||
routeGeometry: string | null,
|
||||
): Promise<{ item: TourListItem; obstacle_check?: TourObstacleCheck }> {
|
||||
if (item.tour_type !== 'kayak') return { item };
|
||||
const line = this.lineOf(item, waypoints, routeGeometry);
|
||||
if (!line) return { item: { ...item, paddle_check: null } };
|
||||
const reviews = (await this.reviewsRepo.listForPlace(item.place_id)).flatMap((row) => storedReviewOf(row) ?? []);
|
||||
const { entries, own, check } = await this.checkOf(line, item.boat_type ?? null, reviews);
|
||||
return {
|
||||
item: { ...item, paddle_check: check },
|
||||
obstacle_check: { revision: check.revision, evaluated_at: new Date().toISOString(), entries, own },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto';
|
||||
import { TourObstacleReviewsDto } from './dto/tour-obstacle-reviews.dto';
|
||||
import { TourPaddlePlanDto } from './dto/tour-paddle-plan.dto';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
@@ -25,6 +29,7 @@ export class ToursController {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly paddlePlans: TourPaddlePlanService,
|
||||
private readonly obstacleReviews: TourObstacleReviewsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -87,6 +92,24 @@ export class ToursController {
|
||||
return await this.paddlePlans.update(tripId, placeId, body, socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-06: what the paddlers decided about the obstacles of a paddle tour,
|
||||
* each bound to the route and the data the user saw. Answers the detail
|
||||
* with its checklist; 503 while the obstacle data cannot be read.
|
||||
*/
|
||||
@RequirePermission('place_edit')
|
||||
@Put(':placeId/obstacle-reviews')
|
||||
@ResponseContract(tourDetailResponseSchema)
|
||||
async saveObstacleReviews(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('placeId') placeId: string,
|
||||
@Body() body: TourObstacleReviewsDto,
|
||||
@CurrentUser() user: User,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.obstacleReviews.save(tripId, placeId, body, user.id, socketId);
|
||||
}
|
||||
|
||||
/** Takes an import planned over in place back to its recorded track; the planning is dropped. */
|
||||
@RequirePermission('place_edit')
|
||||
@Post(':placeId/restore-recorded')
|
||||
|
||||
@@ -33,9 +33,14 @@ export function parseRouteGeometry(routeGeometry: string | null | undefined): Ge
|
||||
/** Confidence threshold: below this, the tour imports with a caution flag. */
|
||||
export const LOW_CONFIDENCE_THRESHOLD = 0.5;
|
||||
|
||||
/** A short stable hash of a text, in hex: the paddle checklist's source basis and revisions take it. */
|
||||
export function shortHash(text: string, length = 16): string {
|
||||
return createHash('sha256').update(text).digest('hex').slice(0, length);
|
||||
}
|
||||
|
||||
/** A short stable fingerprint of one part of a trail. */
|
||||
function fingerprint(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
||||
return shortHash(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** Coordinates to five places (a metre) and heights to the metre, so a re-export with other float noise reads the same. */
|
||||
|
||||
@@ -46,7 +46,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -61,7 +61,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'get_tour_plan',
|
||||
description:
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. The full track geometry is not included.',
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. The full track geometry is not included.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
|
||||
import { TourSources } from '../../db/entities/TourSources.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { PaddleCheckCache } from './tour-paddle-check.cache';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
@@ -36,9 +41,19 @@ import { Module } from '@nestjs/common';
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
WandererModule,
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, Places]),
|
||||
// The obstacle data a paddle tour's checklist reads (PAD-06, PAD-09).
|
||||
PaddleObstaclesModule,
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, TourObstacleReviews, Places]),
|
||||
],
|
||||
controllers: [ToursController, ToursImportController, ToursWandererController],
|
||||
providers: [ToursService, TourPaddlePlanService, ToursWandererService, ToursMcp],
|
||||
providers: [
|
||||
ToursService,
|
||||
TourPaddlePlanService,
|
||||
PaddleCheckCache,
|
||||
TourPaddleCheckService,
|
||||
TourObstacleReviewsService,
|
||||
ToursWandererService,
|
||||
ToursMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import {
|
||||
computeTourMetrics,
|
||||
localHashOf,
|
||||
@@ -73,6 +74,7 @@ export class ToursService {
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly placeImport: PlaceImportService,
|
||||
private readonly paddleChecks: TourPaddleCheckService,
|
||||
) {}
|
||||
|
||||
private toItem(r: TourListRow): TourListItem {
|
||||
@@ -169,12 +171,15 @@ export class ToursService {
|
||||
}
|
||||
}
|
||||
|
||||
/** All tours (the facet + owning place) for a trip, newest first. */
|
||||
/** All tours (the facet + owning place) for a trip, newest first; a paddle tour with its check bench (PAD-09). */
|
||||
async listTours(tripId: string): Promise<TourListItem[]> {
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return [];
|
||||
const rows = await this.toursRepo.listForTrip(tid);
|
||||
return rows.map((r) => this.toItem(r));
|
||||
return await this.paddleChecks.forTrip(
|
||||
tid,
|
||||
rows.map((r) => this.toItem(r)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The days every tour of the trip sits on, for the MCP read tools. */
|
||||
@@ -189,13 +194,16 @@ export class ToursService {
|
||||
const row = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined;
|
||||
if (!row || tid === null) throw new NotFoundException('Tour not found');
|
||||
|
||||
let waypoints: TourWaypoint[] = await this.waypointsRepo.listForPlace(row.place_id);
|
||||
const stored: TourWaypoint[] = await this.waypointsRepo.listForPlace(row.place_id);
|
||||
let waypoints = stored;
|
||||
// A recorded paddle track's checklist reads its line, so the place is read for it as for the controls below.
|
||||
const place =
|
||||
waypoints.length < 2 || row.tour_type === 'kayak' ? await this.placesRepo.findInTrip(row.place_id, tid) : null;
|
||||
// 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 = await this.placesRepo.findInTrip(row.place_id, tid);
|
||||
const geometry = parseRouteGeometry(place?.route_geometry);
|
||||
if (geometry.length >= 2) {
|
||||
const start = geometry[0];
|
||||
@@ -207,7 +215,13 @@ export class ToursService {
|
||||
}
|
||||
}
|
||||
|
||||
return { tour: this.toItem(row), waypoints, provenance: await this.provenanceOf(tid, row.place_id) };
|
||||
const checked = await this.paddleChecks.forTour(this.toItem(row), stored, place?.route_geometry ?? null);
|
||||
return {
|
||||
tour: checked.item,
|
||||
waypoints,
|
||||
provenance: await this.provenanceOf(tid, row.place_id),
|
||||
...(checked.obstacle_check ? { obstacle_check: checked.obstacle_check } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +303,13 @@ export class ToursService {
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
return { tour: this.toItem(row), waypoints, provenance: await this.provenanceOf(tid, placeId) };
|
||||
const checked = await this.paddleChecks.forTour(this.toItem(row), waypoints, place.route_geometry ?? null);
|
||||
return {
|
||||
tour: checked.item,
|
||||
waypoints,
|
||||
provenance: await this.provenanceOf(tid, placeId),
|
||||
...(checked.obstacle_check ? { obstacle_check: checked.obstacle_check } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,6 +432,8 @@ export class ToursService {
|
||||
origin_file_name: provenance.origin_file_name,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, await this.waypointsRepo.listForPlace(pid));
|
||||
// The copy keeps the decisions about its obstacles, bound to what they were decided on.
|
||||
await this.toursRepo.copyObstacleReviews(pid, id);
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -476,7 +498,8 @@ export class ToursService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private broadcastToursChanged(tripId: string, placeIds: number[], socketId?: string): void {
|
||||
/** Tells the trip's other clients to reload these tours; a failed notice never fails the committed write. */
|
||||
broadcastToursChanged(tripId: string, placeIds: number[], socketId?: string): void {
|
||||
try {
|
||||
this.places.broadcast(tripId, 'tours:changed', { placeIds }, socketId);
|
||||
} catch {
|
||||
|
||||
@@ -733,9 +733,8 @@ export class TripsService {
|
||||
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
|
||||
}
|
||||
|
||||
// A Tour is a place plus its `tours` facet, its import record and the waypoints its route was planned
|
||||
// through; without them the copy is a plain place with a line, no Tour, its route not editable.
|
||||
// The facet row goes first, since the waypoints and the import record reference it.
|
||||
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions and the waypoints its
|
||||
// route was planned through. The facet row goes first, since the others reference it; without them it is no Tour.
|
||||
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
|
||||
const copiedTours = new Map<number, number>();
|
||||
for (const { place_id, ...tour } of oldTours) {
|
||||
@@ -745,6 +744,7 @@ export class TripsService {
|
||||
copiedTours.set(place_id, newPlaceId);
|
||||
}
|
||||
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copySource(from, to)));
|
||||
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyObstacleReviews(from, to)));
|
||||
|
||||
const waypointsByPlace = new Map<number, TourWaypointRow[]>();
|
||||
for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) {
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
import { db } from '../../src/db/database';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Days } from '../../src/db/entities/Days.entity';
|
||||
import { PaddleFeatures } from '../../src/db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../src/db/entities/PaddleRegions.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { TourObstacleReviews } from '../../src/db/entities/TourObstacleReviews.entity';
|
||||
import { TourSources } from '../../src/db/entities/TourSources.entity';
|
||||
import { TourTypes } from '../../src/db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
@@ -45,7 +48,7 @@ import { Test } from '@nestjs/testing';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -112,6 +115,14 @@ const BIG_GPX = TRACK_GPX.replace('</gpx>', `<!-- ${'x'.repeat(512 * 1024)} --><
|
||||
/** A paddle plan without marks: enough for the gates, which answer before the body is read. */
|
||||
const NO_MARKS = { paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } };
|
||||
|
||||
/** A change to a paddle tour's checklist: enough for the gates, which answer before the body is read. */
|
||||
const NO_OPS = { ops: [{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }] };
|
||||
|
||||
/** A body as a second read answers it: a paddle tour's checklist is compared anew on every read, so its time moves. */
|
||||
function readAgain(body: { obstacle_check?: { evaluated_at: string } }): unknown {
|
||||
return body.obstacle_check ? { ...body, obstacle_check: { ...body.obstacle_check, evaluated_at: 'read' } } : body;
|
||||
}
|
||||
|
||||
describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
@@ -224,6 +235,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/tours')).status).toBe(401);
|
||||
expect((await request(server).put('/api/trips/5/tours/1/paddle-plan').send(NO_MARKS)).status).toBe(401);
|
||||
expect((await request(server).put('/api/trips/5/tours/1/obstacle-reviews').send(NO_OPS)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('404 on every route while the addon is disabled', async () => {
|
||||
@@ -242,6 +254,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
await importGpx(OWNER, BIG_GPX),
|
||||
await previewGpx(OWNER, BIG_GPX),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/paddle-plan`).set('Cookie', cookie).send(NO_MARKS),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/obstacle-reviews`).set('Cookie', cookie).send(NO_OPS),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
@@ -262,6 +275,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
await importGpx(OUTSIDER, BIG_GPX),
|
||||
await previewGpx(OUTSIDER, BIG_GPX),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/paddle-plan`).set('Cookie', cookie).send(NO_MARKS),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/obstacle-reviews`).set('Cookie', cookie).send(NO_OPS),
|
||||
await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)),
|
||||
];
|
||||
for (const res of responses) {
|
||||
@@ -289,6 +303,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
await importGpx(VIEWER, BIG_GPX),
|
||||
await previewGpx(VIEWER, BIG_GPX),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/paddle-plan`).set('Cookie', cookie).send(NO_MARKS),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}/obstacle-reviews`).set('Cookie', cookie).send(NO_OPS),
|
||||
];
|
||||
for (const res of writes) {
|
||||
expect(res.status).toBe(403);
|
||||
@@ -574,7 +589,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/5/tours/${res.body.tour.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body).toEqual(res.body);
|
||||
expect(readAgain(detail.body)).toEqual(readAgain(res.body));
|
||||
});
|
||||
|
||||
it('a change of boat keeps the chosen speed, and a paddle tour can become a hike again', async () => {
|
||||
@@ -696,7 +711,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/5/tours/${res.body.tour.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body).toEqual(res.body);
|
||||
expect(readAgain(detail.body)).toEqual(readAgain(res.body));
|
||||
});
|
||||
|
||||
it('400 for a portage, a lock or a carrying speed on a hike; nothing written', async () => {
|
||||
@@ -781,6 +796,129 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the obstacle checklist (PAD-06, PAD-09)', () => {
|
||||
/** A paddle tour along the GEOMETRY line, a weir in the middle of its first leg. */
|
||||
const paddleTour = () =>
|
||||
createViaApi(
|
||||
5,
|
||||
tourBody({
|
||||
tour_type: 'kayak',
|
||||
max_hiking_difficulty: null,
|
||||
boat_type: 'kayak',
|
||||
water_context: 'river',
|
||||
}),
|
||||
);
|
||||
const reviewsOf = (placeId: number, body: Record<string, unknown>, userId = OWNER) =>
|
||||
request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}/obstacle-reviews`)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send(body);
|
||||
const detailOf = async (placeId: number, tripId = 5) =>
|
||||
(await request(server).get(`/api/trips/${tripId}/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
||||
|
||||
beforeEach(async () => {
|
||||
await insertRows(orm, PaddleRegions, [
|
||||
{
|
||||
id: 'tirol',
|
||||
name: 'Tirol',
|
||||
boundary: null,
|
||||
min_lat: 46.9,
|
||||
min_lng: 10.9,
|
||||
max_lat: 47.1,
|
||||
max_lng: 11.1,
|
||||
status: 'ready',
|
||||
feature_count: 1,
|
||||
source_timestamp: '2026-10-01T00:00:00.000Z',
|
||||
imported_at: '2026-10-02 04:20:00',
|
||||
},
|
||||
]);
|
||||
await insertRows(orm, PaddleFeatures, [
|
||||
{
|
||||
region: 'tirol',
|
||||
osm_ref: 'node/1',
|
||||
kind: 'weir',
|
||||
group_key: null,
|
||||
name: 'Wehr',
|
||||
geometry: JSON.stringify({ type: 'Point', coordinates: [11.005, 47.005] }),
|
||||
tags: JSON.stringify({ waterway: 'weir', name: 'Wehr' }),
|
||||
tag_hash: 'hash-1',
|
||||
min_lat: 47.005,
|
||||
min_lng: 11.005,
|
||||
max_lat: 47.005,
|
||||
max_lng: 11.005,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteRows(orm, PaddleRegions);
|
||||
});
|
||||
|
||||
it('lists the weir as open, saves a decision against its basis, and the list carries the bench', async () => {
|
||||
const placeId = await paddleTour();
|
||||
const before = await detailOf(placeId);
|
||||
expect(before.tour.paddle_check).toMatchObject({ data: 'covered', entries: 1, open: 1, decided: 0 });
|
||||
const [entry] = before.obstacle_check.entries;
|
||||
expect(entry).toMatchObject({ key: 'node/1', status: 'open', relation: 'on_route' });
|
||||
|
||||
broadcast.mockClear();
|
||||
const res = await reviewsOf(placeId, {
|
||||
ops: [{ key: 'node/1', op: 'decide', decision: 'pass', note: 'Rechts ueber die Rampe', basis: entry.basis }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.obstacle_check.entries[0]).toMatchObject({
|
||||
status: 'decided',
|
||||
decision: { decision: 'pass', note: 'Rechts ueber die Rampe', decided_by: OWNER },
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'tours:changed', { placeIds: [placeId] }, 'sock-1');
|
||||
expect(await countRows(orm, TourObstacleReviews, { place: placeId })).toBe(1);
|
||||
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.body.tours[0].paddle_check).toMatchObject({ open: 0, decided: 1 });
|
||||
});
|
||||
|
||||
it('400 for a hike, for a decision without its basis and for one obstacle twice; 403 without place_edit', async () => {
|
||||
const hike = await createViaApi();
|
||||
const refused = await reviewsOf(hike, NO_OPS);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.error).toBe('Only a paddle tour has an obstacle checklist');
|
||||
|
||||
const placeId = await paddleTour();
|
||||
const decision = { key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: null };
|
||||
expect((await reviewsOf(placeId, { ops: [decision] })).status).toBe(400);
|
||||
expect((await reviewsOf(placeId, { ops: [NO_OPS.ops[0], NO_OPS.ops[0]] })).status).toBe(400);
|
||||
expect((await reviewsOf(placeId, { ops: [] })).status).toBe(400);
|
||||
expect((await reviewsOf(placeId, NO_OPS, VIEWER)).status).toBe(403);
|
||||
expect(await countRows(orm, TourObstacleReviews)).toBe(0);
|
||||
});
|
||||
|
||||
it('copying the trip carries the decisions, still bound to what they were decided on', async () => {
|
||||
const placeId = await paddleTour();
|
||||
const [entry] = (await detailOf(placeId)).obstacle_check.entries;
|
||||
expect(
|
||||
(
|
||||
await reviewsOf(placeId, {
|
||||
ops: [{ key: 'node/1', op: 'decide', decision: 'portage', note: null, basis: entry.basis }],
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/copy')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ title: 'Regen again' });
|
||||
expect(res.status).toBe(201);
|
||||
const copyId = res.body.trip.id as number;
|
||||
const [copied] = (await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER)))
|
||||
.body.tours;
|
||||
expect((await detailOf(copied.place_id, copyId)).obstacle_check.entries[0]).toMatchObject({
|
||||
key: 'node/1',
|
||||
status: 'todo',
|
||||
decision: { decision: 'portage' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPX preview and typed import (PAD-02)', () => {
|
||||
it('shows every track with its whole line and measures, and stores nothing', async () => {
|
||||
const res = await previewGpx(OWNER, TRACK_GPX);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* A TourPaddleCheckService for suites that build ToursService by hand and are
|
||||
* not about paddle checks: the list and the detail pass through as they are.
|
||||
* It takes nothing in its constructor, so lint:test-new-service does not count
|
||||
* it; the paddle checks have their own suites over the real service.
|
||||
*/
|
||||
import type { TourPaddleCheckService } from '../../src/nest/tours/tour-paddle-check.service';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
|
||||
export class NoPaddleChecks {
|
||||
async forTrip(_tripId: number, items: TourListItem[]): Promise<TourListItem[]> {
|
||||
return items;
|
||||
}
|
||||
|
||||
async forTour(item: TourListItem): Promise<{ item: TourListItem }> {
|
||||
return { item };
|
||||
}
|
||||
}
|
||||
|
||||
/** The pass-through as the service ToursService takes. */
|
||||
export function noPaddleChecks(): TourPaddleCheckService {
|
||||
return new NoPaddleChecks() as unknown as TourPaddleCheckService;
|
||||
}
|
||||
@@ -173,4 +173,13 @@ describe('PaddleFeaturesRepository', () => {
|
||||
const capped = await features.findInBox({ south: 49, west: 12, north: 50, east: 13 }, 2);
|
||||
expect(capped.map((row) => row.osm_ref)).toEqual(['node/1', 'node/2']);
|
||||
});
|
||||
|
||||
it('PADFEATREPO-003: counts the objects and names the newest row, nothing for an empty table', async () => {
|
||||
expect(await features.stats()).toEqual({ count: 0, max_id: null });
|
||||
await add('oberpfalz');
|
||||
await features.replaceRegion('oberpfalz', [feature('node/1', 49.5, 12.5), feature('node/2', 49.6, 12.6)]);
|
||||
const stats = await features.stats();
|
||||
expect(stats.count).toBe(2);
|
||||
expect(stats.max_id).toEqual(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* `TourObstacleReviewsRepository` (PAD-06) and the Tours repository's reads
|
||||
* and copies for the paddle checklist: decisions written and replaced per
|
||||
* obstacle, read per tour and per trip, removed by key, the lines of a
|
||||
* trip's paddle tours, and a copy taking its decisions along.
|
||||
*/
|
||||
import { TourObstacleReviews } from '../../../../src/db/entities/TourObstacleReviews.entity';
|
||||
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
|
||||
import { Tours } from '../../../../src/db/entities/Tours.entity';
|
||||
import type {
|
||||
TourObstacleReviewsRepository,
|
||||
TourObstacleReviewWrite,
|
||||
} from '../../../../src/db/repositories/TourObstacleReviews.repository';
|
||||
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { makePlace } from '../../../helpers/factories/places';
|
||||
import { findRows, insertRow } from '../../../helpers/factories/rows';
|
||||
import { makeTour } from '../../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../../helpers/factories/trips';
|
||||
import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let reviews: TourObstacleReviewsRepository;
|
||||
let tours: ToursRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
reviews = t.repo(TourObstacleReviews);
|
||||
tours = t.repo(Tours);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function review(key: string, overrides: Partial<TourObstacleReviewWrite> = {}): TourObstacleReviewWrite {
|
||||
return {
|
||||
obstacle_key: key,
|
||||
decision: 'pass',
|
||||
note: null,
|
||||
route_fingerprint: 'p1:o:_',
|
||||
source_fingerprint: 's1:0123456789abcdef:4920000,1200500',
|
||||
obstacle_snapshot: '{"v":1}',
|
||||
decided_at: '2026-10-03 10:00:00',
|
||||
decided_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(t);
|
||||
const trip = await makeTrip(t, user.id);
|
||||
const other = await makeTrip(t, user.id);
|
||||
const regen = await makePlace(t, trip.id, { name: 'Regen', route_geometry: '[[49.2,12],[49.2,12.01]]' });
|
||||
await makeTour(t, regen.id, { tourTypeRef: 'kayak' });
|
||||
const naab = await makePlace(t, trip.id, { name: 'Naab' });
|
||||
await makeTour(t, naab.id, { tourTypeRef: 'kayak' });
|
||||
const ridge = await makePlace(t, trip.id, { name: 'Ridge', route_geometry: '[[47,11],[47.1,11.1]]' });
|
||||
await makeTour(t, ridge.id);
|
||||
const foreign = await makePlace(t, other.id, { name: 'Foreign' });
|
||||
await makeTour(t, foreign.id, { tourTypeRef: 'kayak' });
|
||||
return { user, trip, other, regen, naab, ridge, foreign };
|
||||
}
|
||||
|
||||
describe('TourObstacleReviewsRepository', () => {
|
||||
it('TOREV-001: writes decisions and replaces the one stored for an obstacle, read back by key', async () => {
|
||||
const { user, regen } = await fixture();
|
||||
await reviews.writeMany(regen.id, [review('way/7'), review('node/1', { decided_by: user.id })]);
|
||||
await reviews.writeMany(regen.id, [
|
||||
review('node/1', { decision: 'portage', note: 'Links', decided_at: '2026-10-04 08:00:00' }),
|
||||
]);
|
||||
|
||||
expect(await reviews.listForPlace(regen.id)).toEqual([
|
||||
{
|
||||
place_id: regen.id,
|
||||
...review('node/1', { decision: 'portage', note: 'Links', decided_at: '2026-10-04 08:00:00' }),
|
||||
},
|
||||
{ place_id: regen.id, ...review('way/7') },
|
||||
]);
|
||||
});
|
||||
|
||||
it("TOREV-002: reads the decisions of a trip's tours, never another trip's", async () => {
|
||||
const { trip, regen, naab, foreign } = await fixture();
|
||||
await reviews.writeMany(naab.id, [review('node/2')]);
|
||||
await reviews.writeMany(regen.id, [review('node/1')]);
|
||||
await reviews.writeMany(foreign.id, [review('node/3')]);
|
||||
|
||||
expect((await reviews.listForTrip(trip.id)).map((row) => [row.place_id, row.obstacle_key])).toEqual([
|
||||
[regen.id, 'node/1'],
|
||||
[naab.id, 'node/2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOREV-003: removes the decisions about the given obstacles and says how many went', async () => {
|
||||
const { regen, naab } = await fixture();
|
||||
await reviews.writeMany(regen.id, [review('node/1'), review('node/2'), review('lock:way/5')]);
|
||||
await reviews.writeMany(naab.id, [review('node/1')]);
|
||||
|
||||
expect(await reviews.deleteKeys(regen.id, ['node/1', 'lock:way/5', 'node/9'])).toBe(2);
|
||||
expect((await reviews.listForPlace(regen.id)).map((row) => row.obstacle_key)).toEqual(['node/2']);
|
||||
expect(await reviews.listForPlace(naab.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursRepository for the paddle checklist', () => {
|
||||
it("TOREV-004: lists the line of every paddle tour of a trip, hikes and other trips' tours left out", async () => {
|
||||
const { trip, regen, naab } = await fixture();
|
||||
expect(await tours.listPaddleLinesForTrip(trip.id)).toEqual([
|
||||
{ place_id: regen.id, route_geometry: '[[49.2,12],[49.2,12.01]]' },
|
||||
{ place_id: naab.id, route_geometry: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOREV-005: a copy takes the decisions along, beside the source record a trip copy takes', async () => {
|
||||
const { user, trip, regen } = await fixture();
|
||||
await reviews.writeMany(regen.id, [review('node/1', { decided_by: user.id, note: 'Rechts' })]);
|
||||
await insertRow(t, TourSources, {
|
||||
place: regen.id,
|
||||
provider: 'wanderer',
|
||||
instance: 'https://wanderer.example',
|
||||
external_id: 'trail-1',
|
||||
content_hash: 'a.b.c',
|
||||
local_hash: 'l',
|
||||
imported_at: '2026-10-01 10:00:00',
|
||||
});
|
||||
const copy = await makePlace(t, trip.id, { name: 'Regen copy' });
|
||||
await makeTour(t, copy.id, { tourTypeRef: 'kayak' });
|
||||
await tours.copyObstacleReviews(regen.id, copy.id);
|
||||
expect(await reviews.listForPlace(copy.id)).toEqual([
|
||||
{ place_id: copy.id, ...review('node/1', { decided_by: user.id, note: 'Rechts' }) },
|
||||
]);
|
||||
|
||||
const tripCopy = await makePlace(t, trip.id, { name: 'Regen in the copied trip' });
|
||||
await makeTour(t, tripCopy.id, { tourTypeRef: 'kayak' });
|
||||
await Promise.all([tours.copySource(regen.id, tripCopy.id), tours.copyObstacleReviews(regen.id, tripCopy.id)]);
|
||||
expect(await reviews.listForPlace(tripCopy.id)).toHaveLength(1);
|
||||
expect(await findRows(t, TourSources, { place: tripCopy.id })).toEqual([
|
||||
expect.objectContaining({ external_id: 'trail-1' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
boundaryContains,
|
||||
boxOfBoundary,
|
||||
coverageOf,
|
||||
coverageOfLine,
|
||||
parseBox,
|
||||
type RegionBoundary,
|
||||
} from '../../../../src/nest/paddle-obstacles/paddle-geo';
|
||||
@@ -154,3 +155,87 @@ describe('boxOfBoundary', () => {
|
||||
expect(boxOfBoundary(twoIslands)).toEqual({ south: 0, west: 0, north: 6, east: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverageOfLine', () => {
|
||||
const region = { boundary: holed, box: null };
|
||||
|
||||
it('PGEO-013: a line wholly inside a region is covered, one leaving it partly, one outside every region not at all', () => {
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[49.1, 11.2],
|
||||
[49.2, 11.4],
|
||||
],
|
||||
[region],
|
||||
),
|
||||
).toBe('covered');
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[49.9, 12.5],
|
||||
[50.2, 12.5],
|
||||
],
|
||||
[region],
|
||||
),
|
||||
).toBe('partial');
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[48.1, 12.5],
|
||||
[48.2, 12.5],
|
||||
],
|
||||
[region],
|
||||
),
|
||||
).toBe('none');
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[49.1, 11.2],
|
||||
[49.2, 11.4],
|
||||
],
|
||||
[],
|
||||
),
|
||||
).toBe('none');
|
||||
expect(coverageOfLine([], [region])).toBe('none');
|
||||
});
|
||||
|
||||
it('PGEO-014: samples a long straight leg along its length, so crossing the hole is seen', () => {
|
||||
// Both ends lie in the region, the middle runs through the hole at 49.5 north.
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[49.5, 11.5],
|
||||
[49.5, 12.5],
|
||||
],
|
||||
[region],
|
||||
),
|
||||
).toBe('partial');
|
||||
});
|
||||
|
||||
it('PGEO-015: a region without a boundary counts by its box, and by nothing without one', () => {
|
||||
const boxed = { boundary: null, box: { south: 0, west: 0, north: 1, east: 1 } };
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.8, 0.8],
|
||||
],
|
||||
[boxed],
|
||||
),
|
||||
).toBe('covered');
|
||||
expect(
|
||||
coverageOfLine(
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.8, 0.8],
|
||||
],
|
||||
[{ boundary: null, box: null }],
|
||||
),
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('PGEO-016: thins a very long line to a bounded number of samples', () => {
|
||||
const long = Array.from({ length: 2000 }, (_, i): [number, number] => [49.1 + i * 0.0004, 11.6]);
|
||||
expect(coverageOfLine(long, [region])).toBe('covered');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
PaddleObstaclesService,
|
||||
} from '../../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { deleteRows, insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
@@ -207,3 +207,74 @@ describe('PaddleObstaclesService.inBox', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('PaddleObstaclesService.readAlong', () => {
|
||||
it('PADOBS-011: reads a long line run by run, answers an object two runs or regions hold once, and covers the line itself', async () => {
|
||||
await region('oberpfalz');
|
||||
await region('bayern');
|
||||
// A line 0.3 degrees east, three runs: a weir at its start, one in its middle, held twice.
|
||||
await insertRows(orm(), PaddleFeatures, [
|
||||
weir('oberpfalz', 'node/1', 49.2, 12.0),
|
||||
weir('oberpfalz', 'node/2', 49.2, 12.15),
|
||||
weir('bayern', 'node/2', 49.2, 12.15),
|
||||
weir('oberpfalz', 'node/3', 49.6, 12.15),
|
||||
]);
|
||||
const along = await service.readAlong([
|
||||
[49.2, 12.0],
|
||||
[49.2, 12.3],
|
||||
]);
|
||||
expect(along.features.map((feature) => feature.ref).sort()).toEqual(['node/1', 'node/2']);
|
||||
expect(along.regions.map((entry) => entry.id).sort()).toEqual(['bayern', 'oberpfalz']);
|
||||
expect(along).toMatchObject({ coverage: 'covered', truncated: false });
|
||||
});
|
||||
|
||||
it('PADOBS-012: a line past the edge of the data is partly covered, one outside every region not at all', async () => {
|
||||
await region('oberpfalz');
|
||||
expect(
|
||||
(
|
||||
await service.readAlong([
|
||||
[49.9, 12.5],
|
||||
[50.2, 12.5],
|
||||
])
|
||||
).coverage,
|
||||
).toBe('partial');
|
||||
expect(
|
||||
await service.readAlong([
|
||||
[53.2, 12.8],
|
||||
[53.25, 12.9],
|
||||
]),
|
||||
).toEqual({ features: [], regions: [], coverage: 'none', truncated: false });
|
||||
});
|
||||
|
||||
it('PADOBS-013: a run cut at the cap marks the whole answer as cut', async () => {
|
||||
await region('oberpfalz');
|
||||
const many = Array.from({ length: PADDLE_OBSTACLES_CAP + 1 }, (_, i) =>
|
||||
weir('oberpfalz', `node/${i + 1}`, 49.2 + (i % 50) * 0.0001, 12.1 + Math.floor(i / 50) * 0.0001),
|
||||
);
|
||||
await insertRows(orm(), PaddleFeatures, many);
|
||||
const along = await service.readAlong([
|
||||
[49.2, 12.1],
|
||||
[49.21, 12.11],
|
||||
]);
|
||||
expect(along.truncated).toBe(true);
|
||||
expect(along.features).toHaveLength(PADDLE_OBSTACLES_CAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleObstaclesService.dataVersion', () => {
|
||||
it('PADOBS-014: changes when objects are added or removed and when a region is read again or removed', async () => {
|
||||
const empty = await service.dataVersion();
|
||||
await region('oberpfalz');
|
||||
const withRegion = await service.dataVersion();
|
||||
expect(withRegion).not.toBe(empty);
|
||||
await insertRows(orm(), PaddleFeatures, [weir('oberpfalz', 'node/1', 49.2, 12.0)]);
|
||||
const withObject = await service.dataVersion();
|
||||
expect(withObject).not.toBe(withRegion);
|
||||
expect(await service.dataVersion()).toBe(withObject);
|
||||
await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { imported_at: '2026-11-03 04:20:00' });
|
||||
const readAgain = await service.dataVersion();
|
||||
expect(readAgain).not.toBe(withObject);
|
||||
await deleteRows(orm(), PaddleRegions, { id: 'oberpfalz' });
|
||||
expect(await service.dataVersion()).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,3 +83,21 @@ describe('keptTags and paddleNameOf', () => {
|
||||
expect(paddleNameOf({ waterway: 'weir' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keptTags: checks on site', () => {
|
||||
it('PTAG-006: keeps when someone last looked on site, the dated variants included', () => {
|
||||
expect(
|
||||
keptTags({
|
||||
waterway: 'weir',
|
||||
check_date: '2025-06-01',
|
||||
'survey:date': '2024-05',
|
||||
'check_date:canoe': '2026-04-02',
|
||||
}),
|
||||
).toEqual({
|
||||
waterway: 'weir',
|
||||
check_date: '2025-06-01',
|
||||
'survey:date': '2024-05',
|
||||
'check_date:canoe': '2026-04-02',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { TourObstacleReviewsService } from '../../../src/nest/tours/tour-obstacle-reviews.service';
|
||||
import type { TourPaddlePlanService } from '../../../src/nest/tours/tour-paddle-plan.service';
|
||||
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
||||
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
||||
@@ -100,6 +101,7 @@ function importFixture(
|
||||
}
|
||||
|
||||
const paddlePlansStub = { update: vi.fn() } as unknown as TourPaddlePlanService;
|
||||
const reviewsStub = { save: vi.fn() } as unknown as TourObstacleReviewsService;
|
||||
|
||||
describe('ToursController planner routes', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
@@ -119,6 +121,7 @@ describe('ToursController planner routes', () => {
|
||||
['create', RequestMethod.POST, '/', 'place_edit'],
|
||||
['update', RequestMethod.PUT, ':placeId', 'place_edit'],
|
||||
['updatePaddlePlan', RequestMethod.PUT, ':placeId/paddle-plan', 'place_edit'],
|
||||
['saveObstacleReviews', RequestMethod.PUT, ':placeId/obstacle-reviews', 'place_edit'],
|
||||
] as const;
|
||||
for (const [handler, method, path, permission] of routes) {
|
||||
const fn = ToursController.prototype[handler];
|
||||
@@ -133,7 +136,7 @@ describe('ToursController planner routes', () => {
|
||||
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service, paddlePlansStub);
|
||||
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
||||
|
||||
expect(await controller.create('7', request, 'socket-1')).toBe(result);
|
||||
expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1');
|
||||
@@ -142,7 +145,7 @@ describe('ToursController planner routes', () => {
|
||||
it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service, paddlePlansStub);
|
||||
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
||||
|
||||
expect(await controller.detail('7', '1')).toBe(result);
|
||||
expect(service.getTour).toHaveBeenCalledWith('7', '1');
|
||||
@@ -151,7 +154,7 @@ describe('ToursController planner routes', () => {
|
||||
it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service, paddlePlansStub);
|
||||
const controller = new ToursController(service, paddlePlansStub, reviewsStub);
|
||||
|
||||
expect(await controller.update('7', '1', request, 'socket-2')).toBe(result);
|
||||
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
|
||||
@@ -160,18 +163,28 @@ describe('ToursController planner routes', () => {
|
||||
it('TOURS-CTL-006: PUT /tours/:placeId/paddle-plan delegates the validated plan and socket id (PAD-03)', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: [] };
|
||||
const paddlePlans = { update: vi.fn().mockResolvedValue(result) } as unknown as TourPaddlePlanService;
|
||||
const controller = new ToursController({} as ToursService, paddlePlans);
|
||||
const controller = new ToursController({} as ToursService, paddlePlans, reviewsStub);
|
||||
const plan = { paddle_speed_kmh: 4, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } };
|
||||
|
||||
expect(await controller.updatePaddlePlan('7', '1', plan, 'socket-3')).toBe(result);
|
||||
expect(paddlePlans.update).toHaveBeenCalledWith('7', '1', plan, 'socket-3');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-007: PUT /tours/:placeId/obstacle-reviews delegates the changes with the user and socket id (PAD-06)', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: [] };
|
||||
const reviews = { save: vi.fn().mockResolvedValue(result) } as unknown as TourObstacleReviewsService;
|
||||
const controller = new ToursController({} as ToursService, paddlePlansStub, reviews);
|
||||
const body = { ops: [{ key: 'node/1', op: 'clear' as const, decision: null, note: null, basis: null }] };
|
||||
|
||||
expect(await controller.saveObstacleReviews('7', '1', body, { id: 4 } as User, 'socket-4')).toBe(result);
|
||||
expect(reviews.save).toHaveBeenCalledWith('7', '1', body, 4, 'socket-4');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => {
|
||||
const tours = [{ place_id: 1 }];
|
||||
const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService;
|
||||
|
||||
expect(await new ToursController(service, paddlePlansStub).list('7')).toEqual({ tours });
|
||||
expect(await new ToursController(service, paddlePlansStub, reviewsStub).list('7')).toEqual({ tours });
|
||||
expect(service.listTours).toHaveBeenCalledWith('7');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import {
|
||||
@@ -166,6 +167,7 @@ beforeAll(async () => {
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
buildPlaceImportService(),
|
||||
noPaddleChecks(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* PaddleCheckCache: the readings of the last lines, the least recently used
|
||||
* one dropped once it holds more than it keeps.
|
||||
*/
|
||||
import { PaddleCheckCache, type PaddleCheckEvaluation } from '../../../src/nest/tours/tour-paddle-check.cache';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function evaluation(totalM: number): PaddleCheckEvaluation {
|
||||
return {
|
||||
line: { points: [], alongM: [], totalM, portages: [], locks: [] },
|
||||
candidates: [],
|
||||
own: [],
|
||||
dataKeys: new Set(),
|
||||
complete: true,
|
||||
data: { data: 'covered', data_as_of: null, read_at: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe('PaddleCheckCache', () => {
|
||||
it('PCC-001: answers what it was given and nothing it was not', () => {
|
||||
const cache = new PaddleCheckCache();
|
||||
cache.set('a', evaluation(1));
|
||||
expect(cache.get('a')?.line.totalM).toBe(1);
|
||||
expect(cache.get('b')).toBeUndefined();
|
||||
cache.set('a', evaluation(2));
|
||||
expect(cache.get('a')?.line.totalM).toBe(2);
|
||||
});
|
||||
|
||||
it('PCC-002: past 256 readings drops the one used longest ago, a reading just read is kept', () => {
|
||||
const cache = new PaddleCheckCache();
|
||||
for (let i = 0; i < 256; i++) cache.set(`line-${i}`, evaluation(i));
|
||||
expect(cache.get('line-0')).toBeDefined();
|
||||
cache.set('line-256', evaluation(256));
|
||||
expect(cache.get('line-0')).toBeDefined();
|
||||
expect(cache.get('line-1')).toBeUndefined();
|
||||
expect(cache.get('line-256')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* PAD-06 and PAD-09 on the server, from the container over a snapshot db: a
|
||||
* paddle tour's checklist in its detail and its check bench in the tours
|
||||
* list, the cached reading of the obstacle data, the checklist while the data
|
||||
* cannot be read, and saving decisions bound to the basis the user saw.
|
||||
*/
|
||||
import { PaddleFeatures } from '../../../src/db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../../src/db/entities/PaddleRegions.entity';
|
||||
import { TourObstacleReviews } from '../../../src/db/entities/TourObstacleReviews.entity';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { PaddleObstaclesService } from '../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
||||
import {
|
||||
NOT_A_PADDLE_TOUR_MESSAGE,
|
||||
PADDLE_DATA_UNAVAILABLE_MESSAGE,
|
||||
TourObstacleReviewsService,
|
||||
} from '../../../src/nest/tours/tour-obstacle-reviews.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { deleteRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import {
|
||||
tourDetailResponseSchema,
|
||||
tourListItemSchema,
|
||||
type ObstacleDecision,
|
||||
type ObstacleReviewOp,
|
||||
type TourDetailResponse,
|
||||
type TourObstacleEntry,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/audit/audit-log.logger')>()),
|
||||
...log,
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule | undefined;
|
||||
let tours: ToursService;
|
||||
let reviews: TourObstacleReviewsService;
|
||||
let obstacles: PaddleObstaclesService;
|
||||
const orm = () => {
|
||||
if (!t) throw new Error('No container booted');
|
||||
return t.get(MikroORM);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
await t?.close();
|
||||
// A container per test: the readings it caches belong to the data of one test.
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
tours = t.get(ToursService);
|
||||
reviews = t.get(TourObstacleReviewsService);
|
||||
obstacles = t.get(PaddleObstaclesService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
async function region(id = 'oberpfalz') {
|
||||
await insertRow(orm(), PaddleRegions, {
|
||||
id,
|
||||
name: 'Oberpfalz',
|
||||
boundary: null,
|
||||
min_lat: 48.9,
|
||||
min_lng: 11.5,
|
||||
max_lat: 50,
|
||||
max_lng: 13,
|
||||
status: 'ready',
|
||||
feature_count: 1,
|
||||
source_timestamp: '2026-10-01T00:00:00.000Z',
|
||||
imported_at: '2026-10-02 04:20:00',
|
||||
});
|
||||
}
|
||||
|
||||
function weir(ref: string, lat: number, lng: number, tags: Record<string, string> = {}) {
|
||||
return {
|
||||
region: 'oberpfalz',
|
||||
osm_ref: ref,
|
||||
kind: 'weir',
|
||||
group_key: null,
|
||||
name: 'Wehr Regendorf',
|
||||
geometry: JSON.stringify({ type: 'Point', coordinates: [lng, lat] }),
|
||||
tags: JSON.stringify({ waterway: 'weir', name: 'Wehr Regendorf', ...tags }),
|
||||
tag_hash: `hash-${ref}`,
|
||||
min_lat: lat,
|
||||
min_lng: lng,
|
||||
max_lat: lat,
|
||||
max_lng: lng,
|
||||
};
|
||||
}
|
||||
|
||||
/** A trip with a paddle tour east along 49.2 north, a weir in its middle, and a hike. */
|
||||
async function fixture(options: { boat?: 'kayak' | 'sup' } = {}) {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const paddle = await makePlace(orm(), trip.id, {
|
||||
name: 'Regen',
|
||||
lat: 49.2,
|
||||
lng: 12,
|
||||
route_geometry: JSON.stringify([
|
||||
[49.2, 12.0],
|
||||
[49.2, 12.01],
|
||||
]),
|
||||
});
|
||||
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', boat_type: options.boat ?? 'kayak' });
|
||||
await addTourWaypoints(orm(), paddle.id, [
|
||||
{ lat: 49.2, lng: 12.0 },
|
||||
{ lat: 49.2, lng: 12.01 },
|
||||
]);
|
||||
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', lat: 47, lng: 11 });
|
||||
await makeTour(orm(), hike.id);
|
||||
await addTourWaypoints(orm(), hike.id, [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11.01 },
|
||||
]);
|
||||
return { user, trip, paddle, hike };
|
||||
}
|
||||
|
||||
async function detail(tripId: number, placeId: number): Promise<TourDetailResponse> {
|
||||
const read = await tours.getTour(String(tripId), String(placeId));
|
||||
expect(tourDetailResponseSchema.safeParse(read).success).toBe(true);
|
||||
return read;
|
||||
}
|
||||
|
||||
function decide(entry: Pick<TourObstacleEntry, 'key' | 'basis'>, decision: ObstacleDecision): ObstacleReviewOp {
|
||||
return { key: entry.key, op: 'decide', decision, note: null, basis: entry.basis ?? null };
|
||||
}
|
||||
|
||||
describe('TourPaddleCheckService: the checklist and the bench', () => {
|
||||
it('TPC-001: a paddle tour past a weir lists it as open, with the basis a decision would be bound to', async () => {
|
||||
const { trip, paddle } = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
|
||||
const read = await detail(trip.id, paddle.id);
|
||||
expect(read.tour.paddle_check).toMatchObject({
|
||||
data: 'covered',
|
||||
data_as_of: '2026-10-01T00:00:00.000Z',
|
||||
entries: 1,
|
||||
open: 1,
|
||||
review: 0,
|
||||
decided: 0,
|
||||
bans: 0,
|
||||
own: 0,
|
||||
});
|
||||
expect(read.obstacle_check?.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'node/1',
|
||||
kind: 'weir',
|
||||
name: 'Wehr Regendorf',
|
||||
relation: 'on_route',
|
||||
status: 'open',
|
||||
decision: null,
|
||||
basis: { route: expect.stringMatching(/^p1:o:/), source: expect.stringMatching(/^s1:/) },
|
||||
}),
|
||||
]);
|
||||
expect(read.obstacle_check?.revision).toBe(read.tour.paddle_check?.revision);
|
||||
});
|
||||
|
||||
it('TPC-002: the list carries the bench of every paddle tour and leaves hikes and lineless tours alone', async () => {
|
||||
const { trip, paddle, hike } = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
const lineless = await makePlace(orm(), trip.id, { name: 'No line' });
|
||||
await makeTour(orm(), lineless.id, { tourTypeRef: 'kayak' });
|
||||
|
||||
const list = await tours.listTours(String(trip.id));
|
||||
for (const item of list) expect(tourListItemSchema.safeParse(item).success).toBe(true);
|
||||
const byId = new Map(list.map((item) => [item.place_id, item]));
|
||||
expect(byId.get(paddle.id)?.paddle_check).toMatchObject({ data: 'covered', open: 1 });
|
||||
expect(byId.get(hike.id)).not.toHaveProperty('paddle_check');
|
||||
expect(byId.get(lineless.id)?.paddle_check).toBeNull();
|
||||
});
|
||||
|
||||
it('TPC-003: a trip without paddle tours reads no obstacle data at all', async () => {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
|
||||
await makeTour(orm(), hike.id);
|
||||
const along = vi.spyOn(obstacles, 'readAlong');
|
||||
const version = vi.spyOn(obstacles, 'dataVersion');
|
||||
|
||||
expect(await tours.listTours(String(trip.id))).toHaveLength(1);
|
||||
expect(along).not.toHaveBeenCalled();
|
||||
expect(version).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TPC-004: reads the data along a line once, and again once the data changed', async () => {
|
||||
const { trip, paddle } = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
const along = vi.spyOn(obstacles, 'readAlong');
|
||||
|
||||
await detail(trip.id, paddle.id);
|
||||
await tours.listTours(String(trip.id));
|
||||
expect(along).toHaveBeenCalledTimes(1);
|
||||
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
||||
const read = await detail(trip.id, paddle.id);
|
||||
expect(along).toHaveBeenCalledTimes(2);
|
||||
expect(read.tour.paddle_check?.open).toBe(2);
|
||||
});
|
||||
|
||||
it('TPC-005: a reading during which the data changed is not kept', async () => {
|
||||
const { trip, paddle } = await fixture();
|
||||
await region();
|
||||
const along = vi.spyOn(obstacles, 'readAlong');
|
||||
vi.spyOn(obstacles, 'dataVersion').mockResolvedValueOnce('before').mockResolvedValue('after');
|
||||
|
||||
await detail(trip.id, paddle.id);
|
||||
await detail(trip.id, paddle.id);
|
||||
expect(along).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('TPC-006: data the line leaves, or no data at all, reads as partly covered or not covered', async () => {
|
||||
const { trip, paddle } = await fixture();
|
||||
expect((await detail(trip.id, paddle.id)).tour.paddle_check).toMatchObject({ data: 'none', entries: 0 });
|
||||
|
||||
await insertRow(orm(), PaddleRegions, {
|
||||
id: 'west',
|
||||
name: 'West',
|
||||
boundary: null,
|
||||
min_lat: 49,
|
||||
min_lng: 11.9,
|
||||
max_lat: 49.5,
|
||||
max_lng: 12.005,
|
||||
status: 'ready',
|
||||
feature_count: 0,
|
||||
source_timestamp: '2026-09-01T00:00:00.000Z',
|
||||
imported_at: '2026-09-02 04:20:00',
|
||||
});
|
||||
expect((await detail(trip.id, paddle.id)).tour.paddle_check).toMatchObject({
|
||||
data: 'partial',
|
||||
data_as_of: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('TPC-007: while the data cannot be read the checklist says so, and a stored decision stays as one it cannot compare', async () => {
|
||||
const { user, trip, paddle } = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
const [entry] = (await detail(trip.id, paddle.id)).obstacle_check?.entries ?? [];
|
||||
if (!entry) throw new Error('no entry');
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
|
||||
vi.spyOn(obstacles, 'readAlong').mockRejectedValue(new Error('disk gone'));
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
||||
const read = await detail(trip.id, paddle.id);
|
||||
expect(read.tour.paddle_check).toMatchObject({ data: 'unavailable', data_as_of: null, read_at: null, entries: 0 });
|
||||
expect(read.obstacle_check?.entries).toEqual([
|
||||
expect.objectContaining({ key: 'node/1', status: 'unknown', relation: null, basis: null }),
|
||||
]);
|
||||
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining('checklist without data'));
|
||||
});
|
||||
|
||||
it('TPC-008: a stored decision that does not read is left out and said so', async () => {
|
||||
const { user, trip, paddle } = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
await insertRow(orm(), TourObstacleReviews, {
|
||||
place: paddle.id,
|
||||
obstacle_key: 'node/1',
|
||||
decision: 'pass',
|
||||
note: null,
|
||||
route_fingerprint: 'p1:o:_',
|
||||
source_fingerprint: 'gone',
|
||||
obstacle_snapshot: '{"broken":',
|
||||
decided_at: '2026-10-03 10:00:00',
|
||||
decided_by: user.id,
|
||||
});
|
||||
|
||||
const read = await detail(trip.id, paddle.id);
|
||||
expect(read.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'open' })]);
|
||||
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining(`${paddle.id}/node/1 does not read`));
|
||||
});
|
||||
|
||||
it('TPC-009: a hike carries no checklist', async () => {
|
||||
const { trip, hike } = await fixture();
|
||||
const read = await detail(trip.id, hike.id);
|
||||
expect(read).not.toHaveProperty('obstacle_check');
|
||||
expect(read.tour).not.toHaveProperty('paddle_check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourObstacleReviewsService.save', () => {
|
||||
async function decidedFixture() {
|
||||
const setup = await fixture();
|
||||
await region();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
||||
const [entry] = (await detail(setup.trip.id, setup.paddle.id)).obstacle_check?.entries ?? [];
|
||||
if (!entry) throw new Error('no entry');
|
||||
return { ...setup, entry };
|
||||
}
|
||||
|
||||
const stored = (placeId: number) => findRow(orm(), TourObstacleReviews, { place: placeId, obstacle_key: 'node/1' });
|
||||
|
||||
it('TOR-001: stores a decision with its basis and what it was about, and answers the detail with it', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
const read = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [decide(entry, 'pass')] },
|
||||
user.id,
|
||||
'sock',
|
||||
);
|
||||
|
||||
expect(read.obstacle_check?.entries[0]).toMatchObject({
|
||||
key: 'node/1',
|
||||
status: 'decided',
|
||||
decision: { decision: 'pass', note: null, decided_by: user.id, decided_at: expect.stringMatching(/Z$/) },
|
||||
});
|
||||
expect(read.tour.paddle_check).toMatchObject({ open: 0, decided: 1 });
|
||||
const row = await stored(paddle.id);
|
||||
expect(row).toMatchObject({ route_fingerprint: entry.basis?.route, source_fingerprint: entry.basis?.source });
|
||||
expect(JSON.parse(String(row?.obstacle_snapshot))).toMatchObject({ v: 1, kind: 'weir', refs: ['node/1'] });
|
||||
expect(t?.realtime.broadcastMock).toHaveBeenCalledWith(
|
||||
String(trip.id),
|
||||
'tours:changed',
|
||||
{ placeIds: [paddle.id] },
|
||||
'sock',
|
||||
);
|
||||
});
|
||||
|
||||
it('TOR-002: a portage decided without a portage drawn stays to do, a reroute while the route still passes too', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
const portage = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [decide(entry, 'portage')] },
|
||||
user.id,
|
||||
);
|
||||
expect(portage.obstacle_check?.entries[0]?.status).toBe('todo');
|
||||
const reroute = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [decide(entry, 'reroute')] },
|
||||
user.id,
|
||||
);
|
||||
expect(reroute.obstacle_check?.entries[0]?.status).toBe('todo');
|
||||
expect(reroute.tour.paddle_check?.open).toBe(1);
|
||||
});
|
||||
|
||||
it('TOR-003: the same decision again changes nothing, a note changes only the note, clear removes it', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
await updateRows(orm(), TourObstacleReviews, { place: paddle.id }, { decided_at: '2026-10-03 10:00:00' });
|
||||
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
expect((await stored(paddle.id))?.decided_at).toBe('2026-10-03 10:00:00');
|
||||
|
||||
await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ key: 'node/1', op: 'note', decision: null, note: 'Rechts umtragen', basis: null }] },
|
||||
user.id,
|
||||
);
|
||||
expect(await stored(paddle.id)).toMatchObject({ note: 'Rechts umtragen', decided_at: '2026-10-03 10:00:00' });
|
||||
|
||||
const cleared = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }] },
|
||||
user.id,
|
||||
);
|
||||
expect(await stored(paddle.id)).toBeNull();
|
||||
expect(cleared.obstacle_check?.entries[0]?.status).toBe('open');
|
||||
});
|
||||
|
||||
it('TOR-004: a decision made on another route near the obstacle reads as one to check again', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
const stale = { route: entry.basis?.route.replace(/^p1:o:/, 'p1:n:') ?? '', source: entry.basis?.source ?? '' };
|
||||
const read = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: stale }] },
|
||||
user.id,
|
||||
);
|
||||
expect(read.obstacle_check?.entries[0]).toMatchObject({ status: 'review', changed: ['route'] });
|
||||
expect(read.tour.paddle_check?.review).toBe(1);
|
||||
});
|
||||
|
||||
it('TOR-005: a decision about an object not on the checklist now is skipped, and said so', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ ...decide(entry, 'pass'), key: 'node/99' }] },
|
||||
user.id,
|
||||
);
|
||||
expect(await findRows(orm(), TourObstacleReviews, { place: paddle.id })).toEqual([]);
|
||||
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining('node/99 skipped'));
|
||||
});
|
||||
|
||||
it('TOR-006: an object the complete data no longer holds reads as gone, and kept once the decision is kept', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
// The region read again: the weir has moved out of reach of the route.
|
||||
await deleteRows(orm(), PaddleFeatures, { osm_ref: 'node/1' });
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.6, 12.005)]);
|
||||
await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { imported_at: '2026-10-05 04:20:00' });
|
||||
|
||||
const gone = await detail(trip.id, paddle.id);
|
||||
expect(gone.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'gone' })]);
|
||||
expect(gone.tour.paddle_check).toMatchObject({ review: 1, entries: 0 });
|
||||
|
||||
const kept = await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ key: 'node/1', op: 'keep', decision: null, note: null, basis: null }] },
|
||||
user.id,
|
||||
);
|
||||
expect(kept.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'kept' })]);
|
||||
expect((await stored(paddle.id))?.source_fingerprint).toBe('gone');
|
||||
});
|
||||
|
||||
it('TOR-007: keeping a decision about an object still there changes nothing', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
await reviews.save(
|
||||
String(trip.id),
|
||||
String(paddle.id),
|
||||
{ ops: [{ key: 'node/1', op: 'keep', decision: null, note: null, basis: null }] },
|
||||
user.id,
|
||||
);
|
||||
expect((await stored(paddle.id))?.source_fingerprint).toBe(entry.basis?.source);
|
||||
});
|
||||
|
||||
it('TOR-008: a hike has no checklist to save to, an unknown tour is not found', async () => {
|
||||
const { user, trip, hike, entry } = await decidedFixture();
|
||||
const refusal = reviews.save(String(trip.id), String(hike.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
await expect(refusal).rejects.toBeInstanceOf(DomainError);
|
||||
await expect(refusal).rejects.toMatchObject({ status: 400, message: NOT_A_PADDLE_TOUR_MESSAGE });
|
||||
await expect(reviews.save(String(trip.id), '999999', { ops: [decide(entry, 'pass')] }, user.id)).rejects.toThrow(
|
||||
'Tour not found',
|
||||
);
|
||||
await expect(reviews.save('x', String(hike.id), { ops: [decide(entry, 'pass')] }, user.id)).rejects.toThrow(
|
||||
'Tour not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('TOR-009: while the obstacle data cannot be read a decision waits with a 503 and nothing is stored', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
||||
vi.spyOn(obstacles, 'readAlong').mockRejectedValue(new Error('disk gone'));
|
||||
await expect(
|
||||
reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id),
|
||||
).rejects.toMatchObject({ status: 503, message: PADDLE_DATA_UNAVAILABLE_MESSAGE });
|
||||
expect(await stored(paddle.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('TOR-010: a paddle tour without a line skips a decision, it has nothing to decide about', async () => {
|
||||
const { user, trip, entry } = await decidedFixture();
|
||||
const lineless = await makePlace(orm(), trip.id, { name: 'No line' });
|
||||
await makeTour(orm(), lineless.id, { tourTypeRef: 'kayak' });
|
||||
const read = await reviews.save(String(trip.id), String(lineless.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
expect(read.tour.paddle_check).toBeNull();
|
||||
expect(await findRows(orm(), TourObstacleReviews, { place: lineless.id })).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOR-011: a copied tour keeps its decisions, bound to what they were decided on', async () => {
|
||||
const { user, trip, paddle, entry } = await decidedFixture();
|
||||
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
||||
const copy = await tours.copyTour(String(trip.id), String(paddle.id), 'Regen again');
|
||||
expect(copy.obstacle_check?.entries[0]).toMatchObject({ key: 'node/1', status: 'decided' });
|
||||
expect(await findRows(orm(), TourObstacleReviews, { place: copy.tour.place_id })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { buildPlaceImportService } from '../../helpers/place-import';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common';
|
||||
@@ -304,6 +305,7 @@ function tourRepositoryFixture(ownerTripId: number) {
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
buildPlaceImportService(),
|
||||
noPaddleChecks(),
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TourProvenanceRow, ToursRepository } from '../../../src/db/reposit
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { buildPlaceImportService } from '../../helpers/place-import';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
@@ -138,6 +139,7 @@ function makeService() {
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
buildPlaceImportService(),
|
||||
noPaddleChecks(),
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service };
|
||||
}
|
||||
@@ -180,6 +182,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
@@ -293,6 +296,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { buildPlaceImportService } from '../../helpers/place-import';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
@@ -82,6 +83,7 @@ beforeAll(async () => {
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
buildPlaceImportService(),
|
||||
noPaddleChecks(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user