Merge dev into fix/prettier

Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
Maurice
2026-10-07 15:43:13 +02:00
242 changed files with 22952 additions and 731 deletions
@@ -1,4 +1,4 @@
import { coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
import { caseWhenNotNull, coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
import type { DayAssignments } from '../entities/DayAssignments.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -16,9 +16,12 @@ import type { Platform } from '@mikro-orm/core';
* p.duration_minutes, p.notes as place_notes,
* p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id,
* p.website, p.phone, p.stop_type, p.fill_percent,
* t.place_id AS tour_place_id,
* CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
* c.name as category_name, c.color as category_color, c.icon as category_icon
* FROM day_assignments da
* JOIN places p ON da.place_id = p.id
* LEFT JOIN tours t ON t.place_id = p.id
* LEFT JOIN categories c ON p.category_id = c.id
* WHERE <da.id = ? | da.day_id = ? | da.day_id IN (...)>
* [ORDER BY da.order_index ASC, da.created_at ASC]
@@ -58,6 +61,10 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow {
phone: string | null;
stop_type: string | null;
fill_percent: number | null;
/** The place's id when it is a Tour (its `tours` facet row), else null. */
tour_place_id: number | null;
/** The place's `route_geometry`, but only for a Tour; null for every other place. */
tour_route_geometry: string | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
@@ -66,11 +73,19 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow {
/**
* {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) —
* {@link AssignmentWithPlaceRow} minus the six columns a public share link
* must not leak.
* must not leak, and minus the two Tour facet columns the share projection
* never selected.
*/
export type SharePublicAssignmentRow = Omit<
AssignmentWithPlaceRow,
'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent'
| 'google_place_id'
| 'google_ftid'
| 'osm_id'
| 'amap_poi_id'
| 'stop_type'
| 'fill_percent'
| 'tour_place_id'
| 'tour_route_geometry'
>;
/**
@@ -266,6 +281,10 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
'p.phone',
'p.stop_type',
'p.fill_percent',
// `place_id` on `Tours` is the `persist(false)` twin of its primary-key
// relation, the same trap as `category_id` above, so `columnRef` again.
columnRef(platform, 't.place_id').as('tour_place_id'),
caseWhenNotNull(platform, 't.place_id', 'p.route_geometry').as('tour_route_geometry'),
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
@@ -280,6 +299,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.tours', 't')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.id': id })
@@ -296,6 +316,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.tours', 't')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.day': day_id })
@@ -313,6 +334,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.tours', 't')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.day': { $in: day_ids } })
@@ -516,6 +538,20 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
return row?.day_id;
}
/**
* `moveAssignment`'s Tour check: `SELECT place_id FROM day_assignments
* WHERE id = ?`, `columnRef` for the same `persist(false)` reason as
* {@link getDayId} above.
*/
async getPlaceId(id: number): Promise<number | undefined> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('da')
.select([columnRef(platform, 'da.place_id').as('place_id')])
.where({ id })
.execute<{ place_id: number } | undefined>('get', false);
return row?.place_id;
}
/** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */
async moveToDay(id: number, day_id: number, order_index: number): Promise<void> {
await this.nativeUpdate({ id }, { day: day_id, order_index });
@@ -59,6 +59,8 @@ export interface PlaceWithCategoryRow extends PlaceRow {
export interface PlaceListRow extends PlaceWithCategoryRow {
country_code: string | null;
region_name: string | null;
/** The place's own id when it is a Tour (it has a `tours` facet row), else null. */
tour_place_id: number | null;
}
/** {@link PlacesRepository.findActiveTripFile}'s narrow `trip_files` shape. */
@@ -344,6 +346,22 @@ export class PlacesRepository extends TrekRepository<Places> {
.execute<{ google_place_id: string | null; image_url: string | null } | undefined>('get', false);
}
/**
* `SELECT 1 FROM tours WHERE place_id = ?`, read through the place's
* `tours` facet so the Places domain answers it without the Tours
* repository. `remove`/`removeMany` call it inside their delete
* transaction to report which deleted places were Tours; the facet row and
* its waypoints go with the place through `ON DELETE CASCADE`.
*/
async isTour(id: number): Promise<boolean> {
const row = await this.qb('p')
.join('p.tours', 't')
.select(['p.id'])
.where({ 'p.id': id })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/** PL19/PL21 — `DELETE FROM places WHERE id = ?`, unscoped: every caller proved trip access first. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
@@ -741,13 +759,19 @@ export class PlacesRepository extends TrekRepository<Places> {
assignment?: 'all' | 'unassigned' | 'assigned';
},
): Promise<PlaceListRow[]> {
const platform = this.getEntityManager().getPlatform();
const qb = this.qb('p')
.leftJoin('p.category', 'c')
// #2537: the atlas' cached region rides along (`LEFT JOIN place_regions pr ON pr.place_id = p.id`).
.leftJoin('p.place_regions', 'pr')
// Tours: `LEFT JOIN tours t ON t.place_id = p.id` marks the Tour-backed
// places. `columnRef`, because the bare `t.place_id` twin is
// persist(false) and selects nothing.
.leftJoin('p.tours', 't')
.select(
[
'p.*',
columnRef(platform, 't.place_id').as('tour_place_id'),
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
@@ -1455,6 +1479,56 @@ export class PlacesRepository extends TrekRepository<Places> {
.execute();
return rows as PublicApiAssignedPlaceRow[];
}
/**
* TO10 (`ToursService.createTour`) — `INSERT INTO places (trip_id, name,
* lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, 'walking',
* ?)`. The place a drawn tour lives on starts at the route's first point;
* every other column keeps its default. Returns the generated id.
*/
async insertTourPlace(input: {
trip_id: number;
name: string;
lat: number;
lng: number;
route_geometry: string;
}): Promise<number> {
return await this.insert({
trip: input.trip_id,
name: input.name,
lat: input.lat,
lng: input.lng,
transport_mode: 'walking',
route_geometry: input.route_geometry,
});
}
/**
* TO11 (`ToursService.updateTour`) — `UPDATE places SET name = ?, lat = ?,
* lng = ?, transport_mode = 'walking', route_geometry = ?, updated_at =
* CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. Returns whether a place
* of that trip was updated, so a tour deleted or moved between the check
* and the write answers 404 instead of writing nothing.
*/
async updateTourRoute(
id: number,
trip_id: number,
input: { name: string; lat: number; lng: number; route_geometry: string },
): Promise<boolean> {
const platform = this.getEntityManager().getPlatform();
const updated = await this.nativeUpdate(
{ id, trip: trip_id },
{
name: input.name,
lat: input.lat,
lng: input.lng,
transport_mode: 'walking',
route_geometry: input.route_geometry,
updated_at: currentTimestamp(platform),
},
);
return updated > 0;
}
}
/** The `places` columns {@link PlacesRepository.listAssignedForPublicApi} selects, typed off {@link PlaceRow}. */
@@ -0,0 +1,9 @@
import type { TourTypes } from '../entities/TourTypes.entity';
import { TrekRepository } from './_shared/trek-repository';
/**
* `tour_types` — the controlled vocabulary `tours.tour_type` points at. Only
* `hike` exists, written by the migration that creates the table; nothing
* reads the catalogue yet, so this repository has no methods of its own.
*/
export class TourTypesRepository extends TrekRepository<TourTypes> {}
@@ -0,0 +1,77 @@
import type { TourWaypoints } from '../entities/TourWaypoints.entity';
import { TrekRepository } from './_shared/trek-repository';
/** One saved routing control point of a tour, in route order. */
export interface TourWaypointRow {
lat: number;
lng: number;
role: 'start' | 'via' | 'end';
sequence: number;
}
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
export interface TourWaypointWithPlaceRow extends TourWaypointRow {
place_id: number;
}
interface TourWaypointsKyselyDB {
tour_waypoints: {
id: number;
place_id: number;
lat: number;
lng: number;
role: TourWaypointRow['role'];
sequence: number;
};
places: { id: number; trip_id: number };
}
/**
* `tour_waypoints` — the start, via and end points the route editor saved
* for a tour, so reopening it restores the controls rather than only the
* derived line. Rows hang off `tours(place_id)` and go with the tour.
*/
export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
private db() {
return this.kysely<TourWaypointsKyselyDB>();
}
/** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
return await this.db()
.selectFrom('tour_waypoints')
.select(['lat', 'lng', 'role', 'sequence'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
.execute();
}
/** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */
async listForTrip(trip_id: number): Promise<TourWaypointWithPlaceRow[]> {
return await this.db()
.selectFrom('tour_waypoints as tw')
.innerJoin('places as p', 'p.id', 'tw.place_id')
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence'])
.where('p.trip_id', '=', trip_id)
.orderBy('tw.place_id', 'asc')
.orderBy('tw.sequence', 'asc')
.execute();
}
/**
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
* VALUES (...)` for each point. The caller owns the transaction: a create
* writes the tour first, an edit clears the old points first.
*/
async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise<void> {
if (waypoints.length === 0) return;
await this.insertMany(
waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })),
);
}
/** TW4 — `DELETE FROM tour_waypoints WHERE place_id = ?`. */
async deleteForPlace(place_id: number): Promise<void> {
await this.nativeDelete({ place: place_id });
}
}
@@ -0,0 +1,231 @@
import type { Tours } from '../entities/Tours.entity';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
* its owning place's name, plus two read-model flags (`planned`: the place
* sits on at least one day; `has_waypoints`: the route editor saved control
* points for it). SQLite hands both flags back as 0/1.
*/
export interface TourListRow {
place_id: number;
name: string;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
tour_group_id: number | null;
max_hiking_difficulty: number;
planned: number;
has_waypoints: number;
}
/** Every `tours` column, as the trip copy reads and re-inserts it. */
export interface TourRow {
place_id: number;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
tour_group_id: number | null;
created_at: string | null;
max_hiking_difficulty: number;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
/** What a create or a GPX import writes; the unset metadata columns stay NULL. */
export interface TourInsert {
place_id: number;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
match_confidence: number | null;
max_hiking_difficulty: number;
}
/** What a route edit replaces on an existing tour. */
export type TourUpdate = Omit<TourInsert, 'place_id'>;
interface ToursKyselyDB {
tours: TourRow;
places: { id: number; trip_id: number; name: string };
day_assignments: { id: number; day_id: number; place_id: number };
tour_waypoints: { id: number; place_id: number };
}
/**
* `tours` — the facet that makes a place a tour (#2586). Keyed on the owning
* place, so it carries no trip of its own: every trip-scoped statement here
* joins `places` for the predicate. Deleting the place cascades the row and
* its `tour_waypoints`.
*
* Kysely throughout: the list projection needs two correlated `EXISTS`
* subqueries, and the trip-scoped update and the day check join tables the
* entity has no relation path to.
*/
export class ToursRepository extends TrekRepository<Tours> {
private db() {
return this.kysely<ToursKyselyDB>();
}
/** The shared SELECT of {@link listForTrip} and {@link findInTrip}. */
private listQuery() {
return this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.select((eb) => [
'p.id as place_id',
'p.name as name',
't.tour_type as tour_type',
't.distance as distance',
't.elevation_gain as elevation_gain',
't.elevation_loss as elevation_loss',
't.duration as duration',
't.difficulty as difficulty',
't.wanderer_ref as wanderer_ref',
't.match_confidence as match_confidence',
't.tour_group_id as tour_group_id',
't.max_hiking_difficulty as max_hiking_difficulty',
eb
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
.as('planned'),
eb
.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id'))
.as('has_waypoints'),
]);
}
/**
* TO1 — `SELECT p.id AS place_id, p.name, t.*, EXISTS(day_assignments) AS
* planned, EXISTS(tour_waypoints) AS has_waypoints FROM tours t JOIN places
* p ON p.id = t.place_id WHERE p.trip_id = ? ORDER BY t.created_at DESC`,
* newest first. The place id breaks ties inside one CURRENT_TIMESTAMP second.
*/
async listForTrip(trip_id: number): Promise<TourListRow[]> {
const rows = await this.listQuery()
.where('p.trip_id', '=', trip_id)
.orderBy('t.created_at', 'desc')
.orderBy('t.place_id', 'desc')
.execute();
return rows.map(toListRow);
}
/** TO2 — {@link listForTrip}'s projection for one place, `undefined` unless it is a tour of that trip. */
async findInTrip(trip_id: number, place_id: number): Promise<TourListRow | undefined> {
const row = await this.listQuery().where('p.trip_id', '=', trip_id).where('p.id', '=', place_id).executeTakeFirst();
return row ? toListRow(row) : undefined;
}
/** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */
async existsForPlace(place_id: number): Promise<boolean> {
const row = await this.db()
.selectFrom('tours')
.select('place_id')
.where('place_id', '=', place_id)
.executeTakeFirst();
return !!row;
}
/** TO4 — `SELECT place_id FROM tours WHERE place_id IN (...)`: which of these places are tours. */
async listTourPlaceIds(place_ids: number[]): Promise<number[]> {
if (place_ids.length === 0) return [];
const rows = await this.db().selectFrom('tours').select('place_id').where('place_id', 'in', place_ids).execute();
return rows.map((r) => r.place_id);
}
/**
* TO5 — `SELECT 1 FROM day_assignments da JOIN tours t ON t.place_id =
* da.place_id WHERE da.day_id = ? AND da.place_id = ? [AND da.id != ?]`.
*
* A tour fills its day, so one day holds it at most once. Create and move
* both ask this inside the transaction that writes the assignment; move
* passes the assignment being moved so it does not collide with itself.
* False for an ordinary place, which may sit on a day any number of times.
*/
async isOnDay(day_id: number, place_id: number, except_assignment_id?: number): Promise<boolean> {
let query = this.db()
.selectFrom('day_assignments as da')
.innerJoin('tours as t', 't.place_id', 'da.place_id')
.select('da.id')
.where('da.day_id', '=', day_id)
.where('da.place_id', '=', place_id);
if (except_assignment_id !== undefined) query = query.where('da.id', '!=', except_assignment_id);
return !!(await query.executeTakeFirst());
}
/** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */
async listRowsForTrip(trip_id: number): Promise<TourRow[]> {
return await this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.selectAll('t')
.where('p.trip_id', '=', trip_id)
.orderBy('t.place_id', 'asc')
.execute();
}
/** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */
async insertTour(input: TourInsert): Promise<void> {
await this.insert({
place: input.place_id,
tourTypeRef: input.tour_type,
distance: input.distance,
elevation_gain: input.elevation_gain,
elevation_loss: input.elevation_loss,
duration: input.duration,
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
});
}
/**
* TO8 — a copied tour keeps every column, `created_at` included, so the
* copy lists its tours in the source's order.
*/
async insertCopy(place_id: number, row: Omit<TourRow, 'place_id'>): Promise<void> {
await this.db()
.insertInto('tours')
.values({ ...row, place_id })
.execute();
}
/**
* TO9 — `UPDATE tours SET tour_type = ?, distance = ?, ... WHERE place_id =
* ? AND place_id IN (SELECT id FROM places WHERE trip_id = ?)`. Returns
* whether a row of that trip was updated.
*/
async updateInTrip(trip_id: number, place_id: number, input: TourUpdate): Promise<boolean> {
const result = await this.db()
.updateTable('tours')
.set({
tour_type: input.tour_type,
distance: input.distance,
elevation_gain: input.elevation_gain,
elevation_loss: input.elevation_loss,
duration: input.duration,
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
})
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
}
function toListRow<
R extends Omit<TourListRow, 'planned' | 'has_waypoints'> & { planned: unknown; has_waypoints: unknown },
>(r: R): TourListRow {
return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) };
}