From aee275e2b0e9cde4ace02d56e710dc8deeef9dec Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 28 Oct 2026 15:17:37 +0100 Subject: [PATCH] fix(places): read an imported row for the dedup set in one helper The candidate a GPX or KML row is checked as is built in lineCandidate, so places.service.ts stays within its size baseline, and the atomic import test imports the same Ridge line it later expects to be skipped. --- server/src/nest/places/places.helpers.ts | 8 ++++++++ server/src/nest/places/places.service.ts | 12 ++++++------ server/tests/unit/nest/tours.gpx.atomic.test.ts | 3 ++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts index 3dd5c2884..9533a78d0 100644 --- a/server/src/nest/places/places.helpers.ts +++ b/server/src/nest/places/places.helpers.ts @@ -134,6 +134,14 @@ function lineCourse(routeGeometry: string | null | undefined): string { return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(','); } +/** An imported row as the dedup set reads it: a line when it carries one. */ +export function lineCandidate( + name: string | null | undefined, + row: { lat: number | null; lng: number | null; routeGeometry?: string | null }, +): DedupCandidate { + return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry }; +} + /** A track's entry in `DedupSet.tracks`: its normalized name and its course. */ function trackKey(name: string, routeGeometry: string | null | undefined): string { return `${name}\u0000${lineCourse(routeGeometry)}`; diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index b0514e2f1..0ac0c1678 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -58,7 +58,7 @@ import { buildCategoryNameLookup, escapeLikePattern, dedupSetOf, - isPlaceDuplicate, + isPlaceDuplicate, lineCandidate, mapWithConcurrency, pickEnrichmentMatch, reclaimPhotoCache, @@ -758,7 +758,7 @@ export class PlacesService { async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise { const dedup = await this.buildDedupSet(tripId); return rows.map((wp) => { - const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }; + const candidate = lineCandidate(wp.name, wp); if (isPlaceDuplicate(candidate, dedup)) return true; trackInsertedInDedupSet(candidate, dedup); return false; @@ -925,7 +925,7 @@ export class PlacesService { // `findWithTagsAndRatings` INSIDE the caller's transaction, so the read // sees the still-uncommitted row. for (const wp of waypoints) { - if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }, dedup)) { + if (isPlaceDuplicate(lineCandidate(wp.name, wp), dedup)) { skipped++; continue; } @@ -960,7 +960,7 @@ export class PlacesService { }); const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); - trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }, dedup); + trackInsertedInDedupSet(lineCandidate(wp.name, wp), dedup); } return { places: created, count: created.length, skipped }; @@ -1033,7 +1033,7 @@ export class PlacesService { const fallbackName = `Placemark ${fallbackIndex}`; const name = parsedPlacemark.name || fallbackName; - if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath, routeGeometry: parsedPlacemark.routeGeometry }, dedup)) { + if (isPlaceDuplicate(lineCandidate(name, parsedPlacemark), dedup)) { summary.skippedCount += 1; dupCount++; fallbackIndex += 1; @@ -1079,7 +1079,7 @@ export class PlacesService { const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); - trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath, routeGeometry: parsedPlacemark.routeGeometry }, dedup); + trackInsertedInDedupSet(lineCandidate(name, parsedPlacemark), dedup); summary.createdCount += 1; fallbackIndex += 1; } diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index 3282ad414..6dcacf826 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -418,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { }); it('imports only the new tracks and publishes only committed rows in a mixed result', async () => { + // The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870). const existing = Buffer.from(`Ridge 100150 - `); + 120`); expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1); broadcast.mockClear();