diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index fed3dbb6e..085524d24 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -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 = { 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 = { + column: 'tour_obstacle_reviews.obstacle_snapshot', + schema: obstacleSnapshotSchema.nullable(), + fallback: () => null, +}; diff --git a/server/src/db/repositories/PaddleFeatures.repository.ts b/server/src/db/repositories/PaddleFeatures.repository.ts index 5c483fb1b..34b9036a4 100644 --- a/server/src/db/repositories/PaddleFeatures.repository.ts +++ b/server/src/db/repositories/PaddleFeatures.repository.ts @@ -66,6 +66,18 @@ export class PaddleFeaturesRepository extends TrekRepository { .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().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 { await this.db().deleteFrom('paddle_features').where('region_id', '=', regionId).execute(); diff --git a/server/src/db/repositories/TourObstacleReviews.repository.ts b/server/src/db/repositories/TourObstacleReviews.repository.ts index 260432dd5..c7758c115 100644 --- a/server/src/db/repositories/TourObstacleReviews.repository.ts +++ b/server/src/db/repositories/TourObstacleReviews.repository.ts @@ -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 {} +/** `decision` narrowed to the four values the column's CHECK constraint allows. */ +type ReviewsDB = { + tour_obstacle_reviews: Omit & { 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; + +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 { + private db() { + return this.kysely(); + } + + async listForPlace(place_id: number): Promise { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 1c44ff2d6..6a3a4630a 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -112,7 +112,10 @@ export interface TourPaddlePlanUpdate { duration: number | null; } -type ToursKyselyDB = Pick; +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 { * 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> { + 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 { + 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 { await this.db() .insertInto('tour_sources') diff --git a/server/src/nest/paddle-obstacles/paddle-geo.ts b/server/src/nest/paddle-obstacles/paddle-geo.ts index ff7a2af8a..27fa7156a 100644 --- a/server/src/nest/paddle-obstacles/paddle-geo.ts +++ b/server/src/nest/paddle-obstacles/paddle-geo.ts @@ -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'; +} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts index 755edaa26..721ae6826 100644 --- a/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts @@ -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 { + 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(); + const features: PaddleFeature[] = []; + const regions = new Map(); + 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 { + 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 { 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, }; diff --git a/server/src/nest/paddle-obstacles/paddle-tags.ts b/server/src/nest/paddle-obstacles/paddle-tags.ts index b928fc283..e22a6846a 100644 --- a/server/src/nest/paddle-obstacles/paddle-tags.ts +++ b/server/src/nest/paddle-obstacles/paddle-tags.ts @@ -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 { diff --git a/server/src/nest/tours/dto/tour-obstacle-reviews.dto.ts b/server/src/nest/tours/dto/tour-obstacle-reviews.dto.ts new file mode 100644 index 000000000..16f97c850 --- /dev/null +++ b/server/src/nest/tours/dto/tour-obstacle-reviews.dto.ts @@ -0,0 +1,5 @@ +import { tourObstacleReviewsRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class TourObstacleReviewsDto extends createZodDto(tourObstacleReviewsRequestSchema) {} diff --git a/server/src/nest/tours/tour-obstacle-reviews.service.ts b/server/src/nest/tours/tour-obstacle-reviews.service.ts new file mode 100644 index 000000000..4e3196856 --- /dev/null +++ b/server/src/nest/tours/tour-obstacle-reviews.service.ts @@ -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 { + 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 { + 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; +} diff --git a/server/src/nest/tours/tour-paddle-check.cache.ts b/server/src/nest/tours/tour-paddle-check.cache.ts new file mode 100644 index 000000000..b152ffd8a --- /dev/null +++ b/server/src/nest/tours/tour-paddle-check.cache.ts @@ -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; + /** The data covers the whole line and nothing was cut, so a missing object is really missing. */ + complete: boolean; + data: Pick; +} + +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(); + + 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); + } + } +} diff --git a/server/src/nest/tours/tour-paddle-check.service.ts b/server/src/nest/tours/tour-paddle-check.service.ts new file mode 100644 index 000000000..f763294a3 --- /dev/null +++ b/server/src/nest/tours/tour-paddle-check.service.ts @@ -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(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 { + 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, + 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 { + 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; + 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 { + 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 }, + }; + } +} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts index 71f0a6dc4..582bc673d 100644 --- a/server/src/nest/tours/tours.controller.ts +++ b/server/src/nest/tours/tours.controller.ts @@ -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') diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts index 1db48b21d..c2fed3555 100644 --- a/server/src/nest/tours/tours.helpers.ts +++ b/server/src/nest/tours/tours.helpers.ts @@ -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. */ diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 5a4336789..04ec837bb 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -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), diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 5caaed108..3d0cd11c0 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -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 {} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index e734155de..3779540b3 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -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 { 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 { diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index d4c0a59ab..8ffab7832 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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(); 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(); for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) { diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 29fcf01bc..775f48815 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -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('', `< /** 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>; @@ -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, 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); diff --git a/server/tests/helpers/fake-paddle-checks.ts b/server/tests/helpers/fake-paddle-checks.ts new file mode 100644 index 000000000..9a57ddc83 --- /dev/null +++ b/server/tests/helpers/fake-paddle-checks.ts @@ -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 { + 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; +} diff --git a/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts b/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts index 0c4002510..76ace286a 100644 --- a/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts +++ b/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts @@ -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)); + }); }); diff --git a/server/tests/unit/db/repositories/TourObstacleReviews.repository.test.ts b/server/tests/unit/db/repositories/TourObstacleReviews.repository.test.ts new file mode 100644 index 000000000..389c3a12a --- /dev/null +++ b/server/tests/unit/db/repositories/TourObstacleReviews.repository.test.ts @@ -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 { + 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' }), + ]); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-geo.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-geo.test.ts index c2912010c..f65592d4e 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-geo.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-geo.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts index d0b0b36a4..8584535c8 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts @@ -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); + }); +}); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-tags.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-tags.test.ts index 8261cbcd7..8801e0a82 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-tags.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-tags.test.ts @@ -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', + }); + }); +}); diff --git a/server/tests/unit/nest/tours.controller.test.ts b/server/tests/unit/nest/tours.controller.test.ts index 773f9f6ef..1741702fb 100644 --- a/server/tests/unit/nest/tours.controller.test.ts +++ b/server/tests/unit/nest/tours.controller.test.ts @@ -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'); }); }); diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index cc80fdd11..273c5f431 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -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(), ); }); diff --git a/server/tests/unit/nest/tours.paddle-check.cache.test.ts b/server/tests/unit/nest/tours.paddle-check.cache.test.ts new file mode 100644 index 000000000..9fffa0148 --- /dev/null +++ b/server/tests/unit/nest/tours.paddle-check.cache.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/nest/tours.paddle-check.test.ts b/server/tests/unit/nest/tours.paddle-check.test.ts new file mode 100644 index 000000000..21d730b9f --- /dev/null +++ b/server/tests/unit/nest/tours.paddle-check.test.ts @@ -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()), + ...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 = {}) { + 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 { + const read = await tours.getTour(String(tripId), String(placeId)); + expect(tourDetailResponseSchema.safeParse(read).success).toBe(true); + return read; +} + +function decide(entry: Pick, 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); + }); +}); diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts index 71b195ada..23c72b24e 100644 --- a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -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 }; } diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index 77c2a22e2..f14c57787 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -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); diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index ff9094f43..5884f34fd 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -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(), ); });