mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
refactor(places): import paths through repositories
This commit is contained in:
@@ -21,6 +21,18 @@ export class CategoriesRepository extends TrekRepository<Categories> {
|
||||
return categories.map((category) => toRow(category) as CategoryRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL33 (`PlacesService.importKmlPlaces`) — `SELECT id, name FROM
|
||||
* categories`, the id/name-only projection the KML importer folds into
|
||||
* `buildCategoryNameLookup` to resolve a folder name to a category id. No
|
||||
* `ORDER BY` in the legacy statement — none added here.
|
||||
*/
|
||||
async listIdName(): Promise<{ id: number; name: string }[]> {
|
||||
return this.qb('c')
|
||||
.select(['c.id', 'c.name'])
|
||||
.execute<{ id: number; name: string }[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT * FROM categories WHERE id = ?` — no owner filter. `user_id`
|
||||
* being nullable makes a row *look* ownable, but this legacy statement
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Places } from '../entities/Places.entity';
|
||||
import type { Tags } from '../entities/Tags.entity';
|
||||
import { absDifference, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions';
|
||||
import { absDifference, coalesceParam, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
@@ -594,4 +594,125 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
|
||||
return qb.execute<PlaceWithCategoryRow[]>('all', false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 5 (`PlacesService` import paths) — every method below is
|
||||
// additive: `insertPlace`/`updatePlace`/`findWithTagsAndRatings`/the
|
||||
// `findDuplicateBy*` trio above are Task 4's, unchanged and reused verbatim
|
||||
// by the four importers (GPX/KML/Google/Naver) this task converts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PL36 (`colorizeImportedTracks`'s read half) — `SELECT DISTINCT
|
||||
* route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT
|
||||
* NULL`. `DISTINCT` via `.select([...], true)` (the same shape
|
||||
* `listForTrip`/PL3 uses for its own `SELECT DISTINCT`), not `$ne: null`
|
||||
* folded into a single-object filter, so the emitted `WHERE` clause reads
|
||||
* in the legacy's own `trip_id = ? AND route_color IS NOT NULL` order.
|
||||
*
|
||||
* Called and its result acted on inside the SAME `uow.transactional(...)`
|
||||
* block `setRouteColor` below writes in (the service's own PL36+PL37
|
||||
* pairing) — read and write share one transaction so two concurrent
|
||||
* imports can never both observe the same set of free colours.
|
||||
*/
|
||||
async distinctRouteColors(trip_id: string | number): Promise<string[]> {
|
||||
const rows = await this.qb('p')
|
||||
.select(['p.route_color as c'], true)
|
||||
.where('p.trip_id = ? AND p.route_color IS NOT NULL', [trip_id])
|
||||
.execute<{ c: string }[]>('all', false);
|
||||
return rows.map((r) => r.c);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL37 (`colorizeImportedTracks`'s write half) — `UPDATE places SET
|
||||
* route_color = ? WHERE id = ?`. No `updated_at` stamp — the legacy
|
||||
* statement never wrote one for this column (#776's own deliberate
|
||||
* omission, restated in `PlacesService`'s class docstring), and this
|
||||
* method reproduces exactly that, not the "every write stamps
|
||||
* `updated_at`" shape `updatePlace`/`backfillFtid`/`fillIfEmpty` below
|
||||
* follow.
|
||||
*/
|
||||
async setRouteColor(id: number, route_color: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { route_color });
|
||||
}
|
||||
|
||||
/**
|
||||
* PL39 (`storeGooglePlaces`'s dedup-backfill half) — `UPDATE places SET
|
||||
* google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, unscoped
|
||||
* by `trip_id` (matching the legacy statement exactly — the candidate id
|
||||
* was already resolved against this trip by the `findDuplicatePlace` call
|
||||
* immediately before it).
|
||||
*/
|
||||
async backfillFtid(id: number, google_ftid: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { google_ftid, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
/**
|
||||
* PL43/PL44/PL46 (`enrichOne`'s two `UPDATE`s and
|
||||
* `backfillMissingAddresses`'s one) — three legacy statements, one shape:
|
||||
* `UPDATE places SET <col> = COALESCE(<col>, ?), ..., updated_at =
|
||||
* CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. One `nativeUpdate` per
|
||||
* CALL, built from whichever of the six columns the caller passes
|
||||
* (`fields`'s keys are all optional — a key left OFF `fields` entirely is
|
||||
* not touched at all, not even COALESCE'd against itself); every key
|
||||
* that IS present renders `COALESCE(<col>, ?)` through `coalesceParam`
|
||||
* (never an inline `raw()`), so a column already holding a value is never
|
||||
* overwritten and an empty one is filled from `fields`'s value.
|
||||
* `updated_at` is stamped unconditionally — all three legacy statements
|
||||
* stamp it, none of them conditionally.
|
||||
*
|
||||
* The three call shapes this serves (Task 5's "×3 shapes"):
|
||||
* - PL43 (`enrichOne`'s main update) — `google_place_id`, `google_ftid`,
|
||||
* `address`, `website`, `phone` all at once.
|
||||
* - PL44 (`enrichOne`'s photo update) — `image_url` alone.
|
||||
* - PL46 (`backfillMissingAddresses`) — `address` alone.
|
||||
*/
|
||||
async fillIfEmpty(id: number, trip_id: number, fields: {
|
||||
google_place_id?: string | null;
|
||||
google_ftid?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const data: {
|
||||
google_place_id?: ReturnType<typeof coalesceParam>;
|
||||
google_ftid?: ReturnType<typeof coalesceParam>;
|
||||
address?: ReturnType<typeof coalesceParam>;
|
||||
website?: ReturnType<typeof coalesceParam>;
|
||||
phone?: ReturnType<typeof coalesceParam>;
|
||||
image_url?: ReturnType<typeof coalesceParam>;
|
||||
updated_at: ReturnType<typeof currentTimestamp>;
|
||||
} = { updated_at: currentTimestamp(platform) };
|
||||
if (fields.google_place_id !== undefined) data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id);
|
||||
if (fields.google_ftid !== undefined) data.google_ftid = coalesceParam(platform, 'google_ftid', fields.google_ftid);
|
||||
if (fields.address !== undefined) data.address = coalesceParam(platform, 'address', fields.address);
|
||||
if (fields.website !== undefined) data.website = coalesceParam(platform, 'website', fields.website);
|
||||
if (fields.phone !== undefined) data.phone = coalesceParam(platform, 'phone', fields.phone);
|
||||
if (fields.image_url !== undefined) data.image_url = coalesceParam(platform, 'image_url', fields.image_url);
|
||||
await this.nativeUpdate({ id, trip: trip_id }, data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 7 (`trips.rpc.ts`) — additive (per `task-4-report.md`'s own
|
||||
// note that this method "may not exist"): every column above is Task 4/5's,
|
||||
// unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* RP2 (`trips.rpc.ts::getPlaces`) — `SELECT * FROM places WHERE trip_id = ?
|
||||
* ORDER BY created_at DESC`. The trip's place POOL has no itinerary
|
||||
* position of its own (`day_id`/`order_index` live on `day_assignments`),
|
||||
* so this orders by `created_at` like the REST list does — the RPC
|
||||
* method's own comment (`trips.rpc.ts`).
|
||||
*/
|
||||
async listForTripOrdered(trip_id: number): Promise<PlaceRow[]> {
|
||||
return await this.qb('p')
|
||||
.select(['p.*'])
|
||||
.where({ trip: trip_id })
|
||||
.orderBy({ 'p.created_at': 'desc' })
|
||||
.execute<PlaceRow[]>('all', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlaceRatings } from '../../db/entities/PlaceRatings.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { Categories } from '../../db/entities/Categories.entity';
|
||||
import { JourneyDomainModule } from '../journey/journey-domain.module';
|
||||
import { PlacesController } from './places.controller';
|
||||
import { PlacesService } from './places.service';
|
||||
@@ -60,8 +61,9 @@ import { MAX_PLACE_IMAGE_SIZE } from '../common/place-image-upload';
|
||||
// THIS module (not a sibling domain module's `exports`), matching
|
||||
// `AssignmentsDomainModule`'s own `MikroOrmModule.forFeature(...)`
|
||||
// precedent: a `forFeature` registration only reaches providers declared
|
||||
// in the SAME module.
|
||||
MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips]),
|
||||
// in the SAME module. Plan 3c Task 5 adds `CategoriesRepository` for
|
||||
// PL33's `importKmlPlaces` folder → category lookup.
|
||||
MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips, Categories]),
|
||||
],
|
||||
controllers: [PlacesController],
|
||||
providers: [PlacesService, PlacesMcp, PlacesRpc],
|
||||
|
||||
@@ -38,11 +38,13 @@ import { Tags } from '../../db/entities/Tags.entity';
|
||||
import { PlaceRatings } from '../../db/entities/PlaceRatings.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { Categories } from '../../db/entities/Categories.entity';
|
||||
import type { PlacesRepository, PlaceWithCategoryRow } from '../../db/repositories/Places.repository';
|
||||
import type { TagsRepository } from '../../db/repositories/Tags.repository';
|
||||
import type { PlaceRatingsRepository } from '../../db/repositories/PlaceRatings.repository';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import type { CategoriesRepository } from '../../db/repositories/Categories.repository';
|
||||
|
||||
/** Rows a place delete took down with the nights booked there, for the caller to announce. */
|
||||
export interface CancelledStays {
|
||||
@@ -152,6 +154,7 @@ export class PlacesService {
|
||||
@InjectRepository(PlaceRatings) private readonly placeRatingsRepo: PlaceRatingsRepository,
|
||||
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
@InjectRepository(Categories) private readonly categoriesRepo: CategoriesRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string, userId: number) {
|
||||
@@ -812,20 +815,51 @@ export class PlacesService {
|
||||
if (waypoints.length === 0) return null;
|
||||
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const insertStmt = this.dbs.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
// PL31/PL32 — one `insertPlace` call per waypoint. The legacy statement's
|
||||
// narrower 7-column INSERT (trip_id, name, description, lat, lng,
|
||||
// transport_mode='walking' literal, route_geometry) is reproduced as a
|
||||
// typed `insertPlace` partial: the literal becomes a value, and every
|
||||
// column the legacy INSERT omitted takes the same value the DB column
|
||||
// default would have produced (`duration_minutes: 60`, everything else
|
||||
// `null`) — parity by stored row, not by SQL text. Re-selected via
|
||||
// `findWithTagsAndRatings` INSIDE the same transaction the insert ran
|
||||
// in, so the read sees the still-uncommitted row.
|
||||
await this.uow.transactional(async () => {
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
||||
const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!;
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: Number(tripId),
|
||||
name: wp.name,
|
||||
description: wp.description,
|
||||
lat: wp.lat,
|
||||
lng: wp.lng,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: wp.routeGeometry || null,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
}
|
||||
@@ -879,17 +913,13 @@ export class PlacesService {
|
||||
summary.warnings.push(decoded.warning);
|
||||
}
|
||||
|
||||
const categories = this.dbs.all<{ id: number; name: string }>('SELECT id, name FROM categories');
|
||||
// PL33 — `CategoriesRepository.listIdName` (Plan 3a's repository).
|
||||
const categories = await this.categoriesRepo.listIdName();
|
||||
const categoryLookup = buildCategoryNameLookup(categories);
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = this.dbs.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
let fallbackIndex = 1;
|
||||
for (const node of placemarkNodes) {
|
||||
@@ -928,17 +958,40 @@ export class PlacesService {
|
||||
|
||||
const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup);
|
||||
|
||||
const result = insertStmt.run(
|
||||
tripId,
|
||||
// PL34/PL35 — `insertPlace` with the legacy 8-column set (trip_id,
|
||||
// name, description, lat, lng, category_id, transport_mode='walking'
|
||||
// literal, route_geometry); every omitted column takes its DB-default
|
||||
// value. Re-selected via `findWithTagsAndRatings` INSIDE the same
|
||||
// transaction.
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: Number(tripId),
|
||||
name,
|
||||
parsedPlacemark.description,
|
||||
parsedPlacemark.lat,
|
||||
parsedPlacemark.lng,
|
||||
categoryId,
|
||||
parsedPlacemark.routeGeometry,
|
||||
);
|
||||
description: parsedPlacemark.description,
|
||||
lat: parsedPlacemark.lat,
|
||||
lng: parsedPlacemark.lng,
|
||||
address: null,
|
||||
category_id: categoryId,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: parsedPlacemark.routeGeometry,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
});
|
||||
|
||||
const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!;
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
||||
summary.createdCount += 1;
|
||||
@@ -981,22 +1034,19 @@ export class PlacesService {
|
||||
const tracks = result?.places?.filter((p) => p.route_geometry && !p.route_color) ?? [];
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
// Read and write in one transaction so two concurrent imports cannot both
|
||||
// read the same set of free colours.
|
||||
// PL36+PL37 — read (`distinctRouteColors`) and write (`setRouteColor`) in
|
||||
// ONE transaction so two concurrent imports cannot both read the same set
|
||||
// of free colours.
|
||||
await this.uow.transactional(async () => {
|
||||
const taken = new Set(
|
||||
this.dbs.all<{ c: string }>(
|
||||
'SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL', tripId,
|
||||
).map((r) => r.c),
|
||||
);
|
||||
const taken = new Set(await this.placesRepo.distinctRouteColors(tripId));
|
||||
const free = TRACK_COLORS.filter((c) => !taken.has(c));
|
||||
tracks.forEach((track, i) => {
|
||||
for (const [i, track] of tracks.entries()) {
|
||||
// Free ones first, then wrap through the whole palette — never reuse a
|
||||
// free colour twice within the same import.
|
||||
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
|
||||
this.dbs.run('UPDATE places SET route_color = ? WHERE id = ?', color, track.id);
|
||||
await this.placesRepo.setRouteColor(track.id, color);
|
||||
track.route_color = color;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1145,11 +1195,6 @@ export class PlacesService {
|
||||
places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[],
|
||||
): Promise<{ created: PlaceWithTags[]; skipped: number }> {
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const insertStmt = this.dbs.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const updateGoogleFtidStmt = this.dbs.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
await this.uow.transactional(async () => {
|
||||
@@ -1162,14 +1207,47 @@ export class PlacesService {
|
||||
const candidate = { name: p.name, lat: p.lat, lng: p.lng, google_ftid: p.googleFtid };
|
||||
if (isPlaceDuplicate(candidate, dedup)) {
|
||||
const duplicate = await this.findDuplicatePlace(tripId, candidate);
|
||||
// PL39 — `backfillFtid`, unscoped by trip (matching the legacy
|
||||
// statement — `duplicate` was already resolved against this trip).
|
||||
if (duplicate && !duplicate.google_ftid && p.googleFtid) {
|
||||
updateGoogleFtidStmt.run(p.googleFtid, duplicate.id);
|
||||
await this.placesRepo.backfillFtid(duplicate.id, p.googleFtid);
|
||||
}
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid);
|
||||
const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!;
|
||||
// PL38/PL40 — `insertPlace` with the legacy 7-column set (trip_id,
|
||||
// name, lat, lng, notes, google_ftid, transport_mode='walking'
|
||||
// literal); every omitted column takes its DB-default value.
|
||||
// Re-selected via `findWithTagsAndRatings` INSIDE the same
|
||||
// transaction.
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: Number(tripId),
|
||||
name: p.name,
|
||||
description: null,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: p.notes,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: p.googleFtid,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: null,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet(candidate, dedup);
|
||||
}
|
||||
@@ -1396,20 +1474,46 @@ export class PlacesService {
|
||||
}
|
||||
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const insertStmt = this.dbs.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
// PL41/PL42 — `insertPlace` with the legacy 7-column set (trip_id, name,
|
||||
// lat, lng, address, notes, transport_mode='walking' literal); every
|
||||
// omitted column takes its DB-default value. Re-selected via
|
||||
// `findWithTagsAndRatings` INSIDE the same transaction.
|
||||
await this.uow.transactional(async () => {
|
||||
for (const p of places) {
|
||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
|
||||
const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!;
|
||||
const placeId = await this.placesRepo.insertPlace({
|
||||
trip_id: Number(tripId),
|
||||
name: p.name,
|
||||
description: null,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
address: p.address,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: p.notes,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_poi_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: null,
|
||||
route_color: null,
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||
}
|
||||
@@ -1466,19 +1570,16 @@ export class PlacesService {
|
||||
if (!gpid) return;
|
||||
const gftid = trimOrNull(match.google_ftid);
|
||||
|
||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||
this.dbs.run(
|
||||
`UPDATE places
|
||||
SET google_place_id = COALESCE(google_place_id, ?),
|
||||
google_ftid = COALESCE(google_ftid, ?),
|
||||
address = COALESCE(address, ?),
|
||||
website = COALESCE(website, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND trip_id = ?`,
|
||||
gpid, gftid, trimOrNull(match.address), trimOrNull(match.website), trimOrNull(match.phone), place.id, tripId,
|
||||
);
|
||||
// PL43 — `fillIfEmpty`, COALESCE-per-column so enrichment only fills
|
||||
// empty columns — never overwrites data the import already captured
|
||||
// (e.g. Naver's address) or anything the user edited.
|
||||
await this.placesRepo.fillIfEmpty(place.id, Number(tripId), {
|
||||
google_place_id: gpid,
|
||||
google_ftid: gftid,
|
||||
address: trimOrNull(match.address),
|
||||
website: trimOrNull(match.website),
|
||||
phone: trimOrNull(match.phone),
|
||||
});
|
||||
|
||||
// Photo is best-effort: Google often has none, in which case getPlacePhoto
|
||||
// resolves with photoUrl: null. A missing photo (or a provider outage, which
|
||||
@@ -1486,18 +1587,17 @@ export class PlacesService {
|
||||
try {
|
||||
const photo = await this.maps.getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
|
||||
if (photo?.photoUrl) {
|
||||
this.dbs.run(
|
||||
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
photo.photoUrl, place.id, tripId,
|
||||
);
|
||||
// PL44 — `fillIfEmpty`, the `image_url`-only shape.
|
||||
await this.placesRepo.fillIfEmpty(place.id, Number(tripId), { image_url: photo.photoUrl });
|
||||
}
|
||||
} catch {
|
||||
/* no photo — leave image_url as-is */
|
||||
}
|
||||
|
||||
// Push the enriched row to every connected client (no socket exclusion: the
|
||||
// importer's own client should also receive the late update).
|
||||
const updated = await this.dbs.getPlaceWithTags(place.id);
|
||||
// PL45 — push the enriched row to every connected client (no socket
|
||||
// exclusion: the importer's own client should also receive the late
|
||||
// update — `socketId: undefined`, no `X-Socket-Id` to exclude).
|
||||
const updated = await this.placesRepo.findWithTagsAndRatings(place.id);
|
||||
if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
}
|
||||
|
||||
@@ -1564,11 +1664,11 @@ export class PlacesService {
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
if (!address) continue;
|
||||
this.dbs.run(
|
||||
'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
address, place.id, tripId,
|
||||
);
|
||||
const updated = await this.dbs.getPlaceWithTags(place.id);
|
||||
// PL46 — `fillIfEmpty`, the `address`-only shape.
|
||||
await this.placesRepo.fillIfEmpty(place.id, Number(tripId), { address });
|
||||
// PL47 — same broadcast shape as PL45: `socketId: undefined`, no
|
||||
// exclusion.
|
||||
const updated = await this.placesRepo.findWithTagsAndRatings(place.id);
|
||||
if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
} catch (err) {
|
||||
console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -223,6 +223,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestPlaceRatingsRepo(dbService.connection),
|
||||
await createTestTripMembersRepo(dbService.connection),
|
||||
await createTestDayAssignmentsRepo(dbService.connection),
|
||||
await createTestCategoriesRepo(dbService.connection),
|
||||
);
|
||||
// Built after it: a hotel booking writes the stay's day stop through this one.
|
||||
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection));
|
||||
|
||||
@@ -145,6 +145,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
await createTestPlaceRatingsRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestCategoriesRepo(dbs.connection),
|
||||
);
|
||||
// After accommodations: a hotel booking writes the stay's day stop through it.
|
||||
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations, await createTestUnitOfWork(dbs.connection));
|
||||
|
||||
@@ -224,4 +224,23 @@ describe('CategoriesRepository', () => {
|
||||
expect(await categories.findById(gone.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listIdName (Plan 3c Task 5, PL33 — PlacesService.importKmlPlaces\' folder → category lookup)', () => {
|
||||
it('CATREPO-021: projects only id and name, no ORDER BY guaranteed, including a fresh row', async () => {
|
||||
const created = createCategory(testDb, { name: 'RepoIdName' });
|
||||
const rows = await categories.listIdName();
|
||||
const match = rows.find((r) => r.id === created.id);
|
||||
expect(match).toEqual({ id: created.id, name: 'RepoIdName' });
|
||||
// Only the two columns — no color/icon/user_id/created_at leaking through.
|
||||
expect(match ? Object.keys(match).sort() : []).toEqual(['id', 'name']);
|
||||
});
|
||||
|
||||
it('CATREPO-022: includes every category row, seeded defaults and user-created alike', async () => {
|
||||
const before = await categories.listIdName();
|
||||
const created = createCategory(testDb, { name: 'RepoIdNameTwo' });
|
||||
const after = await categories.listIdName();
|
||||
expect(after.length).toBe(before.length + 1);
|
||||
expect(after.map((r) => r.id)).toContain(created.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* PRIM-GPWT-00x (empty tags/ratings only) nor any other suite exercises.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TRACK_COLORS } from '@trek/shared';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
@@ -531,3 +532,264 @@ describe('PlacesRepository.listForTrip (PL3) — filter fragments', () => {
|
||||
expect(rows.map((r) => r.id)).toEqual([winner.id]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 5 — the four importers' `insertPlace` column sets, the
|
||||
// colorize read+write transaction, the ftid backfill, and the
|
||||
// enrichment/backfill `fillIfEmpty` matrix.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PlacesRepository.insertPlace — the four importers\' narrower column sets (PL31/34/38/41), read back inside the same transaction (PL32/35/40/42)', () => {
|
||||
const base = {
|
||||
address: null, category_id: null, price: null, currency: null,
|
||||
place_time: null, end_time: null, duration_minutes: 60, notes: null, image_url: null,
|
||||
google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null, website: null,
|
||||
phone: null, transport_mode: 'walking', route_geometry: null, route_color: null,
|
||||
stop_type: null, fill_percent: null,
|
||||
};
|
||||
|
||||
it('PLACEREPO-033 (PL31/PL32, GPX): the 7-column GPX shape is stored, and the read inside the same transaction sees the uncommitted row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const row = await t.em.transactional(async () => {
|
||||
const id = await places.insertPlace({
|
||||
...base,
|
||||
trip_id: trip.id, name: 'GPX Waypoint', description: 'from a gpx file',
|
||||
lat: 48.85, lng: 2.35, route_geometry: '[[48.85,2.35],[48.86,2.36]]',
|
||||
});
|
||||
// Same transaction, no commit yet — this read must still see it.
|
||||
return places.findWithTagsAndRatings(id);
|
||||
});
|
||||
|
||||
expect(row).toMatchObject({
|
||||
trip_id: trip.id, name: 'GPX Waypoint', description: 'from a gpx file',
|
||||
lat: 48.85, lng: 2.35, address: null, category_id: null, notes: null,
|
||||
transport_mode: 'walking', route_geometry: '[[48.85,2.35],[48.86,2.36]]',
|
||||
duration_minutes: 60, category: null, tags: [], ratings: [],
|
||||
});
|
||||
// Committed for real, not just visible mid-transaction.
|
||||
expect(testDb.prepare('SELECT name FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ name: 'GPX Waypoint' });
|
||||
});
|
||||
|
||||
it('PLACEREPO-033b: a transaction that throws after the insert leaves no row behind — the insert really was uncommitted, not autocommitted ahead of the wrapper', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await expect(t.em.transactional(async () => {
|
||||
const id = await places.insertPlace({ ...base, trip_id: trip.id, name: 'Rolled back', description: null, lat: 1, lng: 1 });
|
||||
// The uncommitted row is visible to a read inside the same transaction...
|
||||
const seen = await places.findWithTagsAndRatings(id);
|
||||
expect(seen?.name).toBe('Rolled back');
|
||||
throw new Error('force rollback');
|
||||
})).rejects.toThrow('force rollback');
|
||||
|
||||
// ...but never lands once the wrapping transaction rolls back.
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('PLACEREPO-034 (PL34/PL35, KML): the 8-column KML shape (adds category_id) is stored, read back in the same transaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const cat = createCategory(testDb, { name: 'Hiking' });
|
||||
|
||||
const row = await t.em.transactional(async () => {
|
||||
const id = await places.insertPlace({
|
||||
...base,
|
||||
trip_id: trip.id, name: 'Placemark 1', description: 'a kml placemark',
|
||||
lat: 35.0, lng: 139.0, category_id: cat.id, route_geometry: null,
|
||||
});
|
||||
return places.findWithTagsAndRatings(id);
|
||||
});
|
||||
|
||||
expect(row).toMatchObject({
|
||||
trip_id: trip.id, name: 'Placemark 1', description: 'a kml placemark',
|
||||
lat: 35.0, lng: 139.0, category_id: cat.id, duration_minutes: 60,
|
||||
transport_mode: 'walking', address: null, notes: null,
|
||||
category: { id: cat.id, name: 'Hiking' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACEREPO-035 (PL38/PL40, Google list): the 7-column Google shape (notes + google_ftid) is stored, read back in the same transaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const row = await t.em.transactional(async () => {
|
||||
const id = await places.insertPlace({
|
||||
...base,
|
||||
trip_id: trip.id, name: 'Google Place', description: null,
|
||||
lat: 40.7, lng: -74.0, notes: 'a note', google_ftid: '0x1:0x2',
|
||||
});
|
||||
return places.findWithTagsAndRatings(id);
|
||||
});
|
||||
|
||||
expect(row).toMatchObject({
|
||||
trip_id: trip.id, name: 'Google Place', notes: 'a note', google_ftid: '0x1:0x2',
|
||||
lat: 40.7, lng: -74.0, address: null, category_id: null, duration_minutes: 60,
|
||||
transport_mode: 'walking',
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACEREPO-036 (PL41/PL42, Naver list): the 7-column Naver shape (address + notes) is stored, read back in the same transaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const row = await t.em.transactional(async () => {
|
||||
const id = await places.insertPlace({
|
||||
...base,
|
||||
trip_id: trip.id, name: 'Naver Spot', description: null,
|
||||
lat: 37.5, lng: 127.0, address: '123 Some Street', notes: 'a naver note',
|
||||
});
|
||||
return places.findWithTagsAndRatings(id);
|
||||
});
|
||||
|
||||
expect(row).toMatchObject({
|
||||
trip_id: trip.id, name: 'Naver Spot', address: '123 Some Street', notes: 'a naver note',
|
||||
lat: 37.5, lng: 127.0, category_id: null, duration_minutes: 60, transport_mode: 'walking',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.distinctRouteColors / setRouteColor (PL36/PL37)', () => {
|
||||
it('PLACEREPO-037: distinctRouteColors returns only this trip\'s non-null colours, scoped by trip_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'A', 1, 1, '[[1,1]]', ?)").run(trip.id, TRACK_COLORS[0]);
|
||||
testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'B', 1, 1, '[[1,1]]', ?)").run(trip.id, TRACK_COLORS[1]);
|
||||
testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry) VALUES (?, 'C', 1, 1, '[[1,1]]')").run(trip.id);
|
||||
testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'D', 1, 1, '[[1,1]]', ?)").run(other.id, TRACK_COLORS[2]);
|
||||
|
||||
const colors = await places.distinctRouteColors(trip.id);
|
||||
expect(new Set(colors)).toEqual(new Set([TRACK_COLORS[0], TRACK_COLORS[1]]));
|
||||
});
|
||||
|
||||
it('PLACEREPO-038: setRouteColor writes the column without stamping updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET updated_at = ? WHERE id = ?').run('2020-01-01 00:00:00', place.id);
|
||||
|
||||
await places.setRouteColor(place.id, TRACK_COLORS[3]);
|
||||
|
||||
const row = testDb.prepare('SELECT route_color, updated_at FROM places WHERE id = ?').get(place.id) as { route_color: string; updated_at: string };
|
||||
expect(row.route_color).toBe(TRACK_COLORS[3]);
|
||||
expect(row.updated_at).toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
|
||||
it('PLACEREPO-039 (D6 single-connection race safety): two concurrent colour claims for the same trip never pick the same free colour', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const placeA = createPlace(testDb, trip.id, { name: 'Track A' });
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'Track B' });
|
||||
testDb.prepare("UPDATE places SET route_geometry = '[[1,1]]' WHERE id IN (?, ?)").run(placeA.id, placeB.id);
|
||||
|
||||
// Mirrors PlacesService.colorizeImportedTracks's own PL36+PL37 pairing
|
||||
// EXACTLY: the read AND the write for one claim share ONE transaction —
|
||||
// splitting them (read in one transaction, write in a second, separate
|
||||
// one) is precisely the bug this pairing exists to prevent, and doing
|
||||
// that here would make the test pass for the wrong reason.
|
||||
const claimAndWrite = (placeId: number) => t.em.transactional(async () => {
|
||||
const taken = new Set(await places.distinctRouteColors(trip.id));
|
||||
const free = TRACK_COLORS.find((c) => !taken.has(c));
|
||||
if (!free) throw new Error('palette exhausted');
|
||||
await places.setRouteColor(placeId, free);
|
||||
return free;
|
||||
});
|
||||
|
||||
const [colorA, colorB] = await Promise.all([
|
||||
claimAndWrite(placeA.id),
|
||||
claimAndWrite(placeB.id),
|
||||
]);
|
||||
|
||||
expect(colorA).not.toBe(colorB);
|
||||
const rows = testDb.prepare('SELECT route_color FROM places WHERE id IN (?, ?)').all(placeA.id, placeB.id) as { route_color: string }[];
|
||||
expect(new Set(rows.map((r) => r.route_color)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.backfillFtid (PL39)', () => {
|
||||
it('PLACEREPO-040: stamps google_ftid and updated_at, unscoped by trip (the candidate was already resolved against the trip by findDuplicatePlace)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET updated_at = ? WHERE id = ?').run('2020-01-01 00:00:00', place.id);
|
||||
|
||||
await places.backfillFtid(place.id, '0x9:0x9');
|
||||
|
||||
const row = testDb.prepare('SELECT google_ftid, updated_at FROM places WHERE id = ?').get(place.id) as { google_ftid: string; updated_at: string };
|
||||
expect(row.google_ftid).toBe('0x9:0x9');
|
||||
expect(row.updated_at).not.toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.fillIfEmpty (PL43/PL44/PL46) — three call shapes over one COALESCE-per-column method', () => {
|
||||
it('PLACEREPO-041 (PL43 shape): fills every empty column named in fields, stamps updated_at, and leaves an omitted column untouched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET phone = ?, updated_at = ? WHERE id = ?').run('+1 555', '2020-01-01 00:00:00', place.id);
|
||||
|
||||
await places.fillIfEmpty(place.id, trip.id, {
|
||||
google_place_id: 'gp1', google_ftid: 'gf1', address: 'addr1', website: 'https://x', phone: 'ignored',
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT google_place_id, google_ftid, address, website, phone, updated_at FROM places WHERE id = ?').get(place.id) as Record<string, string>;
|
||||
expect(row.google_place_id).toBe('gp1');
|
||||
expect(row.google_ftid).toBe('gf1');
|
||||
expect(row.address).toBe('addr1');
|
||||
expect(row.website).toBe('https://x');
|
||||
// Already set — COALESCE keeps the existing value, not the caller's.
|
||||
expect(row.phone).toBe('+1 555');
|
||||
expect(row.updated_at).not.toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
|
||||
it('PLACEREPO-042: a column already set is NOT overwritten; an empty one is filled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Existing address', place.id);
|
||||
|
||||
await places.fillIfEmpty(place.id, trip.id, { address: 'New address' });
|
||||
|
||||
expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string }).address).toBe('Existing address');
|
||||
|
||||
const empty = createPlace(testDb, trip.id, { name: 'No address yet' });
|
||||
await places.fillIfEmpty(empty.id, trip.id, { address: 'First address' });
|
||||
expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(empty.id) as { address: string }).address).toBe('First address');
|
||||
});
|
||||
|
||||
it('PLACEREPO-043 (PL44 shape): image_url-only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
await places.fillIfEmpty(place.id, trip.id, { image_url: '/api/maps/place-photo/x/bytes' });
|
||||
|
||||
const row = testDb.prepare('SELECT image_url, google_place_id FROM places WHERE id = ?').get(place.id) as { image_url: string; google_place_id: string | null };
|
||||
expect(row.image_url).toBe('/api/maps/place-photo/x/bytes');
|
||||
// Not touched — the key was never in `fields`, not even COALESCE'd against itself.
|
||||
expect(row.google_place_id).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACEREPO-044 (PL46 shape): address-only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
await places.fillIfEmpty(place.id, trip.id, { address: '1 Rue de Rivoli' });
|
||||
|
||||
expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string }).address).toBe('1 Rue de Rivoli');
|
||||
});
|
||||
|
||||
it('PLACEREPO-045: WHERE id = ? AND trip_id = ? scoping — a mismatched trip_id writes nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
await places.fillIfEmpty(place.id, otherTrip.id, { address: 'Should not land' });
|
||||
|
||||
expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string | null }).address).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
|
||||
@@ -97,6 +97,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise<Pla
|
||||
await createTestPlaceRatingsRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestCategoriesRepo(dbs.connection),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpe
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
|
||||
@@ -124,6 +124,7 @@ async function makePlacesService(
|
||||
await createTestPlaceRatingsRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestCategoriesRepo(dbs.connection),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1420,6 +1421,29 @@ describe('enrichImportedPlaces', () => {
|
||||
expect(row.image_url).toBe('/api/maps/place-photo/ChIJ1/bytes');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-060b (PL45) — broadcasts place:updated with socketId: undefined, no exclusion (the importer\'s own client gets the late update)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
||||
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcast');
|
||||
|
||||
const svcWithMaps = await enrichSvc({
|
||||
getMapsKey: vi.fn(() => 'key'),
|
||||
searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJ1', lat: 48.85, lng: 2.35 }] })),
|
||||
getPlacePhoto: vi.fn(async () => ({ photoUrl: null, attribution: null })),
|
||||
} as never);
|
||||
|
||||
await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]);
|
||||
|
||||
expect(broadcastSpy).toHaveBeenCalledTimes(1);
|
||||
const [tripArg, event, payload, socketId] = broadcastSpy.mock.calls[0];
|
||||
expect(tripArg).toBe(String(trip.id));
|
||||
expect(event).toBe('place:updated');
|
||||
expect((payload as { place: { id: number } }).place.id).toBe(place.id);
|
||||
expect(socketId).toBeUndefined();
|
||||
broadcastSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-061 — leaves the place alone when no candidate is close enough', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -1718,6 +1742,26 @@ describe('backfillMissingAddresses', () => {
|
||||
expect(row.address).toBe('1 Rue de Rivoli, Paris');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-078b (PL47) — broadcasts place:updated with socketId: undefined, no exclusion', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
||||
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcast');
|
||||
|
||||
const reverseGeocode = vi.fn(async () => ({ name: null, address: '1 Rue de Rivoli, Paris' }));
|
||||
await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [
|
||||
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
|
||||
]);
|
||||
|
||||
expect(broadcastSpy).toHaveBeenCalledTimes(1);
|
||||
const [tripArg, event, payload, socketId] = broadcastSpy.mock.calls[0];
|
||||
expect(tripArg).toBe(String(trip.id));
|
||||
expect(event).toBe('place:updated');
|
||||
expect((payload as { place: { id: number } }).place.id).toBe(place.id);
|
||||
expect(socketId).toBeUndefined();
|
||||
broadcastSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-079 — never overwrites an address the import or the Google pass already wrote', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// Real sibling services over the same in-memory DB — the aggregation runs the
|
||||
@@ -136,6 +136,7 @@ beforeAll(async () => {
|
||||
await createTestPlaceRatingsRepo(dbs().connection),
|
||||
await createTestTripMembersRepo(dbs().connection),
|
||||
await createTestDayAssignmentsRepo(dbs().connection),
|
||||
await createTestCategoriesRepo(dbs().connection),
|
||||
);
|
||||
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection));
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// Real sibling services over the same in-memory DB — updateTrip's date-shift
|
||||
@@ -173,6 +173,7 @@ beforeAll(async () => {
|
||||
await createTestPlaceRatingsRepo(dbs().connection),
|
||||
await createTestTripMembersRepo(dbs().connection),
|
||||
await createTestDayAssignmentsRepo(dbs().connection),
|
||||
await createTestCategoriesRepo(dbs().connection),
|
||||
);
|
||||
svc = new TripsService(
|
||||
dbs(),
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
createTestPlacesRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestDayAssignmentsRepo,
|
||||
createTestCategoriesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
@@ -112,6 +113,7 @@ beforeAll(async () => {
|
||||
await createTestPlaceRatingsRepo(dbs.connection),
|
||||
await createTestTripMembersRepo(dbs.connection),
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestCategoriesRepo(dbs.connection),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user